This repository has been archived on 2022-07-28. You can view files and clone it, but cannot push or open issues or pull requests.
hydra_oidc_poc/idp/handlers/consent.go

477 lines
14 KiB
Go

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
}