oidc-idp/handlers/consent.go
2021-09-11 13:35:15 +02:00

444 lines
13 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"
"encoding/json"
"html/template"
"net/http"
"net/url"
"strings"
"time"
commonModels "git.cacert.org/oidc_idp/models"
"github.com/go-playground/form/v4"
"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_idp/services"
)
type consentHandler struct {
adminClient *admin.Client
bundle *i18n.Bundle
consentTemplate *template.Template
context context.Context
logger *log.Logger
messageCatalog *services.MessageCatalog
}
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.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(r, consentInfo, requestedClaims, consentData.Payload)
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 *ErrorDetails
errorDetails = &ErrorDetails{
ErrorMessage: "could not get consent details",
ErrorDetails: []string{http.StatusText(http.StatusInternalServerError)},
}
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(
r *http.Request,
info ConsentInformation,
claims *commonModels.OIDCClaimsRequest,
payload *models.ConsentRequest,
) (*models.ConsentRequestSession, error) {
idTokenData := make(map[string]interface{}, 0)
accessTokenData := make(map[string]interface{}, 0)
userInfo := h.GetUserInfoFromClientCertificate(r, payload.Subject)
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.NameKey] = userInfo.GetFullName()
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
}
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 (h *consentHandler) GetUserInfoFromClientCertificate(r *http.Request, subject string) *UserInfo {
if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 {
firstCert := r.TLS.PeerCertificates[0]
var verified bool
for _, email := range firstCert.EmailAddresses {
h.logger.Infof("authenticated with a client certificate for email address %s", email)
if subject == email {
verified = true
}
}
if !verified {
h.logger.Warnf("authentication attempt with a wrong certificate that did not contain the requested address %s", subject)
return nil
}
return &UserInfo{
Email: subject,
EmailVerified: verified,
CommonName: firstCert.Subject.CommonName,
}
}
return nil
}
func NewConsentHandler(ctx context.Context, logger *log.Logger) (*consentHandler, error) {
consentTemplate := template.Must(template.ParseFiles(
"templates/base.gohtml", "templates/consent.gohtml"))
return &consentHandler{
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
bundle: services.GetI18nBundle(ctx),
consentTemplate: consentTemplate,
context: ctx,
logger: logger,
messageCatalog: services.GetMessageCatalog(ctx),
}, nil
}