package handlers import ( "context" "database/sql" "encoding/json" "html/template" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-playground/form/v4" "github.com/go-sql-driver/mysql" "github.com/gorilla/csrf" "github.com/lestrrat-go/jwx/jwt/openid" "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/ory/hydra-client-go/client/admin" "github.com/ory/hydra-client-go/models" log "github.com/sirupsen/logrus" "git.cacert.org/oidc_login/common/handlers" commonModels "git.cacert.org/oidc_login/common/models" commonServices "git.cacert.org/oidc_login/common/services" "git.cacert.org/oidc_login/idp/services" ) type consentHandler struct { adminClient *admin.Client bundle *i18n.Bundle consentTemplate *template.Template context context.Context logger *log.Logger messageCatalog *commonServices.MessageCatalog } type ConsentInformation struct { GrantedScopes []string `form:"scope"` SelectedClaims []string `form:"claims"` ConsentChecked bool `form:"consent"` } type UserInfo struct { Email string `db:"email"` EmailVerified bool `db:"verified"` GivenName string `db:"fname"` MiddleName string `db:"mname"` FamilyName string `db:"lname"` BirthDate mysql.NullTime `db:"dob"` Language string `db:"language"` Modified mysql.NullTime `db:"modified"` } var supportedScopes, supportedClaims map[string]*i18n.Message const ( ScopeOpenID = "openid" ScopeOffline = "offline" ScopeOfflineAccess = "offline_access" ScopeProfile = "profile" ScopeEmail = "email" ClaimCAcertGroups = "https://cacert.localhost/groups" ) 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 including your name, birth date and locale.", } supportedScopes[ScopeEmail] = &i18n.Message{ ID: "Scope-email-Description", Other: "Access your primary 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 supportedClaims[ClaimCAcertGroups] = &i18n.Message{ ID: "claim-CAcert-groups-description", Other: "Your CAcert team or group assignments.", } } func (i *UserInfo) GetFullName() string { nameParts := make([]string, 0) if len(i.GivenName) > 0 { nameParts = append(nameParts, i.GivenName) } if len(i.MiddleName) > 0 { nameParts = append(nameParts, i.MiddleName) } if len(i.FamilyName) > 0 { nameParts = append(nameParts, i.FamilyName) } return strings.Join(nameParts, " ") } func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { challenge := r.URL.Query().Get("consent_challenge") h.logger.Debugf("received consent challenge %s", challenge) accept := r.Header.Get("Accept-Language") localizer := i18n.NewLocalizer(h.bundle, accept) // retrieve consent information consentData, requestedClaims, err := h.getRequestedConsentInformation(challenge, r) if err != nil { // error is already handled in getRequestConsentInformation return } switch r.Method { case http.MethodGet: h.renderConsentForm(w, r, consentData, requestedClaims, err, localizer) break case http.MethodPost: var consentInfo ConsentInformation // validate input decoder := form.NewDecoder() if err := decoder.Decode(&consentInfo, r.Form); err != nil { h.logger.Error(err) http.Error( w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError, ) return } if consentInfo.ConsentChecked { sessionData, err := h.getSessionData(consentInfo, requestedClaims, consentData.Payload, r.Context()) if err != nil { h.logger.Errorf("could not get session data: %v", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } consentRequest, err := h.adminClient.AcceptConsentRequest( admin.NewAcceptConsentRequestParams().WithConsentChallenge(challenge).WithBody( &models.AcceptConsentRequest{ GrantAccessTokenAudience: nil, GrantScope: consentInfo.GrantedScopes, HandledAt: models.NullTime(time.Now()), Remember: true, RememberFor: 86400, Session: sessionData, }).WithTimeout(time.Second * 10)) if err != nil { h.logger.Error(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Add("Location", *consentRequest.GetPayload().RedirectTo) w.WriteHeader(http.StatusFound) return } else { consentRequest, err := h.adminClient.RejectConsentRequest( admin.NewRejectConsentRequestParams().WithConsentChallenge(challenge).WithBody( &models.RejectRequest{})) if err != nil { h.logger.Error(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Add("Location", *consentRequest.GetPayload().RedirectTo) w.WriteHeader(http.StatusFound) } } } func (h *consentHandler) getRequestedConsentInformation(challenge string, r *http.Request) ( *admin.GetConsentRequestOK, *commonModels.OIDCClaimsRequest, error, ) { consentData, err := h.adminClient.GetConsentRequest( admin.NewGetConsentRequestParams().WithConsentChallenge(challenge)) if err != nil { h.logger.Errorf("error getting consent information: %v", err) var errorDetails *handlers.ErrorDetails switch v := err.(type) { case *admin.GetConsentRequestConflict: errorDetails = &handlers.ErrorDetails{ ErrorMessage: *v.Payload.Error, ErrorDetails: []string{v.Payload.ErrorDescription}, } if v.Payload.StatusCode != 0 { errorDetails.ErrorCode = strconv.Itoa(int(v.Payload.StatusCode)) } break default: errorDetails = &handlers.ErrorDetails{ ErrorMessage: "could not get consent details", ErrorDetails: []string{http.StatusText(http.StatusInternalServerError)}, } } handlers.GetErrorBucket(r).AddError(errorDetails) return nil, nil, err } var requestedClaims commonModels.OIDCClaimsRequest requestUrl, err := url.Parse(consentData.Payload.RequestURL) if err != nil { h.logger.Warnf("could not parse original request URL %s: %v", consentData.Payload.RequestURL, err) } else { claimsParameter := requestUrl.Query().Get("claims") if claimsParameter != "" { decoder := json.NewDecoder(strings.NewReader(claimsParameter)) err := decoder.Decode(&requestedClaims) if err != nil { h.logger.Warnf( "ignoring claims request parameter %s that could not be decoded: %v", claimsParameter, err, ) } } } return consentData, &requestedClaims, nil } func (h *consentHandler) renderConsentForm( w http.ResponseWriter, r *http.Request, consentData *admin.GetConsentRequestOK, claims *commonModels.OIDCClaimsRequest, err error, localizer *i18n.Localizer, ) { trans := func(id string, values ...map[string]interface{}) string { if len(values) > 0 { return h.messageCatalog.LookupMessage(id, values[0], localizer) } return h.messageCatalog.LookupMessage(id, nil, localizer) } // render consent form client := consentData.GetPayload().Client err = h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{ "Title": trans("TitleRequestConsent"), csrf.TemplateTag: csrf.TemplateField(r), "errors": map[string]string{}, "client": client, "requestedScope": h.mapRequestedScope(consentData.GetPayload().RequestedScope, localizer), "requestedClaims": h.mapRequestedClaims(claims, localizer), "LabelSubmit": trans("LabelSubmit"), "LabelConsent": trans("LabelConsent"), "IntroMoreInformation": template.HTML(trans("IntroConsentMoreInformation", map[string]interface{}{ "client": client.ClientName, "clientLink": client.ClientURI, })), "ClaimsInformation": template.HTML(trans("ClaimsInformation", nil)), "IntroConsentRequested": template.HTML(trans("IntroConsentRequested", map[string]interface{}{ "client": client.ClientName, })), }) } type scopeWithLabel struct { Name string Label string } func (h *consentHandler) mapRequestedScope(scope models.StringSlicePipeDelimiter, localizer *i18n.Localizer) []*scopeWithLabel { result := make([]*scopeWithLabel, 0) for _, scopeName := range scope { if _, ok := supportedScopes[scopeName]; !ok { h.logger.Warnf("unsupported scope %s ignored", scopeName) continue } label, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: supportedScopes[scopeName], }) if err != nil { h.logger.Warnf("could not localize label for scope %s: %v", scopeName, err) 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 *commonModels.OIDCClaimsRequest, localizer *i18n.Localizer) []*claimWithLabel { result := make([]*claimWithLabel, 0) known := make(map[string]bool) for _, claimElement := range []*commonModels.ClaimElement{claims.GetUserInfo(), claims.GetIDToken()} { if claimElement != nil { for k, v := range *claimElement { if _, ok := supportedClaims[k]; !ok { h.logger.Warnf("unsupported claim %s ignored", k) continue } label, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: supportedClaims[k], }) if err != nil { h.logger.Warnf("could not localize label for claim %s: %v", k, err) 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( info ConsentInformation, claims *commonModels.OIDCClaimsRequest, payload *models.ConsentRequest, ctx context.Context, ) (*models.ConsentRequestSession, error) { idTokenData := make(map[string]interface{}, 0) accessTokenData := make(map[string]interface{}, 0) db := services.GetDb(h.context) stmt, err := db.PreparexContext( ctx, `SELECT email, verified, fname, mname, lname, dob, language, modified FROM users WHERE uniqueid = ? AND locked = 0`, ) if err != nil { h.logger.Errorf("error preparing user information SQL: %v", err) return nil, err } defer func() { _ = stmt.Close() }() userInfo := &UserInfo{} err = stmt.QueryRowxContext(ctx, payload.Subject).StructScan(userInfo) switch { case err == sql.ErrNoRows: h.logger.Errorf("could not find entry for subject %s", payload.Subject) return nil, err case err != nil: h.logger.Errorf("error performing user information SQL: %v", err) return nil, err default: h.fillTokenData(accessTokenData, payload.RequestedScope, claims, info, userInfo) h.fillTokenData(idTokenData, payload.RequestedScope, claims, info, userInfo) return &models.ConsentRequestSession{ AccessToken: accessTokenData, IDToken: idTokenData, }, nil } } func (h *consentHandler) fillTokenData(m map[string]interface{}, requestedScope models.StringSlicePipeDelimiter, claimsRequest *commonModels.OIDCClaimsRequest, consentInformation ConsentInformation, userInfo *UserInfo) { for _, scope := range requestedScope { granted := false for _, k := range consentInformation.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] = userInfo.Email m[openid.EmailVerifiedKey] = userInfo.EmailVerified break 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.GivenNameKey] = userInfo.GivenName m[openid.FamilyNameKey] = userInfo.FamilyName m[openid.MiddleNameKey] = userInfo.MiddleName m[openid.NameKey] = userInfo.GetFullName() if userInfo.BirthDate.Valid { m[openid.BirthdateKey] = userInfo.BirthDate.Time.Format("2006-01-02") } m[openid.LocaleKey] = userInfo.Language if userInfo.Modified.Valid { m[openid.UpdatedAtKey] = userInfo.Modified.Time.Unix() } break } } if userInfoClaims := claimsRequest.GetUserInfo(); userInfoClaims != nil { for claimName, claim := range *userInfoClaims { granted := false for _, k := range consentInformation.SelectedClaims { if k == claimName { granted = true break } } if !granted { continue } if claim.WantedValue() != nil { m[claimName] = *claim.WantedValue() continue } switch claimName { case ClaimCAcertGroups: m[claimName] = []string{"admin", "user"} break default: if claim.IsEssential() { h.logger.Warnf( "handling for essential claim name %s not implemented", claimName, ) } else { h.logger.Warnf( "handling for claim name %s not implemented", claimName, ) } } } } } func NewConsentHandler(ctx context.Context, logger *log.Logger) (*consentHandler, error) { consentTemplate, err := template.ParseFiles( "templates/idp/base.gohtml", "templates/idp/consent.gohtml") if err != nil { return nil, err } return &consentHandler{ adminClient: ctx.Value(CtxAdminClient).(*admin.Client), bundle: commonServices.GetI18nBundle(ctx), consentTemplate: consentTemplate, context: ctx, logger: logger, messageCatalog: commonServices.GetMessageCatalog(ctx), }, nil }