oidc-idp/internal/handlers/consent.go
Jan Dittberner 44e18ca3a5 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
2023-08-07 15:15:45 +02:00

599 lines
16 KiB
Go

/*
Copyright 2020-2023 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-playground/form/v4"
"github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"github.com/lestrrat-go/jwx/jwt/openid"
"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/models"
"code.cacert.org/cacert/oidc-idp/internal/services"
)
type ConsentHandler struct {
logger *log.Logger
trans *services.I18NService
adminClient client.OAuth2Api
templates TemplateCache
}
type ConsentInformation struct {
GrantedScopes []string `form:"scope"`
SelectedClaims []string `form:"claims"`
ConsentChecked bool `form:"consent"`
}
type UserInfo struct {
Email string
EmailVerified bool
CommonName string
}
var supportedScopes, supportedClaims map[string]*i18n.Message
const (
ScopeOpenID = "openid"
ScopeOffline = "offline"
ScopeOfflineAccess = "offline_access"
ScopeProfile = "profile"
ScopeEmail = "email"
)
func init() {
supportedScopes = make(map[string]*i18n.Message)
supportedScopes[ScopeOpenID] = &i18n.Message{
ID: "Scope-openid-Description",
Other: "Request information about your identity.",
}
supportedScopes[ScopeOffline] = &i18n.Message{
ID: "Scope-offline-Description",
Other: "Keep access to your information until you revoke the permission.",
}
supportedScopes[ScopeOfflineAccess] = supportedScopes[ScopeOffline]
supportedScopes[ScopeProfile] = &i18n.Message{
ID: "Scope-profile-Description",
Other: "Access your user profile information (your name).",
}
supportedScopes[ScopeEmail] = &i18n.Message{
ID: "Scope-email-Description",
Other: "Access your email address.",
}
supportedClaims = make(map[string]*i18n.Message)
supportedClaims[openid.SubjectKey] = nil
supportedClaims[openid.EmailKey] = nil
supportedClaims[openid.EmailVerifiedKey] = nil
supportedClaims[openid.GivenNameKey] = nil
supportedClaims[openid.FamilyNameKey] = nil
supportedClaims[openid.MiddleNameKey] = nil
supportedClaims[openid.NameKey] = nil
supportedClaims[openid.BirthdateKey] = nil
supportedClaims[openid.ZoneinfoKey] = nil
supportedClaims[openid.LocaleKey] = nil
}
func (i *UserInfo) GetFullName() string {
return i.CommonName
}
func (h *ConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
challenge := r.URL.Query().Get("consent_challenge")
h.logger.WithField("consent_challenge", challenge).Debug("received consent challenge")
localizer := getLocalizer(h.trans, r)
// retrieve consent information
consentData, requestedClaims, err := h.getRequestedConsentInformation(challenge, r)
if err != nil {
// error is already handled in getRequestConsentInformation
return
}
session, err := GetSession(r)
if err != nil {
h.logger.WithError(err).Error("could get session for request")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
h.handleGet(w, r, consentData, requestedClaims, session, challenge, localizer)
case http.MethodPost:
h.handlePost(w, r, consentData, requestedClaims, session, challenge)
}
}
func (h *ConsentHandler) handleGet(
w http.ResponseWriter,
r *http.Request,
consentData *client.OAuth2ConsentRequest,
requestedClaims *models.OIDCClaimsRequest,
session *sessions.Session,
challenge string,
localizer *i18n.Localizer,
) {
// Hydra has a previous session for this user and client
if consentData.GetSkip() {
consentRequest, err := h.handleExistingConsent(consentData, requestedClaims, session)
if err != nil {
h.logger.WithError(err).Error("could not handle existing consent")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = h.acceptConsent(w, r, challenge, consentRequest)
if err != nil {
h.logger.WithError(err).Error("could not accept consent")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
h.renderConsentForm(w, r, consentData, requestedClaims, localizer)
}
func (h *ConsentHandler) handlePost(
w http.ResponseWriter,
r *http.Request,
consentData *client.OAuth2ConsentRequest,
requestedClaims *models.OIDCClaimsRequest,
session *sessions.Session,
challenge string,
) {
var consentInfo ConsentInformation
// validate input
decoder := form.NewDecoder()
if err := decoder.Decode(&consentInfo, r.Form); err != nil {
h.logger.WithError(err).Error("could not decode consent form")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if consentInfo.ConsentChecked {
consentRequest, err := h.rememberNewConsent(consentData, consentInfo, requestedClaims, session)
if err != nil {
h.logger.WithError(err).Error("could not accept consent")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
err = h.acceptConsent(w, r, challenge, consentRequest)
if err != nil {
h.logger.WithError(err).Error("could not accept consent")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
consentRequest, response, err := h.adminClient.RejectOAuth2ConsentRequest(
r.Context(),
).ConsentChallenge(challenge).Execute()
if err != nil {
h.logger.WithError(err).Error("reject consent request failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer func() { _ = response.Body.Close() }()
h.logger.WithFields(
log.Fields{"response": response.Status, "reject_consent_request": consentRequest},
).Debug("received response for RejectOAuth2ConsentRequest")
w.Header().Add("Location", consentRequest.GetRedirectTo())
w.WriteHeader(http.StatusFound)
}
func (h *ConsentHandler) rememberNewConsent(
consentData *client.OAuth2ConsentRequest,
consentInfo ConsentInformation,
requestedClaims *models.OIDCClaimsRequest,
session *sessions.Session,
) (*client.AcceptOAuth2ConsentRequest, error) {
sessionData, err := h.getSessionData(
consentData.GetRequestedScope(),
consentInfo.GrantedScopes,
consentInfo.SelectedClaims,
requestedClaims,
session,
)
if err != nil {
return nil, fmt.Errorf("could not get session data: %w", err)
}
request := client.NewAcceptOAuth2ConsentRequestWithDefaults()
request.SetRemember(true)
request.SetHandledAt(time.Now())
request.SetGrantAccessTokenAudience(consentData.RequestedAccessTokenAudience)
request.SetGrantScope(consentInfo.GrantedScopes)
request.SetRememberFor(0)
request.SetSession(*sessionData)
return request, nil
}
func (h *ConsentHandler) acceptConsent(
w http.ResponseWriter, r *http.Request, challenge string, request *client.AcceptOAuth2ConsentRequest,
) error {
oAuth2RedirectTo, response, err := h.adminClient.AcceptOAuth2ConsentRequest(
r.Context(),
).ConsentChallenge(challenge).AcceptOAuth2ConsentRequest(*request).Execute()
if err != nil {
return fmt.Errorf("accept consent request failed: %w", err)
}
defer func() { _ = response.Body.Close() }()
h.logger.WithFields(
log.Fields{"response": response.Status, "redirect_to": oAuth2RedirectTo},
).Debug("received response for AcceptOAuth2ConsentRequest")
w.Header().Add("Location", oAuth2RedirectTo.GetRedirectTo())
w.WriteHeader(http.StatusFound)
return nil
}
func (h *ConsentHandler) handleExistingConsent(
data *client.OAuth2ConsentRequest,
claims *models.OIDCClaimsRequest,
session *sessions.Session,
) (*client.AcceptOAuth2ConsentRequest, error) {
sessionData, err := h.getSessionData(data.GetRequestedScope(),
data.GetRequestedScope(), []string{}, claims, session)
if err != nil {
return nil, fmt.Errorf("could not get session data: %w", err)
}
request := client.NewAcceptOAuth2ConsentRequestWithDefaults()
request.SetGrantScope(data.RequestedScope)
request.SetHandledAt(time.Now())
request.SetGrantAccessTokenAudience(data.RequestedAccessTokenAudience)
request.SetSession(*sessionData)
return request, nil
}
func (h *ConsentHandler) getRequestedConsentInformation(challenge string, r *http.Request) (
*client.OAuth2ConsentRequest,
*models.OIDCClaimsRequest,
error,
) {
consentRequest, response, err := h.adminClient.GetOAuth2ConsentRequest(
r.Context(),
).ConsentChallenge(challenge).Execute()
if err != nil {
h.logger.WithError(err).Error("error getting consent information")
if errorBucket := GetErrorBucket(r); errorBucket != nil {
errorDetails := &ErrorDetails{
ErrorMessage: "could not get consent details",
ErrorDetails: []string{http.StatusText(http.StatusInternalServerError)},
}
errorBucket.AddError(errorDetails)
}
return nil, nil, fmt.Errorf("error getting consent information: %w", err)
}
defer func() { _ = response.Body.Close() }()
h.logger.WithFields(
log.Fields{"response": response.Status, "consent_request": consentRequest},
).Debug("response for GetOAuth2ConsentRequest")
var requestedClaims models.OIDCClaimsRequest
requestURLStr := consentRequest.GetRequestUrl()
requestURL, err := url.Parse(requestURLStr)
if err != nil {
h.logger.WithError(err).WithField(
"request_url", requestURLStr,
).Warn("could not parse original request URL")
} else {
claimsParameter := requestURL.Query().Get("claims")
if claimsParameter != "" {
decoder := json.NewDecoder(strings.NewReader(claimsParameter))
err := decoder.Decode(&requestedClaims)
if err != nil {
h.logger.WithError(err).WithField(
"claims_parameter", claimsParameter,
).Warn("ignoring claims request parameter that could not be decoded")
}
}
}
return consentRequest, &requestedClaims, nil
}
func (h *ConsentHandler) renderConsentForm(
w http.ResponseWriter,
r *http.Request,
consentRequest *client.OAuth2ConsentRequest,
claims *models.OIDCClaimsRequest,
localizer *i18n.Localizer,
) {
trans := func(id string, values ...map[string]interface{}) string {
if len(values) > 0 {
return h.trans.LookupMessage(id, values[0], localizer)
}
return h.trans.LookupMessage(id, nil, localizer)
}
// render consent form
oAuth2Client := consentRequest.Client
h.templates.render(h.logger, w, Consent, map[string]interface{}{
"Title": trans("TitleRequestConsent"),
csrf.TemplateTag: csrf.TemplateField(r),
"errors": map[string]string{},
"client": oAuth2Client,
"requestedScope": h.mapRequestedScope(consentRequest.RequestedScope, localizer),
"requestedClaims": h.mapRequestedClaims(claims, localizer),
"LabelSubmit": trans("LabelSubmit"),
"LabelConsent": trans("LabelConsent"),
"IntroMoreInformation": template.HTML( //nolint:gosec
trans("IntroConsentMoreInformation", map[string]interface{}{
"client": oAuth2Client.GetClientName(),
"clientLink": oAuth2Client.GetClientUri(),
})),
"ClaimsInformation": template.HTML( //nolint:gosec
trans("ClaimsInformation", nil)),
"IntroConsentRequested": template.HTML( //nolint:gosec
trans("IntroConsentRequested", map[string]interface{}{
"client": oAuth2Client.GetClientName(),
})),
})
}
type scopeWithLabel struct {
Name string
Label string
}
func (h *ConsentHandler) mapRequestedScope(
scope []string,
localizer *i18n.Localizer,
) []*scopeWithLabel {
result := make([]*scopeWithLabel, 0)
for _, scopeName := range scope {
if _, ok := supportedScopes[scopeName]; !ok {
h.logger.WithField("scope", scopeName).Warn("ignoring unsupported scope")
continue
}
label, err := localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: supportedScopes[scopeName],
})
if err != nil {
h.logger.WithError(err).WithField("scope", scopeName).Warn("could not localize scope label")
label = scopeName
}
result = append(result, &scopeWithLabel{Name: scopeName, Label: label})
}
return result
}
type claimWithLabel struct {
Name string
Label string
Essential bool
}
func (h *ConsentHandler) mapRequestedClaims(
claims *models.OIDCClaimsRequest,
localizer *i18n.Localizer,
) []*claimWithLabel {
result := make([]*claimWithLabel, 0)
known := make(map[string]bool)
for _, claimElement := range []*models.ClaimElement{claims.GetUserInfo(), claims.GetIDToken()} {
if claimElement != nil {
for k, v := range *claimElement {
if _, ok := supportedClaims[k]; !ok {
h.logger.WithField("claim", k).Warn("ignoring unsupported claim")
continue
}
label, err := localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: supportedClaims[k],
})
if err != nil {
h.logger.WithError(err).WithField("claim", k).Warn("could not localize claim label")
label = k
}
if !known[k] {
result = append(result, &claimWithLabel{
Name: k,
Label: label,
Essential: v.IsEssential(),
})
known[k] = true
}
}
}
}
return result
}
func (h *ConsentHandler) getSessionData(
requestedScopes,
grantedScopes,
selectedClaims []string,
claims *models.OIDCClaimsRequest,
session *sessions.Session,
) (*client.AcceptOAuth2ConsentRequestSession, error) {
idTokenData := make(map[string]interface{})
if err := h.fillTokenData(
idTokenData,
requestedScopes,
grantedScopes,
claims,
selectedClaims,
session.Values,
); err != nil {
return nil, err
}
consentSession := client.NewAcceptOAuth2ConsentRequestSession()
consentSession.SetIdToken(idTokenData)
return consentSession, nil
}
func (h *ConsentHandler) fillTokenData(
m map[string]interface{},
requestedScopes []string,
grantedScopes []string,
claimsRequest *models.OIDCClaimsRequest,
selectedClaims []string,
sessionData map[interface{}]interface{},
) error {
for _, scope := range requestedScopes {
granted := false
for _, k := range grantedScopes {
if k == scope {
granted = true
break
}
}
if !granted {
continue
}
switch scope {
case ScopeEmail:
// email
// OPTIONAL. This scope value requests access to the email and
// email_verified Claims.
m[openid.EmailKey] = sessionData[services.SessionEmail]
m[openid.EmailVerifiedKey] = true
case ScopeProfile:
// profile
// OPTIONAL. This scope value requests access to the
// End-User's default profile Claims, which are: name,
// family_name, given_name, middle_name, nickname,
// preferred_username, profile, picture, website, gender,
// birthdate, zoneinfo, locale, and updated_at.
m[openid.NameKey] = sessionData[services.SessionFullName]
}
}
if userInfoClaims := claimsRequest.GetUserInfo(); userInfoClaims != nil {
err := h.parseUserInfoClaims(m, userInfoClaims, selectedClaims)
if err != nil {
return err
}
}
return nil
}
func (h *ConsentHandler) parseUserInfoClaims(
m map[string]interface{},
userInfoClaims *models.ClaimElement,
selectedClaims []string,
) error {
for claimName, claim := range *userInfoClaims {
granted := false
for _, k := range selectedClaims {
if k == claimName {
granted = true
break
}
}
if !granted {
continue
}
wantedValue, err := claim.WantedValue()
if err != nil {
if !errors.Is(err, models.ErrNoValue) {
return fmt.Errorf("error handling claim: %w", err)
}
}
if wantedValue != "" {
m[claimName] = wantedValue
continue
}
if claim.IsEssential() {
h.logger.WithField("claim", claimName).Warn("handling for essential claim not implemented")
} else {
h.logger.WithField("claim", claimName).Warn("handling for claim not implemented")
}
}
return nil
}
func NewConsentHandler(
logger *log.Logger, templateCache TemplateCache, trans *services.I18NService, adminClient client.OAuth2Api,
) *ConsentHandler {
return &ConsentHandler{
logger: logger,
trans: trans,
adminClient: adminClient,
templates: templateCache,
}
}