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
2021-01-27 22:22:54 +01:00

495 lines
15 KiB
Go

/*
Copyright 2020, 2021 Jan Dittberner
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
http://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"
"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
}