From 44e18ca3a59d5a562431fc8bff3bf40a824da62a Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Mon, 7 Aug 2023 15:15:45 +0200 Subject: [PATCH] Implement consent management The primary change in this commit is the introduction of consent management. A few minor improvements have been made: - move common header to ui/templates/base.gohtml - add an I18NService to unify localization - add a handlers.getLocalizer function - fix translation extraction and merging in Makefile - add a new AuthMiddleware to centralize client certificate authentication - move client certificate handling to internal/handlers/security.go - improver error handling, allow localization of HTTP error messages --- .gitignore | 7 +- Makefile | 11 +- changelog.md | 4 + cmd/idp/main.go | 27 +- go.mod | 1 + go.sum | 1 + internal/handlers/common.go | 149 +++++++ internal/handlers/consent.go | 54 +-- internal/handlers/error.go | 74 ++-- internal/handlers/login.go | 115 +----- internal/handlers/logout.go | 47 +-- internal/handlers/manage.go | 370 ++++++++++++++++++ internal/handlers/security.go | 33 ++ internal/services/i18n.go | 357 ++++++++++------- translations/active.de.toml | 81 ++++ translations/active.en.toml | 45 +++ ui/templates/base.gohtml | 7 +- ui/templates/client_certificate.gohtml | 57 +-- ui/templates/confirm_revoke.gohtml | 11 + ui/templates/consent.gohtml | 53 +-- ui/templates/errors.gohtml | 20 +- ui/templates/index.gohtml | 6 + ui/templates/manage_consent.gohtml | 47 +++ ui/templates/no_challenge_in_request.gohtml | 9 +- .../no_email_in_client_certificate.gohtml | 9 +- 25 files changed, 1137 insertions(+), 458 deletions(-) create mode 100644 internal/handlers/manage.go create mode 100644 ui/templates/confirm_revoke.gohtml create mode 100644 ui/templates/index.gohtml create mode 100644 ui/templates/manage_consent.gohtml diff --git a/.gitignore b/.gitignore index 8af3e6d..b9030fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ *.pem -.idea/ +/.idea/ /cacert-idp +/certs/ /dist/ /idp.toml /static +/translations/translate.*.toml /ui/css/ /ui/images/ -/ui/js/ -certs/ \ No newline at end of file +/ui/js/ \ No newline at end of file diff --git a/Makefile b/Makefile index 0de8c6a..8f3f773 100644 --- a/Makefile +++ b/Makefile @@ -18,13 +18,12 @@ go.sum: go.mod go mod tidy 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 3efaccf..1ca466e 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - use a session to transport data from the login to the consent screens - implement skip of consent screen for existing consent - adapt to Hydra 2.x +- introduce a central template cache +- move common page header to templates/base.gohtml +### Added +- add management of consent to allow users to check and revoke consent ## [0.2.1] - 2023-08-03 ### Changed diff --git a/cmd/idp/main.go b/cmd/idp/main.go index c83b6b9..47ab89a 100644 --- a/cmd/idp/main.go +++ b/cmd/idp/main.go @@ -88,9 +88,9 @@ func main() { "version": version, "commit": commit, "date": date, }).Info("Starting CAcert OpenID Connect Identity Provider") logger.Infoln("Server is starting") - bundle, catalog := services.InitI18n(logger, config.Strings("i18n.languages")) + i18nService := services.InitI18n(logger, config.Strings("i18n.languages")) - if err = services.AddMessages(catalog); err != nil { + if err = i18nService.AddMessages(); err != nil { logger.WithError(err).Fatal("could not add messages for i18n") } @@ -102,15 +102,26 @@ func main() { logger.WithError(err).Fatal("could not configure Hydra admin client") } - loginHandler := handlers.NewLoginHandler(logger, bundle, catalog, clientTransport.OAuth2Api) - consentHandler := handlers.NewConsentHandler(logger, bundle, catalog, clientTransport.OAuth2Api) - logoutHandler := handlers.NewLogoutHandler(logger, clientTransport.OAuth2Api) + tc := handlers.PopulateTemplateCache() - logoutSuccessHandler := handlers.NewLogoutSuccessHandler(logger, bundle, catalog) - errorHandler := handlers.NewErrorHandler(logger, bundle, catalog) + needsAuth := handlers.NewAuthMiddleware(logger, tc, i18nService).NeedsAuth + + indexHandler := handlers.NewIndex(logger, tc, i18nService) + manageConsentHandler := handlers.NewManageConsent(logger, tc, i18nService, clientTransport.OAuth2Api) + revokeConsentHandler := handlers.NewRevokeConsent(logger, tc, i18nService, clientTransport.OAuth2Api) + + loginHandler := handlers.NewLoginHandler(logger, tc, i18nService, clientTransport.OAuth2Api) + consentHandler := handlers.NewConsentHandler(logger, tc, i18nService, clientTransport.OAuth2Api) + logoutHandler := handlers.NewLogout(logger, clientTransport.OAuth2Api) + + logoutSuccessHandler := handlers.NewLogoutSuccess(logger, tc, i18nService) + errorHandler := handlers.NewErrorHandler(logger, i18nService) staticFiles := http.FileServer(http.FS(ui.Static)) router := http.NewServeMux() + router.HandleFunc("/", needsAuth(indexHandler)) + router.Handle("/manage-consent", needsAuth(manageConsentHandler)) + router.Handle("/revoke-consent/", needsAuth(revokeConsentHandler)) router.Handle("/login", loginHandler) router.Handle("/consent", consentHandler) router.Handle("/logout", logoutHandler) @@ -139,7 +150,7 @@ func main() { csrf.SameSite(csrf.SameSiteStrictMode), csrf.MaxAge(DefaultCSRFMaxAge)) - errorMiddleware, err := handlers.ErrorHandling(logger, ui.Templates, bundle, catalog) + errorMiddleware, err := handlers.ErrorHandling(logger, tc, i18nService) if err != nil { logger.WithError(err).Fatal("could not initialize request error handling") } diff --git a/go.mod b/go.mod index 11e5c64..0ab9e08 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.19 require ( github.com/BurntSushi/toml v1.3.2 + github.com/dustin/go-humanize v1.0.0 github.com/go-playground/form/v4 v4.2.1 github.com/gorilla/csrf v1.7.1 github.com/gorilla/sessions v1.2.1 diff --git a/go.sum b/go.sum index 4f3d5ef..d723b98 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/internal/handlers/common.go b/internal/handlers/common.go index b7ed3ba..cb67a73 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -18,10 +18,18 @@ limitations under the License. package handlers import ( + "bytes" + "context" "fmt" + "html/template" "net/http" + "github.com/dustin/go-humanize" "github.com/gorilla/sessions" + "github.com/nicksnyder/go-i18n/v2/i18n" + log "github.com/sirupsen/logrus" + + "code.cacert.org/cacert/oidc-idp/ui" "code.cacert.org/cacert/oidc-idp/internal/services" ) @@ -36,3 +44,144 @@ func GetSession(r *http.Request) (*sessions.Session, error) { return session, nil } + +type AuthMiddleware struct { + logger *log.Logger + trans *services.I18NService + templates TemplateCache +} + +func getLocalizer(trans *services.I18NService, r *http.Request) *i18n.Localizer { + accept := r.Header.Get("Accept-Language") + + return trans.Localizer(accept) +} + +type authContextKey int + +const ctxKeyAddresses authContextKey = iota + +func (a *AuthMiddleware) NeedsAuth(handler http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, addresses := getDataFromClientCert(a.logger, r) + + if len(addresses) < 1 { + renderNoEmailsInClientCertificate(a.logger, a.templates, a.trans, w, getLocalizer(a.trans, r)) + + return + } + + handler.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyAddresses, addresses))) + } +} + +func GetAuthenticatedAddresses(r *http.Request) []string { + if addresses, ok := r.Context().Value(ctxKeyAddresses).([]string); ok { + return addresses + } + + return nil +} + +func NewAuthMiddleware(logger *log.Logger, tc TemplateCache, trans *services.I18NService) *AuthMiddleware { + return &AuthMiddleware{logger: logger, trans: trans, templates: tc} +} + +type templateName string + +const ( + CertificateLogin templateName = "cert" + ConfirmRevoke templateName = "confirm_revoke" + Consent templateName = "consent" + Error templateName = "error" + Index templateName = "index" + LogoutSuccessful templateName = "logout_successful" + ManageConsent templateName = "manage_consent" + NoChallengeInRequest templateName = "no_challenge" + NoEmailsInClientCertificate templateName = "no_emails" +) + +type TemplateCache map[templateName]*template.Template + +func (c TemplateCache) render( + logger *log.Logger, w http.ResponseWriter, name templateName, params map[string]interface{}, +) { + rendered := bytes.NewBuffer(make([]byte, 0)) + + err := c[name].Lookup("base").Execute(rendered, params) + if err != nil { + logger.WithError(err).Error("template rendering failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + _, _ = w.Write(rendered.Bytes()) +} + +func PopulateTemplateCache() TemplateCache { + funcMap := map[string]any{"humantime": humanize.Time} + + cache := TemplateCache{ + CertificateLogin: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/client_certificate.gohtml", + )), + ConfirmRevoke: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/confirm_revoke.gohtml", + )), + NoEmailsInClientCertificate: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/no_email_in_client_certificate.gohtml", + )), + NoChallengeInRequest: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/no_challenge_in_request.gohtml", + )), + Consent: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/consent.gohtml", + )), + LogoutSuccessful: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/logout_successful.gohtml", + )), + Index: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/index.gohtml", + )), + ManageConsent: template.Must(template.New("").Funcs(funcMap).ParseFS( + ui.Templates, + "templates/base.gohtml", "templates/manage_consent.gohtml", + )), + Error: template.Must(template.ParseFS( + ui.Templates, + "templates/base.gohtml", + "templates/errors.gohtml", + )), + } + + return cache +} + +func renderNoEmailsInClientCertificate( + logger *log.Logger, + templates TemplateCache, + trans *services.I18NService, + w http.ResponseWriter, + localizer *i18n.Localizer, +) { + msg := trans.LookupMessage + + err := templates[NoEmailsInClientCertificate].Lookup("base").Execute(w, map[string]interface{}{ + "Title": msg("NoEmailsInClientCertificateTitle", nil, localizer), + "Explanation": msg("NoEmailsInClientCertificateExplanation", nil, localizer), + }) + if err != nil { + logger.WithError(err).Error("template rendering failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } +} diff --git a/internal/handlers/consent.go b/internal/handlers/consent.go index 417803c..d2e8cce 100644 --- a/internal/handlers/consent.go +++ b/internal/handlers/consent.go @@ -36,17 +36,14 @@ import ( log "github.com/sirupsen/logrus" "code.cacert.org/cacert/oidc-idp/internal/models" - "code.cacert.org/cacert/oidc-idp/ui" - "code.cacert.org/cacert/oidc-idp/internal/services" ) type ConsentHandler struct { - adminClient client.OAuth2Api - bundle *i18n.Bundle - consentTemplate *template.Template - logger *log.Logger - messageCatalog *services.MessageCatalog + logger *log.Logger + trans *services.I18NService + adminClient client.OAuth2Api + templates TemplateCache } type ConsentInformation struct { @@ -113,8 +110,7 @@ func (h *ConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.logger.WithField("consent_challenge", challenge).Debug("received consent challenge") - accept := r.Header.Get("Accept-Language") - localizer := i18n.NewLocalizer(h.bundle, accept) + localizer := getLocalizer(h.trans, r) // retrieve consent information consentData, requestedClaims, err := h.getRequestedConsentInformation(challenge, r) @@ -170,10 +166,7 @@ func (h *ConsentHandler) handleGet( return } - if err := h.renderConsentForm(w, r, consentData, requestedClaims, localizer); err != nil { - h.logger.WithError(err).Error("could not render consent form") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } + h.renderConsentForm(w, r, consentData, requestedClaims, localizer) } func (h *ConsentHandler) handlePost( @@ -363,18 +356,18 @@ func (h *ConsentHandler) renderConsentForm( consentRequest *client.OAuth2ConsentRequest, claims *models.OIDCClaimsRequest, localizer *i18n.Localizer, -) error { +) { trans := func(id string, values ...map[string]interface{}) string { if len(values) > 0 { - return h.messageCatalog.LookupMessage(id, values[0], localizer) + return h.trans.LookupMessage(id, values[0], localizer) } - return h.messageCatalog.LookupMessage(id, nil, localizer) + return h.trans.LookupMessage(id, nil, localizer) } // render consent form oAuth2Client := consentRequest.Client - err := h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{ + h.templates.render(h.logger, w, Consent, map[string]interface{}{ "Title": trans("TitleRequestConsent"), csrf.TemplateTag: csrf.TemplateField(r), "errors": map[string]string{}, @@ -395,12 +388,6 @@ func (h *ConsentHandler) renderConsentForm( "client": oAuth2Client.GetClientName(), })), }) - - if err != nil { - return fmt.Errorf("rendering failed: %w", err) - } - - return nil } type scopeWithLabel struct { @@ -601,23 +588,12 @@ func (h *ConsentHandler) parseUserInfoClaims( } func NewConsentHandler( - logger *log.Logger, - bundle *i18n.Bundle, - messageCatalog *services.MessageCatalog, - adminClient client.OAuth2Api, + logger *log.Logger, templateCache TemplateCache, trans *services.I18NService, adminClient client.OAuth2Api, ) *ConsentHandler { - consentTemplate := template.Must( - template.ParseFS( - ui.Templates, - "templates/base.gohtml", - "templates/consent.gohtml", - )) - return &ConsentHandler{ - adminClient: adminClient, - bundle: bundle, - consentTemplate: consentTemplate, - logger: logger, - messageCatalog: messageCatalog, + logger: logger, + trans: trans, + adminClient: adminClient, + templates: templateCache, } } diff --git a/internal/handlers/error.go b/internal/handlers/error.go index df43be5..1183891 100644 --- a/internal/handlers/error.go +++ b/internal/handlers/error.go @@ -22,10 +22,8 @@ import ( "context" "fmt" "html/template" - "io/fs" "net/http" - "github.com/nicksnyder/go-i18n/v2/i18n" log "github.com/sirupsen/logrus" "code.cacert.org/cacert/oidc-idp/internal/services" @@ -46,34 +44,24 @@ type ErrorDetails struct { } type ErrorBucket struct { - errorDetails *ErrorDetails - templates *template.Template - logger *log.Logger - bundle *i18n.Bundle - messageCatalog *services.MessageCatalog + logger *log.Logger + trans *services.I18NService + errorDetails *ErrorDetails + templates TemplateCache } 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( + localizer := getLocalizer(b.trans, r) + + b.templates.render(b.logger, w, Error, map[string]interface{}{ + "Title": b.trans.LookupMessage( "ErrorTitle", nil, localizer, ), "details": b.errorDetails, }) - - if err != nil { - log.Errorf("error rendering error template: %v", err) - http.Error( - w, - http.StatusText(http.StatusInternalServerError), - http.StatusInternalServerError, - ) - } } } @@ -102,9 +90,7 @@ func (w *errorResponseWriter) WriteHeader(code int) { if code >= http.StatusBadRequest { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.errorBucket.AddError(&ErrorDetails{ - ErrorMessage: http.StatusText(code), - }) + w.errorBucket.AddError(&ErrorDetails{ErrorCode: "HTTP error"}) } w.ResponseWriter.WriteHeader(code) @@ -133,26 +119,15 @@ func (w *errorResponseWriter) Write(content []byte) (int, error) { func ErrorHandling( logger *log.Logger, - templateFS fs.FS, - bundle *i18n.Bundle, - messageCatalog *services.MessageCatalog, + templateCache TemplateCache, + trans *services.I18NService, ) (func(http.Handler) http.Handler, error) { - errorTemplates, err := template.ParseFS( - templateFS, - "templates/base.gohtml", - "templates/errors.gohtml", - ) - if err != nil { - return nil, fmt.Errorf("could not parse templates: %w", err) - } - return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { errorBucket := &ErrorBucket{ - templates: errorTemplates, - logger: logger, - bundle: bundle, - messageCatalog: messageCatalog, + logger: logger, + trans: trans, + templates: templateCache, } next.ServeHTTP( &errorResponseWriter{w, errorBucket, http.StatusOK}, @@ -164,10 +139,9 @@ func ErrorHandling( } type ErrorHandler struct { - logger *log.Logger - bundle *i18n.Bundle - messageCatalog *services.MessageCatalog - template *template.Template + logger *log.Logger + trans *services.I18NService + template *template.Template } func (h *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -177,8 +151,7 @@ func (h *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - accept := r.Header.Get("Accept-Language") - localizer := i18n.NewLocalizer(h.bundle, accept) + localizer := getLocalizer(h.trans, r) errorName := r.URL.Query().Get("error") errorDescription := r.URL.Query().Get("error_description") @@ -190,8 +163,8 @@ func (h *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rendered := bytes.NewBuffer(make([]byte, 0)) - msg := h.messageCatalog.LookupMessage - msgMarkdown := h.messageCatalog.LookupMarkdownMessage + msg := h.trans.LookupMessage + msgMarkdown := h.trans.LookupMarkdownMessage err := h.template.Lookup("base").Execute(rendered, map[string]interface{}{ "Title": msg("AuthServerErrorTitle", nil, localizer), @@ -213,11 +186,10 @@ func (h *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(rendered.Bytes()) } -func NewErrorHandler(logger *log.Logger, bundle *i18n.Bundle, messageCatalog *services.MessageCatalog) *ErrorHandler { +func NewErrorHandler(logger *log.Logger, trans *services.I18NService) *ErrorHandler { return &ErrorHandler{ - logger: logger, - bundle: bundle, - messageCatalog: messageCatalog, + logger: logger, + trans: trans, template: template.Must(template.ParseFS( ui.Templates, "templates/base.gohtml", diff --git a/internal/handlers/login.go b/internal/handlers/login.go index 07b8968..35bc91e 100644 --- a/internal/handlers/login.go +++ b/internal/handlers/login.go @@ -20,7 +20,6 @@ package handlers import ( "bytes" "context" - "crypto/x509" "fmt" "html/template" "io" @@ -32,7 +31,6 @@ import ( log "github.com/sirupsen/logrus" "code.cacert.org/cacert/oidc-idp/internal/services" - "code.cacert.org/cacert/oidc-idp/ui" ) type acrType string @@ -50,20 +48,11 @@ const ( ctxKeyMessage contextKey = iota ) -type templateName string - -const ( - CertificateLogin templateName = "cert" - NoEmailsInClientCertificate templateName = "no_emails" - NoChallengeInRequest templateName = "no_challenge" -) - type LoginHandler struct { - adminClient client.OAuth2Api - bundle *i18n.Bundle - logger *log.Logger - templates map[templateName]*template.Template - messageCatalog *services.MessageCatalog + logger *log.Logger + trans *services.I18NService + adminClient client.OAuth2Api + templates TemplateCache } func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -73,8 +62,7 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - accept := r.Header.Get("Accept-Language") - localizer := i18n.NewLocalizer(h.bundle, accept) + localizer := getLocalizer(h.trans, r) challenge := r.URL.Query().Get("login_challenge") @@ -86,10 +74,10 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.logger.WithField("challenge", challenge).Debug("received login challenge") - certFullName, certEmails := h.getDataFromClientCert(r) + certFullName, certEmails := getDataFromClientCert(h.logger, r) if certEmails == nil { - h.renderNoEmailsInClientCertificate(w, localizer) + renderNoEmailsInClientCertificate(h.logger, h.templates, h.trans, w, localizer) return } @@ -155,7 +143,7 @@ func (h *LoginHandler) handlePost( ctxKeyMessage, FlashMessage{ Type: "warning", - Message: h.messageCatalog.LookupMessage("NoEmailAddressSelected", nil, localizer), + Message: h.trans.LookupMessage("NoEmailAddressSelected", nil, localizer), }, )), challenge, certEmails, localizer) @@ -267,8 +255,8 @@ func (h *LoginHandler) rejectLogin( localizer *i18n.Localizer, ) { rejectRequest := client.NewRejectOAuth2RequestWithDefaults() - rejectRequest.SetErrorDescription(h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer)) - rejectRequest.SetErrorHint(h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer)) + rejectRequest.SetErrorDescription(h.trans.LookupMessage("LoginDeniedByUser", nil, localizer)) + rejectRequest.SetErrorHint(h.trans.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer)) rejectRequest.SetStatusCode(http.StatusForbidden) rejectLoginRequest, response, err := h.adminClient.RejectOAuth2LoginRequest( @@ -291,36 +279,6 @@ func (h *LoginHandler) rejectLogin( w.WriteHeader(http.StatusFound) } -func (h *LoginHandler) getDataFromClientCert(r *http.Request) (string, []string) { - if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 { - firstCert := r.TLS.PeerCertificates[0] - - if !isClientCertificate(firstCert) { - return "", nil - } - - for _, email := range firstCert.EmailAddresses { - h.logger.WithField( - "email", email, - ).Info("authenticated with a client certificate for email address") - } - - return firstCert.Subject.CommonName, firstCert.EmailAddresses - } - - return "", nil -} - -func isClientCertificate(cert *x509.Certificate) bool { - for _, ext := range cert.ExtKeyUsage { - if ext == x509.ExtKeyUsageClientAuth { - return true - } - } - - return false -} - func (h *LoginHandler) renderRequestForClientCert( w http.ResponseWriter, r *http.Request, @@ -328,9 +286,9 @@ func (h *LoginHandler) renderRequestForClientCert( localizer *i18n.Localizer, loginRequest *client.OAuth2LoginRequest, ) { - msg := h.messageCatalog.LookupMessage - msgPlural := h.messageCatalog.LookupMessagePlural - msgMarkdown := h.messageCatalog.LookupMarkdownMessage + msg := h.trans.LookupMessage + msgPlural := h.trans.LookupMessagePlural + msgMarkdown := h.trans.LookupMarkdownMessage rendered := bytes.NewBuffer(make([]byte, 0)) @@ -372,25 +330,10 @@ func (h *LoginHandler) performCertificateLogin(emails []string, r *http.Request) return "", fmt.Errorf("no user found") } -func (h *LoginHandler) renderNoEmailsInClientCertificate(w http.ResponseWriter, localizer *i18n.Localizer) { - msg := h.messageCatalog.LookupMessage - - err := h.templates[NoEmailsInClientCertificate].Lookup("base").Execute(w, map[string]interface{}{ - "Title": msg("NoEmailsInClientCertificateTitle", nil, localizer), - "Explanation": msg("NoEmailsInClientCertificateExplanation", nil, localizer), - }) - if err != nil { - h.logger.WithError(err).Error("template rendering failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } -} - func (h *LoginHandler) renderNoChallengeInRequest(w http.ResponseWriter, localizer *i18n.Localizer) { err := h.templates[NoChallengeInRequest].Lookup("base").Execute(w, map[string]interface{}{ - "Title": h.messageCatalog.LookupMessage("NoChallengeInRequestTitle", nil, localizer), - "Explanation": template.HTML(h.messageCatalog.LookupMarkdownMessage( //nolint:gosec + "Title": h.trans.LookupMessage("NoChallengeInRequestTitle", nil, localizer), + "Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec "NoChallengeInRequestExplanation", nil, localizer, @@ -405,32 +348,12 @@ func (h *LoginHandler) renderNoChallengeInRequest(w http.ResponseWriter, localiz } func NewLoginHandler( - logger *log.Logger, - bundle *i18n.Bundle, - messageCatalog *services.MessageCatalog, - adminClient client.OAuth2Api, + logger *log.Logger, tc TemplateCache, trans *services.I18NService, adminClient client.OAuth2Api, ) *LoginHandler { return &LoginHandler{ - adminClient: adminClient, - bundle: bundle, logger: logger, - templates: map[templateName]*template.Template{ - CertificateLogin: template.Must(template.ParseFS( - ui.Templates, - "templates/base.gohtml", - "templates/client_certificate.gohtml", - )), - NoEmailsInClientCertificate: template.Must(template.ParseFS( - ui.Templates, - "templates/base.gohtml", - "templates/no_email_in_client_certificate.gohtml", - )), - NoChallengeInRequest: template.Must(template.ParseFS( - ui.Templates, - "templates/base.gohtml", - "templates/no_challenge_in_request.gohtml", - )), - }, - messageCatalog: messageCatalog, + trans: trans, + adminClient: adminClient, + templates: tc, } } diff --git a/internal/handlers/logout.go b/internal/handlers/logout.go index 4bbcc88..a979356 100644 --- a/internal/handlers/logout.go +++ b/internal/handlers/logout.go @@ -18,16 +18,13 @@ limitations under the License. package handlers import ( - "bytes" "html/template" "net/http" - "github.com/nicksnyder/go-i18n/v2/i18n" client "github.com/ory/hydra-client-go/v2" log "github.com/sirupsen/logrus" "code.cacert.org/cacert/oidc-idp/internal/services" - "code.cacert.org/cacert/oidc-idp/ui" ) type LogoutHandler struct { @@ -73,7 +70,7 @@ func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusFound) } -func NewLogoutHandler(logger *log.Logger, adminClient client.OAuth2Api) *LogoutHandler { +func NewLogout(logger *log.Logger, adminClient client.OAuth2Api) *LogoutHandler { return &LogoutHandler{ logger: logger, adminClient: adminClient, @@ -81,10 +78,9 @@ func NewLogoutHandler(logger *log.Logger, adminClient client.OAuth2Api) *LogoutH } type LogoutSuccessHandler struct { - bundle *i18n.Bundle - logger *log.Logger - messageCatalog *services.MessageCatalog - template *template.Template + logger *log.Logger + trans *services.I18NService + templates TemplateCache } func (h *LogoutSuccessHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -94,41 +90,22 @@ func (h *LogoutSuccessHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - accept := r.Header.Get("Accept-Language") - localizer := i18n.NewLocalizer(h.bundle, accept) + localizer := getLocalizer(h.trans, r) - rendered := bytes.NewBuffer(make([]byte, 0)) - - err := h.template.Lookup("base").Execute(rendered, map[string]interface{}{ - "Title": h.messageCatalog.LookupMessage("LogoutSuccessfulTitle", nil, localizer), - "Explanation": template.HTML(h.messageCatalog.LookupMarkdownMessage( //nolint:gosec + h.templates.render(h.logger, w, LogoutSuccessful, map[string]interface{}{ + "Title": h.trans.LookupMessage("LogoutSuccessfulTitle", nil, localizer), + "Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec "LogoutSuccessfulText", nil, localizer, )), }) - if err != nil { - h.logger.WithError(err).Error("template rendering failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - _, _ = w.Write(rendered.Bytes()) } -func NewLogoutSuccessHandler( +func NewLogoutSuccess( logger *log.Logger, - bundle *i18n.Bundle, - messageCatalog *services.MessageCatalog, + templateCache TemplateCache, + trans *services.I18NService, ) *LogoutSuccessHandler { - return &LogoutSuccessHandler{ - bundle: bundle, - logger: logger, - messageCatalog: messageCatalog, - template: template.Must(template.ParseFS(ui.Templates, - "templates/base.gohtml", - "templates/logout_successful.gohtml", - )), - } + return &LogoutSuccessHandler{logger: logger, trans: trans, templates: templateCache} } diff --git a/internal/handlers/manage.go b/internal/handlers/manage.go new file mode 100644 index 0000000..bd9a81a --- /dev/null +++ b/internal/handlers/manage.go @@ -0,0 +1,370 @@ +/* +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 handlers + +import ( + "context" + "fmt" + "html/template" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/gorilla/csrf" + "github.com/nicksnyder/go-i18n/v2/i18n" + client "github.com/ory/hydra-client-go/v2" + log "github.com/sirupsen/logrus" + + "code.cacert.org/cacert/oidc-idp/internal/services" +) + +type IndexHandler struct { + logger *log.Logger + trans *services.I18NService + templates TemplateCache +} + +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 + } + + localizer := getLocalizer(h.trans, r) + + if r.URL.Path != "/" { + http.Error(w, h.trans.LookupHTTPErrorMessage(http.StatusNotFound, localizer), http.StatusNotFound) + + return + } + + h.templates.render(h.logger, w, Index, map[string]interface{}{ + "Title": h.trans.LookupMessage("IndexTitle", nil, localizer), + "WelcomeMessage": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec + "IndexWelcomeMessage", + map[string]interface{}{"ManageConsentHRef": "/manage-consent"}, + localizer, + )), + }) +} + +func NewIndex(logger *log.Logger, templateCache TemplateCache, trans *services.I18NService) *IndexHandler { + return &IndexHandler{logger: logger, trans: trans, templates: templateCache} +} + +type ManageConsentHandler struct { + logger *log.Logger + trans *services.I18NService + adminAPI client.OAuth2Api + templates TemplateCache +} + +func (h *ManageConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + + localizer := getLocalizer(h.trans, r) + + allSessions := make(ConsentSessions, 0) + + var ( + sessions []client.OAuth2ConsentSession + ok bool + ) + + for _, s := range GetAuthenticatedAddresses(r) { + if sessions, ok = h.getConsentSessions(w, r, s); !ok { + return + } + + for _, session := range sessions { + allSessions = append(allSessions, ConsentSession{Subject: s, Session: session}) + } + } + + sort.Sort(allSessions) + + requestSession, err := GetSession(r) + if err != nil { + h.logger.WithError(err).Error("could not get session for request") + + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + h.templates.render(h.logger, w, ManageConsent, map[string]interface{}{ + "Title": h.trans.LookupMessage("ManageConsentTitle", nil, localizer), + "Description": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec + "ManageConsentDescription", + nil, + localizer, + )), + "NoConsentGiven": h.trans.LookupMessage("NoConsentGiven", nil, localizer), + "Flashes": requestSession.Flashes("messages"), + "ConsentSessions": allSessions, + "ButtonTitleRevoke": h.trans.LookupMessage("ButtonTitleRevoke", nil, localizer), + "ApplicationTitle": h.trans.LookupMessage("ColumnNameApplication", nil, localizer), + "ActionsTitle": h.trans.LookupMessage("ColumnNameActions", nil, localizer), + "SubjectTitle": h.trans.LookupMessage("ColumnNameSubject", nil, localizer), + "GrantedTitle": h.trans.LookupMessage("ColumnNameGranted", nil, localizer), + "ExpiresTitle": h.trans.LookupMessage("ColumnNameExpires", nil, localizer), + "LabelUnknown": h.trans.LookupMessage("LabelUnknown", nil, localizer), + "LabelNever": h.trans.LookupMessage("LabelNever", nil, localizer), + }) +} + +type ConsentSession struct { + Subject string + Session client.OAuth2ConsentSession +} + +func (s ConsentSession) GetClientName() string { + if !s.Session.HasConsentRequest() { + return "" + } + + request := s.Session.GetConsentRequest() + if !request.HasClient() { + return "" + } + + consentClient := request.GetClient() + + return consentClient.GetClientName() +} + +func (s ConsentSession) GetID() string { + if consent, ok := s.Session.GetConsentRequestOk(); ok { + if app, ok := consent.GetClientOk(); ok { + return app.GetClientId() + } + } + + return "" +} + +func (s ConsentSession) GrantedAt() *time.Time { + if grantedAt, ok := s.Session.GetHandledAtOk(); ok { + return grantedAt + } + + return nil +} + +func (s ConsentSession) Expires() *time.Time { + if expiresAt, ok := s.Session.GetExpiresAtOk(); ok { + if grantedAt := s.GrantedAt(); grantedAt != nil { + return expiresAt.AccessToken + } + } + + return nil +} + +type ConsentSessions []ConsentSession + +func (c ConsentSessions) Len() int { + return len(c) +} + +func (c ConsentSessions) Less(i, j int) bool { + return c[i].Subject < c[j].Subject && c[i].GetClientName() < c[j].GetClientName() +} + +func (c ConsentSessions) Swap(i, j int) { + c[i], c[j] = c[j], c[i] +} + +func (h *ManageConsentHandler) getConsentSessions( + w http.ResponseWriter, r *http.Request, subject string, +) ([]client.OAuth2ConsentSession, bool) { + sessions, response, err := h.adminAPI.ListOAuth2ConsentSessions(r.Context()).Subject(subject).Execute() + if err != nil { + h.logger.WithError(err).Error("error getting consent session list") + + // h.fillAcceptLoginRequestErrorBucket(r, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return nil, false + } + + defer func(response *http.Response) { _ = response.Body.Close() }(response) + + h.logger.WithFields( + log.Fields{"response": response.Status, "consent_sessions": sessions}, + ).Debug("got response for AcceptOAuth2LoginRequest") + + if h.logger.IsLevelEnabled(log.TraceLevel) { + if rb, err := io.ReadAll(response.Body); err == nil { + h.logger.WithField("response_body", rb).Trace("response body from Hydra") + } + } + + return sessions, true +} + +func NewManageConsent( + logger *log.Logger, tc TemplateCache, trans *services.I18NService, adminAPI client.OAuth2Api, +) *ManageConsentHandler { + return &ManageConsentHandler{logger: logger, trans: trans, adminAPI: adminAPI, templates: tc} +} + +type RevokeConsentHandler struct { + logger *log.Logger + trans *services.I18NService + adminAPI client.OAuth2Api + templates TemplateCache +} + +func (h *RevokeConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + localizer := getLocalizer(h.trans, r) + + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error( + w, h.trans.LookupHTTPErrorMessage(http.StatusMethodNotAllowed, localizer), http.StatusMethodNotAllowed, + ) + + return + } + + clientID, _ := strings.CutPrefix(r.URL.Path, "/revoke-consent/") + subject := r.URL.Query().Get("subject") + + if clientID == "" || subject == "" { + http.Error(w, h.trans.LookupHTTPErrorMessage(http.StatusNotFound, localizer), http.StatusNotFound) + + return + } + + if r.Method == http.MethodGet { + h.handleGet(w, r, clientID, localizer, subject) + + return + } + + h.handlePost(w, r, clientID, subject, localizer) +} + +func (h *RevokeConsentHandler) handleGet( + w http.ResponseWriter, r *http.Request, clientID string, localizer *i18n.Localizer, subject string, +) { + clientApp, found, err := h.getClient(r.Context(), clientID) + if err != nil { + h.logger.WithError(err).Error("could not get client") + + http.Error( + w, h.trans.LookupHTTPErrorMessage(http.StatusInternalServerError, localizer), + http.StatusInternalServerError, + ) + + return + } + + if !found { + http.Error(w, h.trans.LookupHTTPErrorMessage(http.StatusNotFound, localizer), http.StatusNotFound) + + return + } + + h.templates.render(h.logger, w, ConfirmRevoke, map[string]interface{}{ + "Title": h.trans.LookupMessage("ConfirmRevokeTitle", nil, localizer), + "ButtonTitleRevoke": h.trans.LookupMessage("ButtonTitleConfirmRevoke", nil, localizer), + "ButtonTitleCancel": h.trans.LookupMessage("ButtonTitleCancel", nil, localizer), + "CancelLink": "/manage-consent", + csrf.TemplateTag: csrf.TemplateField(r), + "Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec + "ConfirmRevokeExplanation", map[string]interface{}{ + "ApplicationID": template.URLQueryEscaper(clientApp.GetClientId()), + "Application": template.HTMLEscapeString(clientApp.GetClientName()), + "Subject": subject, + }, localizer)), + }) +} + +func (h *RevokeConsentHandler) handlePost( + w http.ResponseWriter, r *http.Request, clientID string, subject string, localizer *i18n.Localizer, +) { + err := h.revokeConsent(r.Context(), clientID, subject) + if err != nil { + h.logger.WithError(err).Error("could not revoke consent") + + http.Error( + w, h.trans.LookupHTTPErrorMessage(http.StatusInternalServerError, localizer), + http.StatusInternalServerError, + ) + + return + } + + http.Redirect(w, r, "/manage-consent", http.StatusFound) +} + +func (h *RevokeConsentHandler) getClient(ctx context.Context, clientID string) (*client.OAuth2Client, bool, error) { + clientApp, response, err := h.adminAPI.GetOAuth2Client(ctx, clientID).Execute() + if err != nil { + return nil, false, fmt.Errorf("error getting client application: %w", err) + } + + defer func(response *http.Response) { _ = response.Body.Close() }(response) + + h.logger.WithFields( + log.Fields{"response": response.Status, "client_app": clientApp}, + ).Debug("got response for GetOAuth2Client") + + if h.logger.IsLevelEnabled(log.TraceLevel) { + if rb, err := io.ReadAll(response.Body); err == nil { + h.logger.WithField("response_body", rb).Trace("response body from Hydra") + } + } + + return clientApp, true, nil +} + +func (h *RevokeConsentHandler) revokeConsent(ctx context.Context, clientID, subject string) error { + response, err := h.adminAPI.RevokeOAuth2ConsentSessions(ctx).Client(clientID).Subject(subject).Execute() + if err != nil { + return fmt.Errorf("could not revoke consent: %w", err) + } + + defer func(response *http.Response) { _ = response.Body.Close() }(response) + + h.logger.WithFields( + log.Fields{"response": response.Status}, + ).Debug("got response for RevokeOAuth2ConsentSessions") + + if h.logger.IsLevelEnabled(log.TraceLevel) { + if rb, err := io.ReadAll(response.Body); err == nil { + h.logger.WithField("response_body", rb).Trace("response body from Hydra") + } + } + + return nil +} + +func NewRevokeConsent( + logger *log.Logger, tc TemplateCache, trans *services.I18NService, adminAPI client.OAuth2Api, +) *RevokeConsentHandler { + return &RevokeConsentHandler{logger: logger, trans: trans, adminAPI: adminAPI, templates: tc} +} diff --git a/internal/handlers/security.go b/internal/handlers/security.go index 42d8ee2..1ad07ae 100644 --- a/internal/handlers/security.go +++ b/internal/handlers/security.go @@ -18,9 +18,12 @@ limitations under the License. package handlers import ( + "crypto/x509" "fmt" "net/http" "time" + + log "github.com/sirupsen/logrus" ) func EnableHSTS() func(http.Handler) http.Handler { @@ -32,3 +35,33 @@ func EnableHSTS() func(http.Handler) http.Handler { }) } } + +func getDataFromClientCert(logger *log.Logger, r *http.Request) (string, []string) { + if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 { + firstCert := r.TLS.PeerCertificates[0] + + if !isClientCertificate(firstCert) { + return "", nil + } + + for _, email := range firstCert.EmailAddresses { + logger.WithField( + "email", email, + ).Info("authenticated with a client certificate for email address") + } + + return firstCert.Subject.CommonName, firstCert.EmailAddresses + } + + return "", nil +} + +func isClientCertificate(cert *x509.Certificate) bool { + for _, ext := range cert.ExtKeyUsage { + if ext == x509.ExtKeyUsageClientAuth { + return true + } + } + + return false +} diff --git a/internal/services/i18n.go b/internal/services/i18n.go index 32ccc23..705683b 100644 --- a/internal/services/i18n.go +++ b/internal/services/i18n.go @@ -21,6 +21,7 @@ import ( "bytes" "errors" "fmt" + "net/http" log "github.com/sirupsen/logrus" "github.com/yuin/goldmark" @@ -32,8 +33,136 @@ import ( "golang.org/x/text/language" ) -func AddMessages(catalog *MessageCatalog) error { +type MessageCatalog struct { + messages map[string]*i18n.Message + logger *log.Logger +} + +func (m *MessageCatalog) AddMessages(messages map[string]*i18n.Message) { + for key, value := range messages { + m.messages[key] = value + } +} + +func (s *I18NService) LookupMessage( + id string, + templateData map[string]interface{}, + localizer *i18n.Localizer, +) string { + if message, ok := s.catalog.messages[id]; ok { + translation, err := localizer.Localize(&i18n.LocalizeConfig{ + DefaultMessage: message, + TemplateData: templateData, + }) + if err != nil { + return s.catalog.handleLocalizeError(id, translation, err) + } + + return translation + } + + s.logger.WithField("id", id).Warn("no translation found for id") + + return id +} + +func (s *I18NService) LookupHTTPErrorMessage(httpStatusCode int, localizer *i18n.Localizer) string { + id := fmt.Sprintf("http%d", httpStatusCode) + translation := s.LookupMessage(id, nil, localizer) + + if translation != id { + return translation + } + + return http.StatusText(httpStatusCode) +} + +func (s *I18NService) LookupMarkdownMessage( + id string, + templateData map[string]interface{}, + localizer *i18n.Localizer, +) string { + if message, ok := s.catalog.messages[id]; ok { + translation, err := localizer.Localize(&i18n.LocalizeConfig{ + DefaultMessage: message, + TemplateData: templateData, + }) + if err != nil { + s.logger.WithError(err).Warn(err) + } + + if translation == "" { + return id + } + + buf := &bytes.Buffer{} + + err = goldmark.Convert([]byte(translation), buf) + if err != nil { + return s.catalog.handleLocalizeError(id, translation, fmt.Errorf("markdown conversion error: %w", err)) + } + + return buf.String() + } + + s.logger.WithField("id", id).Warn("no translation found for id") + + return id +} + +func (s *I18NService) LookupMessagePlural( + id string, + templateData map[string]interface{}, + localizer *i18n.Localizer, + count int, +) string { + if message, ok := s.catalog.messages[id]; ok { + translation, err := localizer.Localize(&i18n.LocalizeConfig{ + DefaultMessage: message, + TemplateData: templateData, + PluralCount: count, + }) + if err != nil { + return s.catalog.handleLocalizeError(id, translation, err) + } + + return translation + } + + s.logger.WithField("id", id).Warn("no translation found for id") + + return id +} + +func (m *MessageCatalog) handleLocalizeError(id string, translation string, err error) string { + var messageNotFound *i18n.MessageNotFoundErr + + if errors.As(err, &messageNotFound) { + m.logger.WithError(err).WithField("message", id).Warn("message not found") + + if translation != "" { + return translation + } + } else { + m.logger.WithError(err).WithField("message", id).Error("translation error") + } + + return id +} + +type I18NService struct { + logger *log.Logger + bundle *i18n.Bundle + catalog *MessageCatalog +} + +func (s *I18NService) AddMessages() error { messages := make(map[string]*i18n.Message) + messages["http404"] = &i18n.Message{ + ID: "http404", + Description: "HTTP error 404 not found", + Other: "Not found", + } messages["unknown"] = &i18n.Message{ ID: "ErrorUnknown", Other: "Unknown error", @@ -47,6 +176,55 @@ func AddMessages(catalog *MessageCatalog) error { Other: "A request that your browser sent to the authorization server caused an error." + " The authorization server returned details about the error that are printed below.", } + messages["ButtonTitleCancel"] = &i18n.Message{ + ID: "ButtonTitleCancel", + Description: "Title for a button to cancel an action", + Other: "Cancel", + } + messages["ButtonTitleRevoke"] = &i18n.Message{ + ID: "ButtonTitleRevoke", + Description: "Title for a button to revoke consent", + Other: "Revoke", + } + messages["ButtonTitleConfirmRevoke"] = &i18n.Message{ + ID: "ButtonTitleConfirmRevoke", + Description: "Title for a button to confirm consent revocation", + Other: "Yes, Revoke!", + } + messages["ColumnNameApplication"] = &i18n.Message{ + ID: "ColumnNameApplication", + Description: "Title for a table column showing application names", + Other: "Application", + } + messages["ColumnNameActions"] = &i18n.Message{ + ID: "ColumnNameActions", + Description: "Title for a table column showing available actions", + Other: "Actions", + } + messages["ColumnNameExpires"] = &i18n.Message{ + ID: "ColumnNameExpires", + Description: "Title for a table column showing the expiry date for a consent", + Other: "Expires", + } + messages["ColumnNameGranted"] = &i18n.Message{ + ID: "ColumnNameGranted", + Description: "Title for a table column showing the time when consent has been granted", + Other: "Granted at", + } + messages["ColumnNameSubject"] = &i18n.Message{ + ID: "ColumnNameSubject", + Description: "Title for a table column showing the subject of a consent", + Other: "Subject", + } + messages["ConfirmRevokeTitle"] = &i18n.Message{ + ID: "ConfirmRevokeTitle", + Other: "Revoke consent", + } + messages["ConfirmRevokeExplanation"] = &i18n.Message{ + ID: "ConfirmRevokeExplanation", + Other: "Do you want to revoke your consent to allow **{{ .Application }}**" + + " access to identity data for **{{ .Subject }}**?", + } messages["TitleRequestConsent"] = &i18n.Message{ ID: "TitleRequestConsent", Other: "Application requests your consent", @@ -59,6 +237,14 @@ func AddMessages(catalog *MessageCatalog) error { ID: "LabelConsent", Other: "I hereby agree that the application may get the requested permissions.", } + messages["LabelNever"] = &i18n.Message{ + ID: "LabelNever", + Other: "Never", + } + messages["LabelUnknown"] = &i18n.Message{ + ID: "LabelUnknown", + Other: "Unknown", + } messages["IntroConsentRequested"] = &i18n.Message{ ID: "IntroConsentRequested", Other: "The {{ .client }} application requested your consent for the following set of " + @@ -87,6 +273,15 @@ func AddMessages(catalog *MessageCatalog) error { Other: "You have presented a valid client certificate for multiple email addresses. " + "Please choose which one you want to present to the application:", } + messages["IndexTitle"] = &i18n.Message{ + ID: "IndexTitle", + Other: "Welcome to your identity provider", + } + messages["IndexWelcomeMessage"] = &i18n.Message{ + ID: "IndexWelcomeMessage", + Other: "Go to [manage consent]({{ .ManageConsentHRef }}) to show or revoke consent" + + " you have given to client applications.", + } messages["LoginTitle"] = &i18n.Message{ ID: "LoginTitle", Other: "Authenticate with a client certificate", @@ -117,10 +312,22 @@ func AddMessages(catalog *MessageCatalog) error { ID: "LogoutSuccessfulText", Other: "You have been logged out successfully.", } + messages["ManageConsentTitle"] = &i18n.Message{ + ID: "ManageConsentTitle", + Other: "Manage consent", + } + messages["ManageConsentDescription"] = &i18n.Message{ + ID: "ManageConsentDescription", + Other: "This page allows you to see consent that you have given to client applications in the past.", + } messages["HintChooseAnIdentityForAuthentication"] = &i18n.Message{ ID: "HintChooseAnIdentityForAuthentication", Other: "Choose an identity for authentication.", } + messages["NoConsentGiven"] = &i18n.Message{ + ID: "NoConsentGiven", + Other: "You have not given consent to use your data to any application yet.", + } messages["NoEmailAddressSelected"] = &i18n.Message{ ID: "NoEmailAddressSelected", Other: "You did not select an email address. Please select an email address to continue.", @@ -145,154 +352,16 @@ An email address is required to authenticate yourself.`, " [the ORY Hydra documentation](https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow).", } - catalog.AddMessages(messages) + s.catalog.AddMessages(messages) return nil } -type MessageCatalog struct { - messages map[string]*i18n.Message - logger *log.Logger -} - -func (m *MessageCatalog) AddMessages(messages map[string]*i18n.Message) { - for key, value := range messages { - m.messages[key] = value - } -} - -func (m *MessageCatalog) LookupErrorMessage( - tag string, - field string, - value interface{}, - localizer *i18n.Localizer, -) string { - fieldTag := fmt.Sprintf("%s-%s", field, tag) - - message, ok := m.messages[fieldTag] - if !ok { - m.logger.WithField("field_tag", fieldTag).Info("no specific error message for field and tag") - - message, ok = m.messages[tag] - if !ok { - m.logger.WithField("tag", tag).Info("no specific error message for tag") - - message, ok = m.messages["unknown"] - if !ok { - m.logger.Warn("no default translation found") - - return tag - } - } - } - - translation, err := localizer.Localize(&i18n.LocalizeConfig{ - DefaultMessage: message, - TemplateData: map[string]interface{}{ - "Value": value, - }, - }) - if err != nil { - m.logger.WithError(err).Error("localization failed") - - return tag - } - - return translation -} - -func (m *MessageCatalog) LookupMessage( - id string, - templateData map[string]interface{}, - localizer *i18n.Localizer, -) string { - if message, ok := m.messages[id]; ok { - translation, err := localizer.Localize(&i18n.LocalizeConfig{ - DefaultMessage: message, - TemplateData: templateData, - }) - if err != nil { - return m.handleLocalizeError(id, translation, err) - } - - return translation - } - - m.logger.WithField("id", id).Warn("no translation found for id") - - return id -} - -func (m *MessageCatalog) LookupMarkdownMessage( - id string, - templateData map[string]interface{}, - localizer *i18n.Localizer, -) string { - if message, ok := m.messages[id]; ok { - translation, err := localizer.Localize(&i18n.LocalizeConfig{ - DefaultMessage: message, - TemplateData: templateData, - }) - if err != nil { - return m.handleLocalizeError(id, translation, err) - } - - buf := &bytes.Buffer{} - - err = goldmark.Convert([]byte(translation), buf) - if err != nil { - return m.handleLocalizeError(id, translation, fmt.Errorf("markdown conversion error: %w", err)) - } - - return buf.String() - } - - m.logger.WithField("id", id).Warn("no translation found for id") - - return id -} - -func (m *MessageCatalog) LookupMessagePlural( - id string, - templateData map[string]interface{}, - localizer *i18n.Localizer, - count int, -) string { - if message, ok := m.messages[id]; ok { - translation, err := localizer.Localize(&i18n.LocalizeConfig{ - DefaultMessage: message, - TemplateData: templateData, - PluralCount: count, - }) - if err != nil { - return m.handleLocalizeError(id, translation, err) - } - - return translation - } - - m.logger.WithField("id", id).Warn("no translation found for id") - - return id -} - -func (m *MessageCatalog) handleLocalizeError(id string, translation string, err error) string { - var messageNotFound *i18n.MessageNotFoundErr - - if errors.As(err, &messageNotFound) { - m.logger.WithError(err).WithField("message", id).Warn("message not found") - - if translation != "" { - return translation - } - } else { - m.logger.WithError(err).WithField("message", id).Error("translation error") - } - - return id +func (s *I18NService) Localizer(languages string) *i18n.Localizer { + return i18n.NewLocalizer(s.bundle, languages) } -func InitI18n(logger *log.Logger, languages []string) (*i18n.Bundle, *MessageCatalog) { +func InitI18n(logger *log.Logger, languages []string) *I18NService { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) @@ -311,7 +380,7 @@ func InitI18n(logger *log.Logger, languages []string) (*i18n.Bundle, *MessageCat catalog := initMessageCatalog(logger) - return bundle, catalog + return &I18NService{logger: logger, bundle: bundle, catalog: catalog} } func initMessageCatalog(logger *log.Logger) *MessageCatalog { diff --git a/translations/active.de.toml b/translations/active.de.toml index 6de7d8c..6b112c4 100644 --- a/translations/active.de.toml +++ b/translations/active.de.toml @@ -6,6 +6,21 @@ other = "Eine Anfrage, die dein Browser an den Authorization-Server geschickt ha hash = "sha1-fa3294b49220d6de6f68825d03195e3f33e88378" other = "Fehlermeldung vom Authorization-Server" +[ButtonTitleCancel] +description = "Title for a button to cancel an action" +hash = "sha1-0ea9f9f23f1883bb6642b7ec2f083036497dd56b" +other = "Abbrechen" + +[ButtonTitleConfirmRevoke] +description = "Title for a button to confirm consent revocation" +hash = "sha1-bb25839982a1fe044fc2ac39552903c65402ad48" +other = "Ja, Widerrufen!" + +[ButtonTitleRevoke] +description = "Title for a button to revoke consent" +hash = "sha1-b4524373ff63f37e062bd5f496e8ba04ee5c678d" +other = "Widerrufen" + [CertLoginIntroText] hash = "sha1-e9f7c0522e49ffacc49e3fc35c6ffd31e495baf6" other = "Die Anwendung {{ .ClientName }} fragt nach einer Anmeldung." @@ -18,6 +33,39 @@ other = "Willst du die ausgewählte Identität für die Anmeldung verwenden?" hash = "sha1-4a6721995b5d87c02be77695910af642ca30b18a" other = "Zusätzlich möchte die Anwendung Zugriff auf folgende Informationen:" +[ColumnNameActions] +description = "Title for a table column showing available actions" +hash = "sha1-b1b5e5530afa75d2f7c088486018266a1fbc3456" +other = "Aktionen" + +[ColumnNameApplication] +description = "Title for a table column showing application names" +hash = "sha1-28a9f11a0be09f7d8df6e324f3222e105965d315" +other = "Applikation" + +[ColumnNameExpires] +description = "Title for a table column showing the expiry date for a consent" +hash = "sha1-6c61015982d7748e6549cf56ee372a8bc130e83a" +other = "Läuft ab" + +[ColumnNameGranted] +description = "Title for a table column showing the time when consent has been granted" +hash = "sha1-4ace00a57b59cf745b97f1434d5605ec75722909" +other = "Zugestimmt" + +[ColumnNameSubject] +description = "Title for a table column showing the subject of a consent" +hash = "sha1-048ba3497a13fa58c474cf5a400240584b0ee5d5" +other = "Subjekt" + +[ConfirmRevokeExplanation] +hash = "sha1-664fae4095fce4ff49ea3eed60293eaea99004a0" +other = "Willst du die Freigabe für den Zugriff auf deine Identitätsdaten für **{{ .Subject }}** für **{{ .Application }}** widerrufen?" + +[ConfirmRevokeTitle] +hash = "sha1-13255b407052cfd4a359f6e8e175137b48388a31" +other = "Freigabe widerrufen" + [EmailChoiceText] hash = "sha1-8bba8cd3a8724d8c5b75da9b7d2ac084b6e9df90" one = "Du hast ein gültiges Client-Zertifikat für die folgende E-Mail-Adresse vorgelegt:" @@ -35,6 +83,14 @@ other = "Unbekannter Fehler" hash = "sha1-7ee5b067009bbedc061274ee50a3027b50a06163" other = "Wähle eine Identität für die Anmeldung." +[IndexTitle] +hash = "sha1-c763022b69a8ad58ab42d8ea708192abd85fd8f6" +other = "Willkommen bei deinem Identitätsprovider" + +[IndexWelcomeMessage] +hash = "sha1-00632c6562df53c62861c33e468e729887816419" +other = "Besuche [die Freigabeverwaltung]({{ .ManageConsentHRef }}), um deine Freigaben für Applikationen einzusehen oder zu widerrufen." + [IntroConsentMoreInformation] hash = "sha1-f58b8378238bd433deef3c3e6b0b70d0fd0dd59e" other = "Auf der Beschreibungsseite findest du mehr Informationen zu {{ .client }}." @@ -52,6 +108,10 @@ other = "Ja, bitte" hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6" other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf." +[LabelNever] +hash = "sha1-80c3052d33ccdee15ffaaa110c5c39072495fe63" +other = "Nie" + [LabelRejectCertLogin] description = "Label for a button to reject certificate login" hash = "sha1-49c4d0d1da1c0a7d7e9bf491b28a6e6825f4c2cd" @@ -61,6 +121,10 @@ other = "Nein, schick mich zurück" hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea" other = "Abschicken" +[LabelUnknown] +hash = "sha1-bc7819b34ff87570745fbe461e36a16f80e562ce" +other = "Unbekannt" + [LoginDeniedByUser] hash = "sha1-bbad650536bfb091ad55d576262bbe4358277c73" other = "Die Anmeldung wurde durch den Nutzer abgelehnt." @@ -77,6 +141,14 @@ other = "Du wurdest erfolreich abgemeldet." hash = "sha1-c92ba3b5f47d0b37b43ba499793a09baa94e5b9d" other = "Abmeldung erfolgreich" +[ManageConsentDescription] +hash = "sha1-c0f25f17d557ce029f826322a3b625905966e0ea" +other = "Diese Seite zeigt dir die Freigaben für Client-Applikationen, die du in der Vergangenheit erteilt hast und ermöglicht dir, diese zu widerrufen." + +[ManageConsentTitle] +hash = "sha1-1c63dd057bea5bfc86befa82e041ab08122f2e52" +other = "Freigaben verwalten" + [NoChallengeInRequestExplanation] hash = "sha1-b26a3ef99ecadfbceb62b62eb52e34d197c56c02" other = "In Deinem Anmelde-Request fehlt der notwendige `login_challenge`-Parameter. Mehr Informationen zu diesem Parameter findest du in [der ORY Hydra-Dokumentation](https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow)." @@ -85,6 +157,10 @@ other = "In Deinem Anmelde-Request fehlt der notwendige `login_challenge`-Parame hash = "sha1-b039c647fea0e42bcb0c877c58da499d082f5319" other = "Kein Challenge-Parameter im Anmelde-Request" +[NoConsentGiven] +hash = "sha1-6843ba66b1103d3eed6403f7e276301b852e7ac0" +other = "Du hast noch keiner Applikation eine Freigabe erteilt." + [NoEmailAddressSelected] hash = "sha1-09fdefe67eae9915e32b18c50baf985d5bd27d36" other = "Du hast keine E-Mail-Adresse ausgewählt. Bitte wähle eine E-Mail-Adresse, um die Anmeldung fortzusetzen." @@ -120,3 +196,8 @@ other = "Anwendung erbittet deine Zustimmung" [WrongOrLockedUserOrInvalidPassword] hash = "sha1-87e0a0ac67c6c3a06bed184e10b22aae4d075b64" other = "Du hast einen ungültigen Nutzernamen oder ein ungültiges Passwort eingegeben oder dein Benutzerkonto wurde gesperrt." + +[http404] +description = "HTTP error 404 not found" +hash = "sha1-8d7e56f17b2686bc10641795b8785c03b77581cf" +other = "Nicht gefunden" diff --git a/translations/active.en.toml b/translations/active.en.toml index 38fd2c2..465e1d5 100644 --- a/translations/active.en.toml +++ b/translations/active.en.toml @@ -3,19 +3,28 @@ AuthServerErrorTitle = "Authorization server returned an error" CertLoginIntroText = "The application {{ .ClientName }} requests a login." CertLoginRequestText = "Do you want to use the chosen identity from the certificate for authentication?" ClaimsInformation = "In addition the application wants access to the following information:" +ConfirmRevokeExplanation = "Do you want to revoke your consent to allow **{{ .Application }}** access to identity data for **{{ .Subject }}**?" +ConfirmRevokeTitle = "Revoke consent" ErrorTitle = "An error has occurred" ErrorUnknown = "Unknown error" HintChooseAnIdentityForAuthentication = "Choose an identity for authentication." +IndexTitle = "Welcome to your identity provider" +IndexWelcomeMessage = "Go to [manage consent]({{ .ManageConsentHRef }}) to show or revoke consent you have given to client applications." IntroConsentMoreInformation = "You can find more information about {{ .client }} at its description page." IntroConsentRequested = "The {{ .client }} application requested your consent for the following set of permissions:" LabelConsent = "I hereby agree that the application may get the requested permissions." +LabelNever = "Never" LabelSubmit = "Submit" +LabelUnknown = "Unknown" LoginDeniedByUser = "Login has been denied by the user." LoginTitle = "Authenticate with a client certificate" LogoutSuccessfulText = "You have been logged out successfully." LogoutSuccessfulTitle = "Logout successful" +ManageConsentDescription = "This page allows you to see consent that you have given to client applications in the past." +ManageConsentTitle = "Manage consent" NoChallengeInRequestExplanation = "Your authentication request did not contain the necessary `login_challenge` parameter. You can find more information about this parameter in [the ORY Hydra documentation](https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow)." NoChallengeInRequestTitle = "No challenge parameter in your authentication request" +NoConsentGiven = "You have not given consent to use your data to any application yet." NoEmailAddressSelected = "You did not select an email address. Please select an email address to continue." NoEmailsInClientCertificateExplanation = "The presented client certificate does not contain any email address value.\nAn email address is required to authenticate yourself." NoEmailsInClientCertificateTitle = "No email addresses in client certificate" @@ -26,6 +35,38 @@ Scope-profile-Description = "Access your user profile information (your name)." TitleRequestConsent = "Application requests your consent" WrongOrLockedUserOrInvalidPassword = "You entered an invalid username or password or your account has been locked." +[ButtonTitleCancel] +description = "Title for a button to cancel an action" +other = "Cancel" + +[ButtonTitleConfirmRevoke] +description = "Title for a button to confirm consent revocation" +other = "Yes, Revoke!" + +[ButtonTitleRevoke] +description = "Title for a button to revoke consent" +other = "Revoke" + +[ColumnNameActions] +description = "Title for a table column showing available actions" +other = "Actions" + +[ColumnNameApplication] +description = "Title for a table column showing application names" +other = "Application" + +[ColumnNameExpires] +description = "Title for a table column showing the expiry date for a consent" +other = "Expires" + +[ColumnNameGranted] +description = "Title for a table column showing the time when consent has been granted" +other = "Granted at" + +[ColumnNameSubject] +description = "Title for a table column showing the subject of a consent" +other = "Subject" + [EmailChoiceText] one = "You have presented a valid client certificate for the following email address:" other = "You have presented a valid client certificate for multiple email addresses. Please choose which one you want to present to the application:" @@ -37,3 +78,7 @@ other = "Yes, please use this identity" [LabelRejectCertLogin] description = "Label for a button to reject certificate login" other = "No, please send me back" + +[http404] +description = "HTTP error 404 not found" +other = "Not found" diff --git a/ui/templates/base.gohtml b/ui/templates/base.gohtml index 2a7718e..7769987 100644 --- a/ui/templates/base.gohtml +++ b/ui/templates/base.gohtml @@ -27,9 +27,10 @@ {{ .Title }} -
- {{ template "content" . }} -
+
+ CAcert +
+ {{ template "content" . }}