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
main
Jan Dittberner 9 months ago
parent 679dcb27ce
commit 44e18ca3a5

7
.gitignore vendored

@ -1,10 +1,11 @@
*.pem *.pem
.idea/ /.idea/
/cacert-idp /cacert-idp
/certs/
/dist/ /dist/
/idp.toml /idp.toml
/static /static
/translations/translate.*.toml
/ui/css/ /ui/css/
/ui/images/ /ui/images/
/ui/js/ /ui/js/
certs/

@ -18,13 +18,12 @@ go.sum: go.mod
go mod tidy go mod tidy
translations: $(TRANSLATIONS) $(GOFILES) translations: $(TRANSLATIONS) $(GOFILES)
cd translations ; \ if [ ! -z "$(wildcard translations/translate.*.toml)" ]; then \
goi18n extract .. ; \
goi18n merge active.*.toml ; \
if translate.*.toml 2>/dev/null; then \
echo "missing translations"; \ echo "missing translations"; \
goi18n merge active.*.toml translate.*.toml; \ goi18n merge -outdir translations translations/active.*.toml translations/translate.*.toml; \
fi fi ; \
goi18n extract -outdir translations . ; \
goi18n merge -outdir translations translations/active.*.toml
lint: $(GOFILES) lint: $(GOFILES)
golangci-lint run --verbose golangci-lint run --verbose

@ -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 - use a session to transport data from the login to the consent screens
- implement skip of consent screen for existing consent - implement skip of consent screen for existing consent
- adapt to Hydra 2.x - 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 ## [0.2.1] - 2023-08-03
### Changed ### Changed

@ -88,9 +88,9 @@ func main() {
"version": version, "commit": commit, "date": date, "version": version, "commit": commit, "date": date,
}).Info("Starting CAcert OpenID Connect Identity Provider") }).Info("Starting CAcert OpenID Connect Identity Provider")
logger.Infoln("Server is starting") 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") 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") logger.WithError(err).Fatal("could not configure Hydra admin client")
} }
loginHandler := handlers.NewLoginHandler(logger, bundle, catalog, clientTransport.OAuth2Api) tc := handlers.PopulateTemplateCache()
consentHandler := handlers.NewConsentHandler(logger, bundle, catalog, clientTransport.OAuth2Api)
logoutHandler := handlers.NewLogoutHandler(logger, clientTransport.OAuth2Api)
logoutSuccessHandler := handlers.NewLogoutSuccessHandler(logger, bundle, catalog) needsAuth := handlers.NewAuthMiddleware(logger, tc, i18nService).NeedsAuth
errorHandler := handlers.NewErrorHandler(logger, bundle, catalog)
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)) staticFiles := http.FileServer(http.FS(ui.Static))
router := http.NewServeMux() 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("/login", loginHandler)
router.Handle("/consent", consentHandler) router.Handle("/consent", consentHandler)
router.Handle("/logout", logoutHandler) router.Handle("/logout", logoutHandler)
@ -139,7 +150,7 @@ func main() {
csrf.SameSite(csrf.SameSiteStrictMode), csrf.SameSite(csrf.SameSiteStrictMode),
csrf.MaxAge(DefaultCSRFMaxAge)) csrf.MaxAge(DefaultCSRFMaxAge))
errorMiddleware, err := handlers.ErrorHandling(logger, ui.Templates, bundle, catalog) errorMiddleware, err := handlers.ErrorHandling(logger, tc, i18nService)
if err != nil { if err != nil {
logger.WithError(err).Fatal("could not initialize request error handling") logger.WithError(err).Fatal("could not initialize request error handling")
} }

@ -4,6 +4,7 @@ go 1.19
require ( require (
github.com/BurntSushi/toml v1.3.2 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/go-playground/form/v4 v4.2.1
github.com/gorilla/csrf v1.7.1 github.com/gorilla/csrf v1.7.1
github.com/gorilla/sessions v1.2.1 github.com/gorilla/sessions v1.2.1

@ -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/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 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 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/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.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= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=

@ -18,10 +18,18 @@ limitations under the License.
package handlers package handlers
import ( import (
"bytes"
"context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"github.com/dustin/go-humanize"
"github.com/gorilla/sessions" "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" "code.cacert.org/cacert/oidc-idp/internal/services"
) )
@ -36,3 +44,144 @@ func GetSession(r *http.Request) (*sessions.Session, error) {
return session, nil 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
}
}

@ -36,17 +36,14 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"code.cacert.org/cacert/oidc-idp/internal/models" "code.cacert.org/cacert/oidc-idp/internal/models"
"code.cacert.org/cacert/oidc-idp/ui"
"code.cacert.org/cacert/oidc-idp/internal/services" "code.cacert.org/cacert/oidc-idp/internal/services"
) )
type ConsentHandler struct { type ConsentHandler struct {
adminClient client.OAuth2Api logger *log.Logger
bundle *i18n.Bundle trans *services.I18NService
consentTemplate *template.Template adminClient client.OAuth2Api
logger *log.Logger templates TemplateCache
messageCatalog *services.MessageCatalog
} }
type ConsentInformation struct { 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") h.logger.WithField("consent_challenge", challenge).Debug("received consent challenge")
accept := r.Header.Get("Accept-Language") localizer := getLocalizer(h.trans, r)
localizer := i18n.NewLocalizer(h.bundle, accept)
// retrieve consent information // retrieve consent information
consentData, requestedClaims, err := h.getRequestedConsentInformation(challenge, r) consentData, requestedClaims, err := h.getRequestedConsentInformation(challenge, r)
@ -170,10 +166,7 @@ func (h *ConsentHandler) handleGet(
return return
} }
if err := h.renderConsentForm(w, r, consentData, requestedClaims, localizer); err != nil { h.renderConsentForm(w, r, consentData, requestedClaims, localizer)
h.logger.WithError(err).Error("could not render consent form")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
} }
func (h *ConsentHandler) handlePost( func (h *ConsentHandler) handlePost(
@ -363,18 +356,18 @@ func (h *ConsentHandler) renderConsentForm(
consentRequest *client.OAuth2ConsentRequest, consentRequest *client.OAuth2ConsentRequest,
claims *models.OIDCClaimsRequest, claims *models.OIDCClaimsRequest,
localizer *i18n.Localizer, localizer *i18n.Localizer,
) error { ) {
trans := func(id string, values ...map[string]interface{}) string { trans := func(id string, values ...map[string]interface{}) string {
if len(values) > 0 { 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 // render consent form
oAuth2Client := consentRequest.Client 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"), "Title": trans("TitleRequestConsent"),
csrf.TemplateTag: csrf.TemplateField(r), csrf.TemplateTag: csrf.TemplateField(r),
"errors": map[string]string{}, "errors": map[string]string{},
@ -395,12 +388,6 @@ func (h *ConsentHandler) renderConsentForm(
"client": oAuth2Client.GetClientName(), "client": oAuth2Client.GetClientName(),
})), })),
}) })
if err != nil {
return fmt.Errorf("rendering failed: %w", err)
}
return nil
} }
type scopeWithLabel struct { type scopeWithLabel struct {
@ -601,23 +588,12 @@ func (h *ConsentHandler) parseUserInfoClaims(
} }
func NewConsentHandler( func NewConsentHandler(
logger *log.Logger, logger *log.Logger, templateCache TemplateCache, trans *services.I18NService, adminClient client.OAuth2Api,
bundle *i18n.Bundle,
messageCatalog *services.MessageCatalog,
adminClient client.OAuth2Api,
) *ConsentHandler { ) *ConsentHandler {
consentTemplate := template.Must(
template.ParseFS(
ui.Templates,
"templates/base.gohtml",
"templates/consent.gohtml",
))
return &ConsentHandler{ return &ConsentHandler{
adminClient: adminClient, logger: logger,
bundle: bundle, trans: trans,
consentTemplate: consentTemplate, adminClient: adminClient,
logger: logger, templates: templateCache,
messageCatalog: messageCatalog,
} }
} }

@ -22,10 +22,8 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
"io/fs"
"net/http" "net/http"
"github.com/nicksnyder/go-i18n/v2/i18n"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"code.cacert.org/cacert/oidc-idp/internal/services" "code.cacert.org/cacert/oidc-idp/internal/services"
@ -46,34 +44,24 @@ type ErrorDetails struct {
} }
type ErrorBucket struct { type ErrorBucket struct {
errorDetails *ErrorDetails logger *log.Logger
templates *template.Template trans *services.I18NService
logger *log.Logger errorDetails *ErrorDetails
bundle *i18n.Bundle templates TemplateCache
messageCatalog *services.MessageCatalog
} }
func (b *ErrorBucket) serveHTTP(w http.ResponseWriter, r *http.Request) { func (b *ErrorBucket) serveHTTP(w http.ResponseWriter, r *http.Request) {
if b.errorDetails != nil { if b.errorDetails != nil {
accept := r.Header.Get("Accept-Language") localizer := getLocalizer(b.trans, r)
localizer := i18n.NewLocalizer(b.bundle, accept)
err := b.templates.Lookup("base").Execute(w, map[string]interface{}{ b.templates.render(b.logger, w, Error, map[string]interface{}{
"Title": b.messageCatalog.LookupMessage( "Title": b.trans.LookupMessage(
"ErrorTitle", "ErrorTitle",
nil, nil,
localizer, localizer,
), ),
"details": b.errorDetails, "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 { if code >= http.StatusBadRequest {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.errorBucket.AddError(&ErrorDetails{ w.errorBucket.AddError(&ErrorDetails{ErrorCode: "HTTP error"})
ErrorMessage: http.StatusText(code),
})
} }
w.ResponseWriter.WriteHeader(code) w.ResponseWriter.WriteHeader(code)
@ -133,26 +119,15 @@ func (w *errorResponseWriter) Write(content []byte) (int, error) {
func ErrorHandling( func ErrorHandling(
logger *log.Logger, logger *log.Logger,
templateFS fs.FS, templateCache TemplateCache,
bundle *i18n.Bundle, trans *services.I18NService,
messageCatalog *services.MessageCatalog,
) (func(http.Handler) http.Handler, error) { ) (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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
errorBucket := &ErrorBucket{ errorBucket := &ErrorBucket{
templates: errorTemplates, logger: logger,
logger: logger, trans: trans,
bundle: bundle, templates: templateCache,
messageCatalog: messageCatalog,
} }
next.ServeHTTP( next.ServeHTTP(
&errorResponseWriter{w, errorBucket, http.StatusOK}, &errorResponseWriter{w, errorBucket, http.StatusOK},
@ -164,10 +139,9 @@ func ErrorHandling(
} }
type ErrorHandler struct { type ErrorHandler struct {
logger *log.Logger logger *log.Logger
bundle *i18n.Bundle trans *services.I18NService
messageCatalog *services.MessageCatalog template *template.Template
template *template.Template
} }
func (h *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 return
} }
accept := r.Header.Get("Accept-Language") localizer := getLocalizer(h.trans, r)
localizer := i18n.NewLocalizer(h.bundle, accept)
errorName := r.URL.Query().Get("error") errorName := r.URL.Query().Get("error")
errorDescription := r.URL.Query().Get("error_description") 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)) rendered := bytes.NewBuffer(make([]byte, 0))
msg := h.messageCatalog.LookupMessage msg := h.trans.LookupMessage
msgMarkdown := h.messageCatalog.LookupMarkdownMessage msgMarkdown := h.trans.LookupMarkdownMessage
err := h.template.Lookup("base").Execute(rendered, map[string]interface{}{ err := h.template.Lookup("base").Execute(rendered, map[string]interface{}{
"Title": msg("AuthServerErrorTitle", nil, localizer), "Title": msg("AuthServerErrorTitle", nil, localizer),
@ -213,11 +186,10 @@ func (h *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(rendered.Bytes()) _, _ = 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{ return &ErrorHandler{
logger: logger, logger: logger,
bundle: bundle, trans: trans,
messageCatalog: messageCatalog,
template: template.Must(template.ParseFS( template: template.Must(template.ParseFS(
ui.Templates, ui.Templates,
"templates/base.gohtml", "templates/base.gohtml",

@ -20,7 +20,6 @@ package handlers
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/x509"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
@ -32,7 +31,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"code.cacert.org/cacert/oidc-idp/internal/services" "code.cacert.org/cacert/oidc-idp/internal/services"
"code.cacert.org/cacert/oidc-idp/ui"
) )
type acrType string type acrType string
@ -50,20 +48,11 @@ const (
ctxKeyMessage contextKey = iota ctxKeyMessage contextKey = iota
) )
type templateName string
const (
CertificateLogin templateName = "cert"
NoEmailsInClientCertificate templateName = "no_emails"
NoChallengeInRequest templateName = "no_challenge"
)
type LoginHandler struct { type LoginHandler struct {
adminClient client.OAuth2Api logger *log.Logger
bundle *i18n.Bundle trans *services.I18NService
logger *log.Logger adminClient client.OAuth2Api
templates map[templateName]*template.Template templates TemplateCache
messageCatalog *services.MessageCatalog
} }
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 return
} }
accept := r.Header.Get("Accept-Language") localizer := getLocalizer(h.trans, r)
localizer := i18n.NewLocalizer(h.bundle, accept)
challenge := r.URL.Query().Get("login_challenge") 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") h.logger.WithField("challenge", challenge).Debug("received login challenge")
certFullName, certEmails := h.getDataFromClientCert(r) certFullName, certEmails := getDataFromClientCert(h.logger, r)
if certEmails == nil { if certEmails == nil {
h.renderNoEmailsInClientCertificate(w, localizer) renderNoEmailsInClientCertificate(h.logger, h.templates, h.trans, w, localizer)
return return
} }
@ -155,7 +143,7 @@ func (h *LoginHandler) handlePost(
ctxKeyMessage, ctxKeyMessage,
FlashMessage{ FlashMessage{
Type: "warning", Type: "warning",
Message: h.messageCatalog.LookupMessage("NoEmailAddressSelected", nil, localizer), Message: h.trans.LookupMessage("NoEmailAddressSelected", nil, localizer),
}, },
)), challenge, certEmails, localizer) )), challenge, certEmails, localizer)
@ -267,8 +255,8 @@ func (h *LoginHandler) rejectLogin(
localizer *i18n.Localizer, localizer *i18n.Localizer,
) { ) {
rejectRequest := client.NewRejectOAuth2RequestWithDefaults() rejectRequest := client.NewRejectOAuth2RequestWithDefaults()
rejectRequest.SetErrorDescription(h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer)) rejectRequest.SetErrorDescription(h.trans.LookupMessage("LoginDeniedByUser", nil, localizer))
rejectRequest.SetErrorHint(h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer)) rejectRequest.SetErrorHint(h.trans.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer))
rejectRequest.SetStatusCode(http.StatusForbidden) rejectRequest.SetStatusCode(http.StatusForbidden)
rejectLoginRequest, response, err := h.adminClient.RejectOAuth2LoginRequest( rejectLoginRequest, response, err := h.adminClient.RejectOAuth2LoginRequest(
@ -291,36 +279,6 @@ func (h *LoginHandler) rejectLogin(
w.WriteHeader(http.StatusFound) 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( func (h *LoginHandler) renderRequestForClientCert(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@ -328,9 +286,9 @@ func (h *LoginHandler) renderRequestForClientCert(
localizer *i18n.Localizer, localizer *i18n.Localizer,
loginRequest *client.OAuth2LoginRequest, loginRequest *client.OAuth2LoginRequest,
) { ) {
msg := h.messageCatalog.LookupMessage msg := h.trans.LookupMessage
msgPlural := h.messageCatalog.LookupMessagePlural msgPlural := h.trans.LookupMessagePlural
msgMarkdown := h.messageCatalog.LookupMarkdownMessage msgMarkdown := h.trans.LookupMarkdownMessage
rendered := bytes.NewBuffer(make([]byte, 0)) 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") 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) { func (h *LoginHandler) renderNoChallengeInRequest(w http.ResponseWriter, localizer *i18n.Localizer) {
err := h.templates[NoChallengeInRequest].Lookup("base").Execute(w, map[string]interface{}{ err := h.templates[NoChallengeInRequest].Lookup("base").Execute(w, map[string]interface{}{
"Title": h.messageCatalog.LookupMessage("NoChallengeInRequestTitle", nil, localizer), "Title": h.trans.LookupMessage("NoChallengeInRequestTitle", nil, localizer),
"Explanation": template.HTML(h.messageCatalog.LookupMarkdownMessage( //nolint:gosec "Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec
"NoChallengeInRequestExplanation", "NoChallengeInRequestExplanation",
nil, nil,
localizer, localizer,
@ -405,32 +348,12 @@ func (h *LoginHandler) renderNoChallengeInRequest(w http.ResponseWriter, localiz
} }
func NewLoginHandler( func NewLoginHandler(
logger *log.Logger, logger *log.Logger, tc TemplateCache, trans *services.I18NService, adminClient client.OAuth2Api,
bundle *i18n.Bundle,
messageCatalog *services.MessageCatalog,
adminClient client.OAuth2Api,
) *LoginHandler { ) *LoginHandler {
return &LoginHandler{ return &LoginHandler{
adminClient: adminClient,
bundle: bundle,
logger: logger, logger: logger,
templates: map[templateName]*template.Template{ trans: trans,
CertificateLogin: template.Must(template.ParseFS( adminClient: adminClient,
ui.Templates, templates: tc,
"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,
} }
} }

@ -18,16 +18,13 @@ limitations under the License.
package handlers package handlers
import ( import (
"bytes"
"html/template" "html/template"
"net/http" "net/http"
"github.com/nicksnyder/go-i18n/v2/i18n"
client "github.com/ory/hydra-client-go/v2" client "github.com/ory/hydra-client-go/v2"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"code.cacert.org/cacert/oidc-idp/internal/services" "code.cacert.org/cacert/oidc-idp/internal/services"
"code.cacert.org/cacert/oidc-idp/ui"
) )
type LogoutHandler struct { type LogoutHandler struct {
@ -73,7 +70,7 @@ func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
} }
func NewLogoutHandler(logger *log.Logger, adminClient client.OAuth2Api) *LogoutHandler { func NewLogout(logger *log.Logger, adminClient client.OAuth2Api) *LogoutHandler {
return &LogoutHandler{ return &LogoutHandler{
logger: logger, logger: logger,
adminClient: adminClient, adminClient: adminClient,
@ -81,10 +78,9 @@ func NewLogoutHandler(logger *log.Logger, adminClient client.OAuth2Api) *LogoutH
} }
type LogoutSuccessHandler struct { type LogoutSuccessHandler struct {
bundle *i18n.Bundle logger *log.Logger
logger *log.Logger trans *services.I18NService
messageCatalog *services.MessageCatalog templates TemplateCache
template *template.Template
} }
func (h *LogoutSuccessHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 return
} }
accept := r.Header.Get("Accept-Language") localizer := getLocalizer(h.trans, r)
localizer := i18n.NewLocalizer(h.bundle, accept)
rendered := bytes.NewBuffer(make([]byte, 0)) h.templates.render(h.logger, w, LogoutSuccessful, map[string]interface{}{
"Title": h.trans.LookupMessage("LogoutSuccessfulTitle", nil, localizer),
err := h.template.Lookup("base").Execute(rendered, map[string]interface{}{ "Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec
"Title": h.messageCatalog.LookupMessage("LogoutSuccessfulTitle", nil, localizer),
"Explanation": template.HTML(h.messageCatalog.LookupMarkdownMessage( //nolint:gosec
"LogoutSuccessfulText", "LogoutSuccessfulText",
nil, nil,
localizer, 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, logger *log.Logger,
bundle *i18n.Bundle, templateCache TemplateCache,
messageCatalog *services.MessageCatalog, trans *services.I18NService,
) *LogoutSuccessHandler { ) *LogoutSuccessHandler {
return &LogoutSuccessHandler{ return &LogoutSuccessHandler{logger: logger, trans: trans, templates: templateCache}
bundle: bundle,
logger: logger,
messageCatalog: messageCatalog,
template: template.Must(template.ParseFS(ui.Templates,
"templates/base.gohtml",
"templates/logout_successful.gohtml",
)),
}
} }

@ -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}
}

@ -18,9 +18,12 @@ limitations under the License.
package handlers package handlers
import ( import (
"crypto/x509"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
log "github.com/sirupsen/logrus"
) )
func EnableHSTS() func(http.Handler) http.Handler { 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
}

@ -21,6 +21,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"net/http"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@ -32,8 +33,136 @@ import (
"golang.org/x/text/language" "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 := make(map[string]*i18n.Message)
messages["http404"] = &i18n.Message{
ID: "http404",
Description: "HTTP error 404 not found",
Other: "Not found",
}
messages["unknown"] = &i18n.Message{ messages["unknown"] = &i18n.Message{
ID: "ErrorUnknown", ID: "ErrorUnknown",
Other: "Unknown error", 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." + 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.", " 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{ messages["TitleRequestConsent"] = &i18n.Message{
ID: "TitleRequestConsent", ID: "TitleRequestConsent",
Other: "Application requests your consent", Other: "Application requests your consent",
@ -59,6 +237,14 @@ func AddMessages(catalog *MessageCatalog) error {
ID: "LabelConsent", ID: "LabelConsent",
Other: "I hereby agree that the application may get the requested permissions.", 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{ messages["IntroConsentRequested"] = &i18n.Message{
ID: "IntroConsentRequested", ID: "IntroConsentRequested",
Other: "The <strong>{{ .client }}</strong> application requested your consent for the following set of " + Other: "The <strong>{{ .client }}</strong> 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. " + Other: "You have presented a valid client certificate for multiple email addresses. " +
"Please choose which one you want to present to the application:", "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{ messages["LoginTitle"] = &i18n.Message{
ID: "LoginTitle", ID: "LoginTitle",
Other: "Authenticate with a client certificate", Other: "Authenticate with a client certificate",
@ -117,10 +312,22 @@ func AddMessages(catalog *MessageCatalog) error {
ID: "LogoutSuccessfulText", ID: "LogoutSuccessfulText",
Other: "You have been logged out successfully.", 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{ messages["HintChooseAnIdentityForAuthentication"] = &i18n.Message{
ID: "HintChooseAnIdentityForAuthentication", ID: "HintChooseAnIdentityForAuthentication",
Other: "Choose an identity for authentication.", 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{ messages["NoEmailAddressSelected"] = &i18n.Message{
ID: "NoEmailAddressSelected", ID: "NoEmailAddressSelected",
Other: "You did not select an email address. Please select an email address to continue.", 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).", " [the ORY Hydra documentation](https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow).",
} }
catalog.AddMessages(messages) s.catalog.AddMessages(messages)
return nil return nil
} }
type MessageCatalog struct { func (s *I18NService) Localizer(languages string) *i18n.Localizer {
messages map[string]*i18n.Message return i18n.NewLocalizer(s.bundle, languages)
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 InitI18n(logger *log.Logger, languages []string) (*i18n.Bundle, *MessageCatalog) { func InitI18n(logger *log.Logger, languages []string) *I18NService {
bundle := i18n.NewBundle(language.English) bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
@ -311,7 +380,7 @@ func InitI18n(logger *log.Logger, languages []string) (*i18n.Bundle, *MessageCat
catalog := initMessageCatalog(logger) catalog := initMessageCatalog(logger)
return bundle, catalog return &I18NService{logger: logger, bundle: bundle, catalog: catalog}
} }
func initMessageCatalog(logger *log.Logger) *MessageCatalog { func initMessageCatalog(logger *log.Logger) *MessageCatalog {

@ -6,6 +6,21 @@ other = "Eine Anfrage, die dein Browser an den Authorization-Server geschickt ha
hash = "sha1-fa3294b49220d6de6f68825d03195e3f33e88378" hash = "sha1-fa3294b49220d6de6f68825d03195e3f33e88378"
other = "Fehlermeldung vom Authorization-Server" 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] [CertLoginIntroText]
hash = "sha1-e9f7c0522e49ffacc49e3fc35c6ffd31e495baf6" hash = "sha1-e9f7c0522e49ffacc49e3fc35c6ffd31e495baf6"
other = "Die Anwendung <strong>{{ .ClientName }}</strong> fragt nach einer Anmeldung." other = "Die Anwendung <strong>{{ .ClientName }}</strong> fragt nach einer Anmeldung."
@ -18,6 +33,39 @@ other = "Willst du die ausgewählte Identität für die Anmeldung verwenden?"
hash = "sha1-4a6721995b5d87c02be77695910af642ca30b18a" hash = "sha1-4a6721995b5d87c02be77695910af642ca30b18a"
other = "Zusätzlich möchte die Anwendung Zugriff auf folgende Informationen:" 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] [EmailChoiceText]
hash = "sha1-8bba8cd3a8724d8c5b75da9b7d2ac084b6e9df90" hash = "sha1-8bba8cd3a8724d8c5b75da9b7d2ac084b6e9df90"
one = "Du hast ein gültiges Client-Zertifikat für die folgende E-Mail-Adresse vorgelegt:" 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" hash = "sha1-7ee5b067009bbedc061274ee50a3027b50a06163"
other = "Wähle eine Identität für die Anmeldung." 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] [IntroConsentMoreInformation]
hash = "sha1-f58b8378238bd433deef3c3e6b0b70d0fd0dd59e" hash = "sha1-f58b8378238bd433deef3c3e6b0b70d0fd0dd59e"
other = "Auf der <a href=\"{{ .clientLink }}\">Beschreibungsseite</a> findest du mehr Informationen zu <strong>{{ .client }}</strong>." other = "Auf der <a href=\"{{ .clientLink }}\">Beschreibungsseite</a> findest du mehr Informationen zu <strong>{{ .client }}</strong>."
@ -52,6 +108,10 @@ other = "Ja, bitte"
hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6" hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6"
other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf." other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf."
[LabelNever]
hash = "sha1-80c3052d33ccdee15ffaaa110c5c39072495fe63"
other = "Nie"
[LabelRejectCertLogin] [LabelRejectCertLogin]
description = "Label for a button to reject certificate login" description = "Label for a button to reject certificate login"
hash = "sha1-49c4d0d1da1c0a7d7e9bf491b28a6e6825f4c2cd" hash = "sha1-49c4d0d1da1c0a7d7e9bf491b28a6e6825f4c2cd"
@ -61,6 +121,10 @@ other = "Nein, schick mich zurück"
hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea" hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea"
other = "Abschicken" other = "Abschicken"
[LabelUnknown]
hash = "sha1-bc7819b34ff87570745fbe461e36a16f80e562ce"
other = "Unbekannt"
[LoginDeniedByUser] [LoginDeniedByUser]
hash = "sha1-bbad650536bfb091ad55d576262bbe4358277c73" hash = "sha1-bbad650536bfb091ad55d576262bbe4358277c73"
other = "Die Anmeldung wurde durch den Nutzer abgelehnt." other = "Die Anmeldung wurde durch den Nutzer abgelehnt."
@ -77,6 +141,14 @@ other = "Du wurdest erfolreich abgemeldet."
hash = "sha1-c92ba3b5f47d0b37b43ba499793a09baa94e5b9d" hash = "sha1-c92ba3b5f47d0b37b43ba499793a09baa94e5b9d"
other = "Abmeldung erfolgreich" 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] [NoChallengeInRequestExplanation]
hash = "sha1-b26a3ef99ecadfbceb62b62eb52e34d197c56c02" 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)." 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" hash = "sha1-b039c647fea0e42bcb0c877c58da499d082f5319"
other = "Kein Challenge-Parameter im Anmelde-Request" other = "Kein Challenge-Parameter im Anmelde-Request"
[NoConsentGiven]
hash = "sha1-6843ba66b1103d3eed6403f7e276301b852e7ac0"
other = "Du hast noch keiner Applikation eine Freigabe erteilt."
[NoEmailAddressSelected] [NoEmailAddressSelected]
hash = "sha1-09fdefe67eae9915e32b18c50baf985d5bd27d36" hash = "sha1-09fdefe67eae9915e32b18c50baf985d5bd27d36"
other = "Du hast keine E-Mail-Adresse ausgewählt. Bitte wähle eine E-Mail-Adresse, um die Anmeldung fortzusetzen." 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] [WrongOrLockedUserOrInvalidPassword]
hash = "sha1-87e0a0ac67c6c3a06bed184e10b22aae4d075b64" hash = "sha1-87e0a0ac67c6c3a06bed184e10b22aae4d075b64"
other = "Du hast einen ungültigen Nutzernamen oder ein ungültiges Passwort eingegeben oder dein Benutzerkonto wurde gesperrt." 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"

@ -3,19 +3,28 @@ AuthServerErrorTitle = "Authorization server returned an error"
CertLoginIntroText = "The application <strong>{{ .ClientName }}</strong> requests a login." CertLoginIntroText = "The application <strong>{{ .ClientName }}</strong> requests a login."
CertLoginRequestText = "Do you want to use the chosen identity from the certificate for authentication?" 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:" 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" ErrorTitle = "An error has occurred"
ErrorUnknown = "Unknown error" ErrorUnknown = "Unknown error"
HintChooseAnIdentityForAuthentication = "Choose an identity for authentication." 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 <strong>{{ .client }}</strong> at <a href=\"{{ .clientLink }}\">its description page</a>." IntroConsentMoreInformation = "You can find more information about <strong>{{ .client }}</strong> at <a href=\"{{ .clientLink }}\">its description page</a>."
IntroConsentRequested = "The <strong>{{ .client }}</strong> application requested your consent for the following set of permissions:" IntroConsentRequested = "The <strong>{{ .client }}</strong> application requested your consent for the following set of permissions:"
LabelConsent = "I hereby agree that the application may get the requested permissions." LabelConsent = "I hereby agree that the application may get the requested permissions."
LabelNever = "Never"
LabelSubmit = "Submit" LabelSubmit = "Submit"
LabelUnknown = "Unknown"
LoginDeniedByUser = "Login has been denied by the user." LoginDeniedByUser = "Login has been denied by the user."
LoginTitle = "Authenticate with a client certificate" LoginTitle = "Authenticate with a client certificate"
LogoutSuccessfulText = "You have been logged out successfully." LogoutSuccessfulText = "You have been logged out successfully."
LogoutSuccessfulTitle = "Logout successful" 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)." 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" 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." 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." 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" 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" TitleRequestConsent = "Application requests your consent"
WrongOrLockedUserOrInvalidPassword = "You entered an invalid username or password or your account has been locked." 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] [EmailChoiceText]
one = "You have presented a valid client certificate for the following email address:" 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:" 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] [LabelRejectCertLogin]
description = "Label for a button to reject certificate login" description = "Label for a button to reject certificate login"
other = "No, please send me back" other = "No, please send me back"
[http404]
description = "HTTP error 404 not found"
other = "Not found"

@ -27,9 +27,10 @@
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
</head> </head>
<body class="idp d-flex flex-column h-100"> <body class="idp d-flex flex-column h-100">
<main role="main" class="flex-shrink-0"> <header class="container flex-shrink-0">
{{ template "content" . }} <a href="/"><img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4"></a>
</main> </header>
{{ template "content" . }}
<footer class="footer mt-auto py-3"> <footer class="footer mt-auto py-3">
<div class="container"> <div class="container">
<span class="text-muted small">© 2020-2023 <a href="https://www.cacert.org/">CAcert</a></span> <span class="text-muted small">© 2020-2023 <a href="https://www.cacert.org/">CAcert</a></span>

@ -1,36 +1,37 @@
{{ define "content" }} {{ define "content" }}
<form class="form-signin" method="post"> <main role="main" class="container">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4"> <h1>{{ .Title }}</h1>
<h1 class="h3 mb-3">{{ .Title }}</h1> <p>{{ .IntroText }}</p>
<p class="text-left">{{ .IntroText }}</p>
<p class="text-left">{{ .EmailChoiceText }}</p>
{{ with .FlashMessage }} {{ with .FlashMessage }}
<div class="alert alert-{{ .Type }}" role="alert"> <div class="alert alert-{{ .Type }}" role="alert">
{{ .Message }} {{ .Message }}
</div> </div>
{{ end }} {{ end }}
<div class="mb-3"> <form method="post">
{{ if eq (len .emails) 1 }} <p>{{ .EmailChoiceText }}</p>
{{ $email_address := index .emails 0 }} <div class="mb-3">
<input type="hidden" name="email" value="{{ $email_address }}" id="email_0"> {{ if eq (len .emails) 1 }}
<label for="email_0">{{ $email_address }}</label> {{ $email_address := index .emails 0 }}
{{ else }} <input type="hidden" name="email" value="{{ $email_address }}" id="email_0">
{{ range $index, $element := .emails }} <label for="email_0">{{ $email_address }}</label>
<div class="form-check"> {{ else }}
<input class="form-check-input" type="radio" name="email" {{ range $index, $element := .emails }}
value="{{ $element }}" id="email_{{ $index }}"><label <div class="form-check">
class="form-check-label" for="email_{{ $index }}">{{ $element }}</label> <input class="form-check-input" type="radio" name="email"
</div> value="{{ $element }}" id="email_{{ $index }}"><label
class="form-check-label" for="email_{{ $index }}">{{ $element }}</label>
</div>
{{ end }}
{{ end }} {{ end }}
{{ end }} {{ .csrfField }}
{{ .csrfField }} </div>
</div> <p class="text-left">{{ .RequestText }}</p>
<p class="text-left">{{ .RequestText }}</p> <div class="mb-2">
<div class="mb-2"> <button class="btn btn-primary" type="submit" name="use-identity"
<button class="btn btn-primary" type="submit" name="use-identity" value="accept">{{ .AcceptLabel }}</button>
value="accept">{{ .AcceptLabel }}</button> <button class="btn btn-outline-secondary" type="submit" name="use-identity"
<button class="btn btn-outline-secondary" type="submit" name="use-identity" value="reject">{{ .RejectLabel }}</button>
value="reject">{{ .RejectLabel }}</button> </div>
</div> </form>
</form> </main>
{{ end }} {{ end }}

@ -0,0 +1,11 @@
{{ define "content" }}
<main role="main" class="container">
<h1 class="h3 mb-3">{{ .Title }}</h1>
<p class="text-left">{{ .Explanation }}</p>
<form method="post">
{{ .csrfField }}
<button class="btn btn-danger" type="submit">{{ .ButtonTitleRevoke }}</button>
<a class="btn btn-outline-secondary" role="button" href="{{ .CancelLink }}">{{ .ButtonTitleCancel }}</a>
</form>
</main>
{{ end }}

@ -1,6 +1,5 @@
{{ define "content" }} {{ define "content" }}
<form class="form-consent" method="post"> <main role="main" class="container">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4">
<h1 class="h3 mb-3">{{ .Title }}</h1> <h1 class="h3 mb-3">{{ .Title }}</h1>
{{ if .client.LogoUri }} {{ if .client.LogoUri }}
<p> <p>
@ -8,33 +7,35 @@
</p> </p>
{{ end }} {{ end }}
<p class="text-left">{{ .IntroConsentRequested }}</p> <p class="text-left">{{ .IntroConsentRequested }}</p>
<ul class="list-group text-left small mb-3"> <form method="post">
{{ range $i, $scope := .requestedScope }}
<li class="list-group-item">
<input type="hidden" name="scope[{{ $i }}]" value="{{ $scope.Name }}">
{{ $scope.Label }}</li>
{{ end }}
</ul>
{{ if .requestedClaims }}
<p class="text-left">{{ .ClaimsInformation }}</p>
<ul class="list-group text-left small mb-3"> <ul class="list-group text-left small mb-3">
{{ range $i, $claim := .requestedClaims }} {{ range $i, $scope := .requestedScope }}
<li class="list-group-item"> <li class="list-group-item">
<input type="hidden" name="claims[{{ $i }}]" value="{{ $claim.Name }}"> <input type="hidden" name="scope[{{ $i }}]" value="{{ $scope.Name }}">
{{ $claim.Label }}{{ if $claim.Essential }} *{{ end}} {{ $scope.Label }}</li>
</li> {{ end }}
{{ end}}
</ul> </ul>
{{ end }} {{ if .requestedClaims }}
<p class="text-left">{{ .IntroMoreInformation }}</p> <p class="text-left">{{ .ClaimsInformation }}</p>
<ul class="list-group text-left small mb-3">
{{ range $i, $claim := .requestedClaims }}
<li class="list-group-item">
<input type="hidden" name="claims[{{ $i }}]" value="{{ $claim.Name }}">
{{ $claim.Label }}{{ if $claim.Essential }} *{{ end}}
</li>
{{ end}}
</ul>
{{ end }}
<p class="text-left">{{ .IntroMoreInformation }}</p>
{{ .csrfField }} {{ .csrfField }}
<div class="checkbox mb-3"> <div class="checkbox mb-3">
<label> <label>
<input type="checkbox" name="consent" id="consent" value="true"/> <input type="checkbox" name="consent" id="consent" value="true"/>
{{ .LabelConsent }}</label> {{ .LabelConsent }}</label>
</div> </div>
<button class="btn btn-lg btn-primary btn-block" type="submit">{{ .LabelSubmit }}</button> <button class="btn btn-lg btn-primary btn-block" type="submit">{{ .LabelSubmit }}</button>
</form> </form>
</main>
{{ end }} {{ end }}

@ -1,13 +1,15 @@
{{ define "content" }} {{ define "content" }}
<div class="container"> <main role="main" class="container">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4">
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
<h2>{{ if .details.ErrorCode }} <div class="alert alert-danger">
<strong>{{ .details.ErrorCode }}</strong> {{ end }}{{ .details.ErrorMessage }}</h2> <h2 class="alert-heading">{{ if .details.ErrorCode }}
{{ if .details.ErrorDetails }} <strong>{{ .details.ErrorCode }}</strong> {{ end }}{{ .details.ErrorMessage }}
{{ range .details.ErrorDetails }} </h2>
<p>{{ . }}</p> {{ if .details.ErrorDetails }}
{{ range .details.ErrorDetails }}
{{ . }}
{{ end }}
{{ end }} {{ end }}
{{ end }} </div>
</div> </main>
{{ end }} {{ end }}

@ -0,0 +1,6 @@
{{ define "content" }}
<main role="main" class="container">
<h1>{{ .Title }}</h1>
<p>{{ .WelcomeMessage }}</p>
</main>
{{ end }}

@ -0,0 +1,47 @@
{{ define "content" }}
<main role="main" class="container">
<h1>{{ .Title }}</h1>
<p class="text-left">{{ .Description }}</p>
{{ range .Flashes}}
<div class="alert alert-{{ .Type }}" role="alert">
{{ .Message }}
</div>
{{ end }}
{{ $buttonTitleRevoke := .ButtonTitleRevoke }}
{{ $unknownLabel := .LabelUnknown }}
{{ $neverLabel := .LabelNever }}
{{ if .ConsentSessions }}
<table class="table table-striped">
<thead>
<tr>
<th>{{ .ApplicationTitle }}</th>
<th>{{ .SubjectTitle }}</th>
<th>{{ .GrantedTitle }}</th>
<th>{{ .ExpiresTitle }}</th>
<th>{{ .ActionsTitle }}</th>
</tr>
</thead>
<tbody>
{{ range .ConsentSessions }}
<tr>
<td>{{ .GetClientName }}</td>
<td>{{ .Subject }}</td>
<td>{{ if .GrantedAt }}<span
title="{{ .GrantedAt }}">{{ .GrantedAt | humantime }}</span>{{ else }}{{ $unknownLabel }}{{ end }}
</td>
<td>{{ if .Expires }}<span
title="{{ .Expires }}">{{ .Expires | humantime }}</span>{{ else }}{{ $neverLabel }}{{ end }}
</td>
<td><a href="/revoke-consent/{{ .GetID }}?subject={{ .Subject }}" class="btn btn-danger"
role="button">{{ $buttonTitleRevoke }}</a></td>
</tr>
{{ end }}
</tbody>
</table>
{{ else}}
<div class="alert alert-info">
{{ .NoConsentGiven }}
</div>
{{ end}}
</main>
{{ end }}

@ -1,7 +1,6 @@
{{ define "content" }} {{ define "content" }}
<div class="container"> <main role="main" class="container">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4"> <h1>{{ .Title }}</h1>
<h1 class="h3 mb-3">{{ .Title }}</h1> <p>{{ .Explanation }}</p>
<p class="text-left">{{ .Explanation }}</p> </main>
</div>
{{ end }} {{ end }}

@ -1,7 +1,6 @@
{{ define "content" }} {{ define "content" }}
<div class="container"> <main role="main" class="container">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4"> <h1>{{ .Title }}</h1>
<h1 class="h3 mb-3">{{ .Title }}</h1> <p>{{ .Explanation }}</p>
<p class="text-left">{{ .Explanation }}</p> </main>
</div>
{{ end }} {{ end }}
Loading…
Cancel
Save