From 7ec9e393e0d12482c9423539f99331e92c85da61 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Thu, 3 Aug 2023 16:46:28 +0200 Subject: [PATCH] Add separate protected resource page This commit adds a separate protected resource page to demonstrate how to selectively require logins. Add code to improve client performance by providing modification timestamps and Cache-Control headers for embedded static files. --- .gitignore | 1 + Makefile | 11 +- changelog.md | 2 + cmd/app/main.go | 117 +++++++++++++++++----- internal/handlers/after_logout.go | 4 +- internal/handlers/common.go | 52 ++++++---- internal/handlers/errors.go | 30 ++++-- internal/handlers/index.go | 111 ++++++++++----------- internal/handlers/login.go | 51 ++++++++++ internal/handlers/oidc_callback.go | 21 ++-- internal/handlers/protected.go | 155 +++++++++++++++++++++++++++++ internal/services/i18n.go | 31 +++++- internal/services/session.go | 7 ++ internal/services/token.go | 100 +++++++++++++++++++ translations/active.de.toml | 33 +++++- translations/active.en.toml | 18 +++- ui/templates/base.gohtml | 8 ++ ui/templates/index.gohtml | 8 +- ui/templates/protected.gohtml | 8 ++ 19 files changed, 621 insertions(+), 147 deletions(-) create mode 100644 internal/handlers/login.go create mode 100644 internal/handlers/protected.go create mode 100644 internal/services/token.go create mode 100644 ui/templates/protected.gohtml diff --git a/.gitignore b/.gitignore index edc6ac8..373e2ae 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /resource_app*.toml /sessions /static +/translations/translate.*.toml /ui/css/ /ui/images/ /ui/js/ diff --git a/Makefile b/Makefile index 42453f6..73a7e45 100644 --- a/Makefile +++ b/Makefile @@ -18,13 +18,12 @@ go.sum: go.mod go mod tidy -v translations: $(TRANSLATIONS) $(GOFILES) - cd translations ; \ - goi18n extract .. ; \ - goi18n merge active.*.toml ; \ - if translate.*.toml 2>/dev/null; then \ + if [ ! -z "$(wildcard translations/translate.*.toml)" ]; then \ echo "missing translations"; \ - goi18n merge active.*.toml translate.*.toml; \ - fi + goi18n merge -outdir translations translations/active.*.toml translations/translate.*.toml; \ + fi ; \ + goi18n extract -outdir translations . ; \ + goi18n merge -outdir translations translations/active.*.toml lint: $(GOFILES) golangci-lint run --verbose diff --git a/changelog.md b/changelog.md index 21d36fe..ddd9e4c 100644 --- a/changelog.md +++ b/changelog.md @@ -10,8 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - let the session expire when the token expires - link from logo to start page - move common page header to templates/base.gohtml +- implement caching for static resources ### Added - add identity output to index page +- add a separate protected resource page ## [0.2.0] ### Changed diff --git a/cmd/app/main.go b/cmd/app/main.go index 4afc18d..6b01b17 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -53,6 +53,37 @@ var ( date = "unknown" ) +type StaticFSWrapper struct { + http.FileSystem + ModTime time.Time +} + +func (f *StaticFSWrapper) Open(name string) (http.File, error) { + file, err := f.FileSystem.Open(name) + + return &StaticFileWrapper{File: file, fixedModTime: f.ModTime}, err //nolint:wrapcheck +} + +type StaticFileWrapper struct { + http.File + fixedModTime time.Time +} + +func (f *StaticFileWrapper) Stat() (os.FileInfo, error) { + fileInfo, err := f.File.Stat() + + return &StaticFileInfoWrapper{FileInfo: fileInfo, fixedModTime: f.fixedModTime}, err //nolint:wrapcheck +} + +type StaticFileInfoWrapper struct { + os.FileInfo + fixedModTime time.Time +} + +func (f *StaticFileInfoWrapper) ModTime() time.Time { + return f.fixedModTime +} + func main() { logger := log.New() @@ -91,22 +122,7 @@ func main() { services.AddMessages(catalog) - tlsClientConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - if config.Exists("api-client.rootCAs") { - rootCAFile := config.MustString("api-client.rootCAs") - caCertPool := x509.NewCertPool() - - pemBytes, err := os.ReadFile(rootCAFile) - if err != nil { - log.Fatalf("could not read CA certificate file: %v", err) - } - - caCertPool.AppendCertsFromPEM(pemBytes) - tlsClientConfig.RootCAs = caCertPool - } + tlsClientConfig := getTLSConfig(config) apiTransport := &http.Transport{TLSClientConfig: tlsClientConfig} apiClient := &http.Client{Transport: apiTransport} @@ -118,7 +134,7 @@ func main() { APIClient: apiClient, }) if err != nil { - log.Fatalf("OpenID Connect discovery failed: %s", err) + logger.WithError(err).Fatal("OpenID Connect discovery failed") } sessionPath, sessionAuthKey, sessionEncKey := configureSessionParameters(config) @@ -128,23 +144,38 @@ func main() { publicURL := buildPublicURL(config.MustString("server.name"), config.MustInt("server.port")) - indexHandler, err := handlers.NewIndexHandler(logger, bundle, catalog, oidcInfo, publicURL) + tokenInfoService, err := services.InitTokenInfoService(logger, oidcInfo) + if err != nil { + logger.WithError(err).Fatal("could not initialize token info service") + } + + indexHandler, err := handlers.NewIndexHandler(logger, bundle, catalog, oidcInfo, publicURL, tokenInfoService) if err != nil { logger.WithError(err).Fatal("could not initialize index handler") } + protectedResource, err := handlers.NewProtectedResourceHandler( + logger, bundle, catalog, oidcInfo, publicURL, tokenInfoService, + ) + if err != nil { + logger.WithError(err).Fatal("could not initialize protected resource handler") + } + callbackHandler := handlers.NewCallbackHandler(logger, oidcInfo.KeySet, oidcInfo.OAuth2Config) afterLogoutHandler := handlers.NewAfterLogoutHandler(logger) - staticFiles := http.FileServer(http.FS(ui.Static)) + + staticFiles := staticFileHandler(logger) router := http.NewServeMux() - router.Handle("/", authMiddleware(indexHandler)) + router.Handle("/", indexHandler) + router.Handle("/login", authMiddleware(handlers.NewLoginHandler())) + router.Handle("/protected", authMiddleware(protectedResource)) router.Handle("/callback", callbackHandler) router.Handle("/after-logout", afterLogoutHandler) router.Handle("/health", handlers.NewHealthHandler()) - router.Handle("/images/", staticFiles) - router.Handle("/css/", staticFiles) - router.Handle("/js/", staticFiles) + router.HandleFunc("/images/", staticFiles) + router.HandleFunc("/css/", staticFiles) + router.HandleFunc("/js/", staticFiles) nextRequestID := func() string { return fmt.Sprintf("%d", time.Now().UnixNano()) @@ -176,6 +207,46 @@ func main() { handlers.StartApplication(context.Background(), logger, server, publicURL, config) } +func staticFileHandler(logger *log.Logger) func(w http.ResponseWriter, r *http.Request) { + stat, err := os.Stat(os.Args[0]) + if err != nil { + logger.WithError(err).Fatal("could not use stat on binary") + } + + fileServer := http.FileServer(&StaticFSWrapper{FileSystem: http.FS(ui.Static), ModTime: stat.ModTime()}) + + staticFiles := func(w http.ResponseWriter, r *http.Request) { + w.Header().Del("Expires") + w.Header().Del("Pragma") + w.Header().Set("Cache-Control", "max-age=3600") + + fileServer.ServeHTTP(w, r) + } + + return staticFiles +} + +func getTLSConfig(config *koanf.Koanf) *tls.Config { + tlsClientConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if config.Exists("api-client.rootCAs") { + rootCAFile := config.MustString("api-client.rootCAs") + caCertPool := x509.NewCertPool() + + pemBytes, err := os.ReadFile(rootCAFile) + if err != nil { + log.Fatalf("could not read CA certificate file: %v", err) + } + + caCertPool.AppendCertsFromPEM(pemBytes) + tlsClientConfig.RootCAs = caCertPool + } + + return tlsClientConfig +} + func buildPublicURL(hostname string, port int) string { const defaultHTTPSPort = 443 diff --git a/internal/handlers/after_logout.go b/internal/handlers/after_logout.go index 6f087f3..d9dfc77 100644 --- a/internal/handlers/after_logout.go +++ b/internal/handlers/after_logout.go @@ -21,8 +21,6 @@ import ( "net/http" "github.com/sirupsen/logrus" - - "code.cacert.org/cacert/oidc-demo-app/internal/services" ) type AfterLogoutHandler struct { @@ -30,7 +28,7 @@ type AfterLogoutHandler struct { } func (h *AfterLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - session, err := services.GetSessionStore().Get(r, sessionName) + session, err := GetSession(r) if err != nil { h.logger.WithError(err).Error("could not get session") http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/internal/handlers/common.go b/internal/handlers/common.go index 9dc7da5..49a751e 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -21,14 +21,12 @@ import ( "bytes" "encoding/base64" "encoding/json" - "errors" "fmt" "net/http" "net/url" - "github.com/lestrrat-go/jwx/jwk" - "github.com/lestrrat-go/jwx/jwt" - "github.com/lestrrat-go/jwx/jwt/openid" + "github.com/gorilla/sessions" + "github.com/nicksnyder/go-i18n/v2/i18n" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" @@ -37,28 +35,27 @@ import ( ) const ( - sessionName = "resource_session" - oauth2RedirectStateLength = 8 + sessionName = "resource_app" ) func Authenticate(logger *log.Logger, oauth2Config *oauth2.Config, clientID string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session, err := services.GetSessionStore().Get(r, sessionName) + session, err := GetSession(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if _, ok := session.Values[sessionKeyIDToken]; ok { + if _, ok := session.Values[services.SessionIDToken]; ok { next.ServeHTTP(w, r) return } - session.Values[sessionRedirectTarget] = r.URL.String() + session.Values[services.SessionRedirectTarget] = r.URL.String() if err = session.Save(r, w); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -106,19 +103,34 @@ func getRequestedClaims(logger *log.Logger) string { return buf.String() } -func ParseIDToken(token string, keySet jwk.Set) (openid.Token, error) { - var ( - parsedIDToken jwt.Token - err error - ) - - if parsedIDToken, err = jwt.ParseString(token, jwt.WithKeySet(keySet), jwt.WithToken(openid.New())); err != nil { - return nil, fmt.Errorf("could not parse ID token: %w", err) +func GetSession(r *http.Request) (*sessions.Session, error) { + session, err := services.GetSessionStore().Get(r, sessionName) + if err != nil { + return nil, fmt.Errorf("could not get session") } - if v, ok := parsedIDToken.(openid.Token); ok { - return v, nil + return session, nil +} + +type I18NHandler interface { + GetBundle() *i18n.Bundle + GetCatalog() *services.MessageCatalog +} + +func GetLocalizer(h I18NHandler, r *http.Request) *i18n.Localizer { + accept := r.Header.Get("Accept-Language") + localizer := i18n.NewLocalizer(h.GetBundle(), accept) + + return localizer +} + +func BaseTemplateData(h I18NHandler, localizer *i18n.Localizer) map[string]interface{} { + msg := h.GetCatalog().LookupMessage + + data := map[string]interface{}{ + "WelcomeNavLabel": msg("IndexNavLabel", nil, localizer), + "ProtectedNavLabel": msg("ProtectedNavLabel", nil, localizer), } - return nil, errors.New("ID token is no OpenID Connect Identity Token") + return data } diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index a05ee35..16ed186 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -52,19 +52,27 @@ type ErrorBucket struct { messageCatalog *services.MessageCatalog } +func (b *ErrorBucket) GetBundle() *i18n.Bundle { + return b.bundle +} + +func (b *ErrorBucket) GetCatalog() *services.MessageCatalog { + return b.messageCatalog +} + func (b *ErrorBucket) serveHTTP(w http.ResponseWriter, r *http.Request) { if b.errorDetails != nil { - accept := r.Header.Get("Accept-Language") - localizer := i18n.NewLocalizer(b.bundle, accept) - - err := b.templates.Lookup("base").Execute(w, map[string]interface{}{ - "Title": b.messageCatalog.LookupMessage( - "ErrorTitle", - nil, - localizer, - ), - "details": b.errorDetails, - }) + localizer := GetLocalizer(b, r) + + data := BaseTemplateData(b, localizer) + data["Title"] = b.messageCatalog.LookupMessage( + "ErrorTitle", + nil, + localizer, + ) + data["details"] = b.errorDetails + + err := b.templates.Lookup("base").Execute(w, data) if err != nil { log.WithError(err).Error("error rendering error template") http.Error( diff --git a/internal/handlers/index.go b/internal/handlers/index.go index bfb9798..381b888 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -23,7 +23,6 @@ import ( "net/http" "net/url" - "github.com/lestrrat-go/jwx/jwk" "github.com/nicksnyder/go-i18n/v2/i18n" log "github.com/sirupsen/logrus" @@ -36,98 +35,95 @@ type IndexHandler struct { bundle *i18n.Bundle indexTemplate *template.Template logger *log.Logger - keySet jwk.Set logoutURL string messageCatalog *services.MessageCatalog publicURL string + tokenInfo *services.TokenInfoService } -func (h *IndexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) +func (h *IndexHandler) GetBundle() *i18n.Bundle { + return h.bundle +} + +func (h *IndexHandler) GetCatalog() *services.MessageCatalog { + return h.messageCatalog +} + +func (h *IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } - if request.URL.Path != "/" { - http.NotFound(writer, request) + if r.URL.Path != "/" { + http.NotFound(w, r) return } - accept := request.Header.Get("Accept-Language") - localizer := i18n.NewLocalizer(h.bundle, accept) - - writer.WriteHeader(http.StatusOK) + localizer := GetLocalizer(h, r) - session, err := services.GetSessionStore().Get(request, sessionName) + logoutURL, err := url.Parse(h.logoutURL) if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - logoutURL, err := url.Parse(h.logoutURL) + session, err := GetSession(r) if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - var ( - accessToken string - refreshToken string - idToken string - ok bool - ) + tokenInfo, err := h.tokenInfo.GetTokenInfo(session) + if err != nil { + h.logger.WithError(err).Error("failed to get token info for request") - if accessToken, ok = session.Values[sessionKeyAccessToken].(string); ok { - h.logger.WithField("access_token", accessToken).Info("found access token in session") - } + http.Error(w, err.Error(), http.StatusInternalServerError) - if refreshToken, ok = session.Values[refreshToken].(string); ok { - h.logger.WithField("refresh_token", refreshToken).Info("found refresh token in session") + return } - if idToken, ok = session.Values[sessionKeyIDToken].(string); ok { - logoutURL.RawQuery = url.Values{ - "id_token_hint": []string{idToken}, - "post_logout_redirect_uri": []string{fmt.Sprintf("%s/after-logout", h.publicURL)}, - }.Encode() - } else { - return + if !tokenInfo.Expires.IsZero() { + h.logger.WithField("expires", tokenInfo.Expires).Info("id token expires at") } - oidcToken, err := ParseIDToken(idToken, h.keySet) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) + w.Header().Add("Content-Type", "text/html") - return - } + msgLookup := h.messageCatalog.LookupMessage - expires := oidcToken.Expiration() + data := BaseTemplateData(h, localizer) - h.logger.WithField("expires", expires).Info("id token expires at") + data["Title"] = msgLookup("IndexTitle", nil, localizer) + data["Greeting"] = msgLookup("GreetingAnonymous", nil, localizer) + data["LoginLabel"] = msgLookup("LoginLabel", nil, localizer) + data["IntroductionText"] = msgLookup("IndexIntroductionText", nil, localizer) + data["IsAuthenticated"] = false - writer.Header().Add("Content-Type", "text/html") + if tokenInfo.IDToken != "" { + logoutURL.RawQuery = url.Values{ + "id_token_hint": []string{tokenInfo.IDToken}, + "post_logout_redirect_uri": []string{fmt.Sprintf("%s/after-logout", h.publicURL)}, + }.Encode() - msgLookup := h.messageCatalog.LookupMessage + data["Greeting"] = msgLookup( + "GreetingAuthenticated", map[string]interface{}{"Name": tokenInfo.Name}, localizer, + ) + data["LogoutLabel"] = msgLookup("LogoutLabel", nil, localizer) + data["LogoutURL"] = logoutURL.String() + data["IsAuthenticated"] = true + data["AuthenticatedAs"] = msgLookup("AuthenticatedAs", map[string]interface{}{ + "Name": tokenInfo.Name, + "Email": tokenInfo.Email, + }, localizer) + } - err = h.indexTemplate.Lookup("base").Execute(writer, map[string]interface{}{ - "Title": msgLookup("IndexTitle", nil, localizer), - "Greeting": msgLookup("IndexGreeting", map[string]interface{}{ - "User": oidcToken.Name(), - }, localizer), - "IntroductionText": msgLookup("IndexIntroductionText", nil, localizer), - "LogoutLabel": msgLookup("LogoutLabel", nil, localizer), - "LogoutURL": logoutURL.String(), - "AuthenticatedAs": msgLookup("AuthenticatedAs", map[string]interface{}{ - "Name": oidcToken.Name(), - "Email": oidcToken.Email(), - }, localizer), - }) + err = h.indexTemplate.Lookup("base").Execute(w, data) if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -139,6 +135,7 @@ func NewIndexHandler( catalog *services.MessageCatalog, oidcInfo *services.OIDCInformation, publicURL string, + tokenInfoService *services.TokenInfoService, ) (*IndexHandler, error) { indexTemplate, err := template.ParseFS( ui.Templates, @@ -151,8 +148,8 @@ func NewIndexHandler( logger: logger, bundle: bundle, indexTemplate: indexTemplate, - keySet: oidcInfo.KeySet, logoutURL: oidcInfo.OIDCConfiguration.EndSessionEndpoint, + tokenInfo: tokenInfoService, messageCatalog: catalog, publicURL: publicURL, }, nil diff --git a/internal/handlers/login.go b/internal/handlers/login.go new file mode 100644 index 0000000..a9f9064 --- /dev/null +++ b/internal/handlers/login.go @@ -0,0 +1,51 @@ +/* +Copyright 2020-2023 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "net/http" +) + +type LoginHandler struct{} + +func (l *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + + if r.URL.Path != "/login" { + http.NotFound(w, r) + + return + } + + next := r.URL.Query().Get("next") + if next != "" { + http.Redirect(w, r, next, http.StatusFound) + + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} + +func NewLoginHandler() *LoginHandler { + return &LoginHandler{} +} diff --git a/internal/handlers/oidc_callback.go b/internal/handlers/oidc_callback.go index c07360b..f89716a 100644 --- a/internal/handlers/oidc_callback.go +++ b/internal/handlers/oidc_callback.go @@ -30,13 +30,6 @@ import ( "code.cacert.org/cacert/oidc-demo-app/internal/services" ) -const ( - sessionKeyAccessToken = iota - sessionKeyRefreshToken - sessionKeyIDToken - sessionRedirectTarget -) - type OidcCallbackHandler struct { keySet jwk.Set logger *log.Logger @@ -72,9 +65,9 @@ func (c *OidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - session, err := services.GetSessionStore().Get(r, "resource_session") + session, err := GetSession(r) if err != nil { - c.logger.WithError(err).Error("could not get session store") + c.logger.WithError(err).Error("could not get session") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -96,7 +89,7 @@ func (c *OidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - if redirectTarget, ok := session.Values[sessionRedirectTarget]; ok { + if redirectTarget, ok := session.Values[services.SessionRedirectTarget]; ok { if v, ok := redirectTarget.(string); ok { w.Header().Set("Location", v) w.WriteHeader(http.StatusFound) @@ -131,8 +124,8 @@ func (c *OidcCallbackHandler) storeTokens( session *sessions.Session, tok *oauth2.Token, ) error { - session.Values[sessionKeyAccessToken] = tok.AccessToken - session.Values[sessionKeyRefreshToken] = tok.RefreshToken + session.Values[services.SessionAccessToken] = tok.AccessToken + session.Values[services.SessionRefreshToken] = tok.RefreshToken idTokenValue := tok.Extra("id_token") @@ -141,11 +134,11 @@ func (c *OidcCallbackHandler) storeTokens( return fmt.Errorf("ID token value %v is not a string", idTokenValue) } - session.Values[sessionKeyIDToken] = idToken + session.Values[services.SessionIDToken] = idToken session.Options.MaxAge = int(time.Until(tok.Expiry).Seconds()) - oidcToken, err := ParseIDToken(idToken, c.keySet) + oidcToken, err := services.ParseIDToken(idToken, c.keySet) if err != nil { return fmt.Errorf("could not parse ID token: %w", err) } diff --git a/internal/handlers/protected.go b/internal/handlers/protected.go new file mode 100644 index 0000000..401b230 --- /dev/null +++ b/internal/handlers/protected.go @@ -0,0 +1,155 @@ +/* +Copyright 2020-2023 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "fmt" + "html/template" + "net/http" + "net/url" + + "github.com/nicksnyder/go-i18n/v2/i18n" + log "github.com/sirupsen/logrus" + + "code.cacert.org/cacert/oidc-demo-app/internal/services" + "code.cacert.org/cacert/oidc-demo-app/ui" +) + +type ProtectedResource struct { + bundle *i18n.Bundle + logger *log.Logger + protectedTemplate *template.Template + logoutURL string + tokenInfo *services.TokenInfoService + messageCatalog *services.MessageCatalog + publicURL string +} + +func (h *ProtectedResource) GetBundle() *i18n.Bundle { + return h.bundle +} + +func (h *ProtectedResource) GetCatalog() *services.MessageCatalog { + return h.messageCatalog +} + +func (h *ProtectedResource) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + + if r.URL.Path != "/protected" { + http.NotFound(w, r) + + return + } + + localizer := GetLocalizer(h, r) + + logoutURL, err := url.Parse(h.logoutURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + session, err := GetSession(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + tokenInfo, err := h.tokenInfo.GetTokenInfo(session) + if err != nil { + h.logger.WithError(err).Error("failed to get token info for request") + + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + if !tokenInfo.Expires.IsZero() { + h.logger.WithField("expires", tokenInfo.Expires).Info("id token expires at") + } + + if tokenInfo.IDToken == "" { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + + return + } + + w.Header().Add("Content-Type", "text/html") + + msgLookup := h.messageCatalog.LookupMessage + + logoutURL.RawQuery = url.Values{ + "id_token_hint": []string{tokenInfo.IDToken}, + "post_logout_redirect_uri": []string{fmt.Sprintf("%s/after-logout", h.publicURL)}, + }.Encode() + + data := BaseTemplateData(h, localizer) + + data["Title"] = msgLookup("IndexTitle", nil, localizer) + data["Greeting"] = msgLookup( + "GreetingAuthenticated", map[string]interface{}{"Name": tokenInfo.Name}, localizer, + ) + data["LoginLabel"] = msgLookup("LoginLabel", nil, localizer) + data["IntroductionText"] = msgLookup("ProtectedIntroductionText", nil, localizer) + data["IsAuthenticated"] = true + data["LogoutLabel"] = msgLookup("LogoutLabel", nil, localizer) + data["LogoutURL"] = logoutURL.String() + data["AuthenticatedAs"] = msgLookup("AuthenticatedAs", map[string]interface{}{ + "Name": tokenInfo.Name, + "Email": tokenInfo.Email, + }, localizer) + + err = h.protectedTemplate.Lookup("base").Execute(w, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } +} + +func NewProtectedResourceHandler( + logger *log.Logger, + bundle *i18n.Bundle, + catalog *services.MessageCatalog, + oidcInfo *services.OIDCInformation, + publicURL string, tokenInfoService *services.TokenInfoService, +) (*ProtectedResource, error) { + protectedTemplate, err := template.ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/protected.gohtml") + if err != nil { + return nil, fmt.Errorf("could not parse templates: %w", err) + } + + return &ProtectedResource{ + logger: logger, + bundle: bundle, + protectedTemplate: protectedTemplate, + logoutURL: oidcInfo.OIDCConfiguration.EndSessionEndpoint, + tokenInfo: tokenInfoService, + messageCatalog: catalog, + publicURL: publicURL, + }, nil +} diff --git a/internal/services/i18n.go b/internal/services/i18n.go index 2fd26bd..ea48635 100644 --- a/internal/services/i18n.go +++ b/internal/services/i18n.go @@ -37,14 +37,23 @@ func AddMessages(catalog *MessageCatalog) { Other: "The identity provider authenticated your identity as {{ .Name }}" + " with the email address {{ .Email }}.", } - messages["IndexGreeting"] = &i18n.Message{ - ID: "IndexGreeting", - Other: "Hello {{ .User }}", + messages["GreetingAnonymous"] = &i18n.Message{ + ID: "GreetingAnonymous", + Other: "Hello", + } + messages["GreetingAuthenticated"] = &i18n.Message{ + ID: "GreetingAuthenticated", + Other: "Hello {{ .Name }}", } messages["IndexTitle"] = &i18n.Message{ ID: "IndexTitle", Other: "Welcome to the Demo application", } + messages["LoginLabel"] = &i18n.Message{ + ID: "LoginLabel", + Description: "A label on a login button or link", + Other: "Login", + } messages["LogoutLabel"] = &i18n.Message{ ID: "LogoutLabel", Description: "A label on a logout button or link", @@ -52,7 +61,21 @@ func AddMessages(catalog *MessageCatalog) { } messages["IndexIntroductionText"] = &i18n.Message{ ID: "IndexIntroductionText", - Other: "This is an authorization protected resource", + Other: "This is a public resource.", + } + messages["IndexNavLabel"] = &i18n.Message{ + ID: "IndexNavLabel", + Description: "Label for the index page in the top navigation", + Other: "Welcome", + } + messages["ProtectedIntroductionText"] = &i18n.Message{ + ID: "ProtectedIntroductionText", + Other: "This is an authorization protected resource.", + } + messages["ProtectedNavLabel"] = &i18n.Message{ + ID: "ProtectedNavLabel", + Description: "Label for the protected resource page in the top navigation", + Other: "Protected area", } catalog.AddMessages(messages) diff --git a/internal/services/session.go b/internal/services/session.go index b8c10f0..a07947a 100644 --- a/internal/services/session.go +++ b/internal/services/session.go @@ -26,6 +26,13 @@ import ( var store *sessions.FilesystemStore +const ( + SessionAccessToken = iota + SessionRefreshToken + SessionIDToken + SessionRedirectTarget +) + func InitSessionStore(logger *log.Logger, sessionPath string, keys ...[]byte) { store = sessions.NewFilesystemStore(sessionPath, keys...) diff --git a/internal/services/token.go b/internal/services/token.go new file mode 100644 index 0000000..ff1ad4c --- /dev/null +++ b/internal/services/token.go @@ -0,0 +1,100 @@ +/* +Copyright 2023 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package services + +import ( + "errors" + "fmt" + "time" + + "github.com/gorilla/sessions" + "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/jwt" + "github.com/lestrrat-go/jwx/jwt/openid" + log "github.com/sirupsen/logrus" +) + +type OIDCTokenInfo struct { + AccessToken string + IDToken string + RefreshToken string + Expires time.Time + Name string + Email string +} + +type TokenInfoService struct { + logger *log.Logger + keySet jwk.Set +} + +func (s *TokenInfoService) GetTokenInfo(session *sessions.Session) (*OIDCTokenInfo, error) { + tokenInfo := &OIDCTokenInfo{} + + var ok bool + + if tokenInfo.AccessToken, ok = session.Values[SessionAccessToken].(string); ok { + s.logger.WithField("access_token", tokenInfo.AccessToken).Debug("found access token in session") + } + + if tokenInfo.RefreshToken, ok = session.Values[SessionRefreshToken].(string); ok { + s.logger.WithField("refresh_token", tokenInfo.RefreshToken).Debug("found refresh token in session") + } + + if tokenInfo.IDToken, ok = session.Values[SessionIDToken].(string); ok { + s.logger.WithField("id_token", tokenInfo.IDToken).Debug("found ID token in session") + } + + if tokenInfo.IDToken == "" { + return tokenInfo, nil + } + + oidcToken, err := ParseIDToken(tokenInfo.IDToken, s.keySet) + if err != nil { + return nil, fmt.Errorf("could not parse ID token: %w", err) + } + + tokenInfo.Expires = oidcToken.Expiration() + + tokenInfo.Name = oidcToken.Name() + + tokenInfo.Email = oidcToken.Email() + + return tokenInfo, nil +} + +func InitTokenInfoService(logger *log.Logger, oidcInfo *OIDCInformation) (*TokenInfoService, error) { + return &TokenInfoService{logger: logger, keySet: oidcInfo.KeySet}, nil +} + +func ParseIDToken(token string, keySet jwk.Set) (openid.Token, error) { + var ( + parsedIDToken jwt.Token + err error + ) + + if parsedIDToken, err = jwt.ParseString(token, jwt.WithKeySet(keySet), jwt.WithToken(openid.New())); err != nil { + return nil, fmt.Errorf("could not parse ID token: %w", err) + } + + if v, ok := parsedIDToken.(openid.Token); ok { + return v, nil + } + + return nil, errors.New("ID token is no OpenID Connect Identity Token") +} diff --git a/translations/active.de.toml b/translations/active.de.toml index 6e38b1d..343e226 100644 --- a/translations/active.de.toml +++ b/translations/active.de.toml @@ -6,19 +6,42 @@ other = "Der Identity-Provider hat dich als {{ .Name }} mit der E-Mail-Adresse { hash = "sha1-736aec25a98f5ec5b71400bb0163f891f509b566" other = "Es ist ein Fehler aufgetreten" -[IndexGreeting] -hash = "sha1-d4a13058e497fa24143ea96d50d82b818455ef61" -other = "Hallo {{ .User }}" +[GreetingAnonymous] +hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0" +other = "Hallo" + +[GreetingAuthenticated] +hash = "sha1-22e08bfee49f285ac06df7d582c2a65bab86fa35" +other = "Hallo {{ .Name }}" [IndexIntroductionText] -hash = "sha1-c2c530e263fc9c38482338ed290aafb496794179" -other = "Das ist eine zugriffsgeschützte Resource" +hash = "sha1-e190189ce0b76d957315332b6ca336f90a4d3d8c" +other = "Das ist eine öffentliche Resource." + +[IndexNavLabel] +description = "Label for the index page in the top navigation" +hash = "sha1-f99709e3c9205f21ca31811feec86519b6c1b452" +other = "Willkommen" [IndexTitle] hash = "sha1-eccb2b889c068d3f25496c1dad3fb0f88d021bd9" other = "Willkommen in der Demo-Anwendung" +[LoginLabel] +description = "A label on a login button or link" +hash = "sha1-2049c9ee0610e70f7316f98415755b58067e68ee" +other = "Anmelden" + [LogoutLabel] description = "A label on a logout button or link" hash = "sha1-8acfdeb9a8286f00c8e5dd48471cfdc994807579" other = "Ausloggen" + +[ProtectedIntroductionText] +hash = "sha1-87cd7874f28dfcebcb5460d30b6c2c78dff8f6a4" +other = "Das ist eine zugriffsgeschützte Resource." + +[ProtectedNavLabel] +description = "Label for the protected resource page in the top navigation" +hash = "sha1-185c4cf5675f44de72ff76f16e6cab2a82afa752" +other = "Geschützter Bereich" diff --git a/translations/active.en.toml b/translations/active.en.toml index 23a9930..9a3d6f4 100644 --- a/translations/active.en.toml +++ b/translations/active.en.toml @@ -1,9 +1,23 @@ AuthenticatedAs = "The identity provider authenticated your identity as {{ .Name }} with the email address {{ .Email }}." ErrorTitle = "An error has occurred" -IndexGreeting = "Hello {{ .User }}" -IndexIntroductionText = "This is an authorization protected resource" +GreetingAnonymous = "Hello" +GreetingAuthenticated = "Hello {{ .Name }}" +IndexIntroductionText = "This is a public resource." IndexTitle = "Welcome to the Demo application" +ProtectedIntroductionText = "This is an authorization protected resource." + +[IndexNavLabel] +description = "Label for the index page in the top navigation" +other = "Welcome" + +[LoginLabel] +description = "A label on a login button or link" +other = "Login" [LogoutLabel] description = "A label on a logout button or link" other = "Logout" + +[ProtectedNavLabel] +description = "Label for the protected resource page in the top navigation" +other = "Protected area" diff --git a/ui/templates/base.gohtml b/ui/templates/base.gohtml index b31d6fa..084aae0 100644 --- a/ui/templates/base.gohtml +++ b/ui/templates/base.gohtml @@ -29,6 +29,14 @@
CAcert +
{{ template "content" . }}