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
.idea/
/.idea/
/cacert-idp
/certs/
/dist/
/idp.toml
/static
/translations/translate.*.toml
/ui/css/
/ui/images/
/ui/js/
certs/
/ui/js/

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

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

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

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

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

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

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

@ -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",

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

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

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

@ -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 <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. " +
"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 {

@ -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 <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"
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 <a href=\"{{ .clientLink }}\">Beschreibungsseite</a> findest du mehr Informationen zu <strong>{{ .client }}</strong>."
@ -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"

@ -3,19 +3,28 @@ AuthServerErrorTitle = "Authorization server returned an error"
CertLoginIntroText = "The application <strong>{{ .ClientName }}</strong> 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 <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:"
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"

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

@ -1,36 +1,37 @@
{{ define "content" }}
<form class="form-signin" method="post">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4">
<h1 class="h3 mb-3">{{ .Title }}</h1>
<p class="text-left">{{ .IntroText }}</p>
<p class="text-left">{{ .EmailChoiceText }}</p>
<main role="main" class="container">
<h1>{{ .Title }}</h1>
<p>{{ .IntroText }}</p>
{{ with .FlashMessage }}
<div class="alert alert-{{ .Type }}" role="alert">
{{ .Message }}
</div>
{{ end }}
<div class="mb-3">
{{ if eq (len .emails) 1 }}
{{ $email_address := index .emails 0 }}
<input type="hidden" name="email" value="{{ $email_address }}" id="email_0">
<label for="email_0">{{ $email_address }}</label>
{{ else }}
{{ range $index, $element := .emails }}
<div class="form-check">
<input class="form-check-input" type="radio" name="email"
value="{{ $element }}" id="email_{{ $index }}"><label
class="form-check-label" for="email_{{ $index }}">{{ $element }}</label>
</div>
<form method="post">
<p>{{ .EmailChoiceText }}</p>
<div class="mb-3">
{{ if eq (len .emails) 1 }}
{{ $email_address := index .emails 0 }}
<input type="hidden" name="email" value="{{ $email_address }}" id="email_0">
<label for="email_0">{{ $email_address }}</label>
{{ else }}
{{ range $index, $element := .emails }}
<div class="form-check">
<input class="form-check-input" type="radio" name="email"
value="{{ $element }}" id="email_{{ $index }}"><label
class="form-check-label" for="email_{{ $index }}">{{ $element }}</label>
</div>
{{ end }}
{{ end }}
{{ end }}
{{ .csrfField }}
</div>
<p class="text-left">{{ .RequestText }}</p>
<div class="mb-2">
<button class="btn btn-primary" type="submit" name="use-identity"
value="accept">{{ .AcceptLabel }}</button>
<button class="btn btn-outline-secondary" type="submit" name="use-identity"
value="reject">{{ .RejectLabel }}</button>
</div>
</form>
{{ .csrfField }}
</div>
<p class="text-left">{{ .RequestText }}</p>
<div class="mb-2">
<button class="btn btn-primary" type="submit" name="use-identity"
value="accept">{{ .AcceptLabel }}</button>
<button class="btn btn-outline-secondary" type="submit" name="use-identity"
value="reject">{{ .RejectLabel }}</button>
</div>
</form>
</main>
{{ 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" }}
<form class="form-consent" method="post">
<img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4">
<main role="main" class="container">
<h1 class="h3 mb-3">{{ .Title }}</h1>
{{ if .client.LogoUri }}
<p>
@ -8,33 +7,35 @@
</p>
{{ end }}
<p class="text-left">{{ .IntroConsentRequested }}</p>
<ul class="list-group text-left small mb-3">
{{ 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>
<form method="post">
<ul class="list-group text-left small mb-3">
{{ range $i, $claim := .requestedClaims }}
{{ range $i, $scope := .requestedScope }}
<li class="list-group-item">
<input type="hidden" name="claims[{{ $i }}]" value="{{ $claim.Name }}">
{{ $claim.Label }}{{ if $claim.Essential }} *{{ end}}
</li>
{{ end}}
<input type="hidden" name="scope[{{ $i }}]" value="{{ $scope.Name }}">
{{ $scope.Label }}</li>
{{ end }}
</ul>
{{ end }}
<p class="text-left">{{ .IntroMoreInformation }}</p>
{{ if .requestedClaims }}
<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 }}
<div class="checkbox mb-3">
<label>
<input type="checkbox" name="consent" id="consent" value="true"/>
{{ .LabelConsent }}</label>
</div>
{{ .csrfField }}
<div class="checkbox mb-3">
<label>
<input type="checkbox" name="consent" id="consent" value="true"/>
{{ .LabelConsent }}</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">{{ .LabelSubmit }}</button>
</form>
<button class="btn btn-lg btn-primary btn-block" type="submit">{{ .LabelSubmit }}</button>
</form>
</main>
{{ end }}

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

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