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 10 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",
}