Implement consent workflow
This commit is contained in:
parent
7a2174ea41
commit
e4f17ca315
12 changed files with 382 additions and 140 deletions
|
@ -5,6 +5,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/jwt"
|
||||||
|
"github.com/lestrrat-go/jwx/jwt/openid"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"git.cacert.org/oidc_login/app/services"
|
"git.cacert.org/oidc_login/app/services"
|
||||||
|
@ -21,7 +24,7 @@ func Authenticate(oauth2Config *oauth2.Config, clientId string) func(http.Handle
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := session.Values[sessionKeyUserId]; ok {
|
if _, ok := session.Values[sessionKeyIdToken]; ok {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -47,3 +50,12 @@ func Authenticate(oauth2Config *oauth2.Config, clientId string) func(http.Handle
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseIdToken(token string, keySet *jwk.Set) (openid.Token, error) {
|
||||||
|
if parsedIdToken, err := jwt.ParseString(token, jwt.WithKeySet(keySet), jwt.WithOpenIDClaims()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return parsedIdToken.(openid.Token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -6,12 +6,15 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/jwk"
|
||||||
|
|
||||||
"git.cacert.org/oidc_login/app/services"
|
"git.cacert.org/oidc_login/app/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type indexHandler struct {
|
type indexHandler struct {
|
||||||
logoutUrl string
|
logoutUrl string
|
||||||
serverAddr string
|
serverAddr string
|
||||||
|
keySet *jwk.Set
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
@ -51,21 +54,26 @@ func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reque
|
||||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var user string
|
var idToken string
|
||||||
var ok bool
|
var ok bool
|
||||||
if user, ok = session.Values[sessionKeyUsername].(string); ok {
|
if idToken, ok = session.Values[sessionKeyIdToken].(string); ok {
|
||||||
|
|
||||||
}
|
|
||||||
if idToken, ok := session.Values[sessionKeyIdToken].(string); ok {
|
|
||||||
logoutUrl.RawQuery = url.Values{
|
logoutUrl.RawQuery = url.Values{
|
||||||
"id_token_hint": []string{idToken},
|
"id_token_hint": []string{idToken},
|
||||||
"post_logout_redirect_uri": []string{fmt.Sprintf("https://%s/after-logout", h.serverAddr)},
|
"post_logout_redirect_uri": []string{fmt.Sprintf("https://%s/after-logout", h.serverAddr)},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcToken, err := ParseIdToken(idToken, h.keySet)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.Header().Add("Content-Type", "text/html")
|
writer.Header().Add("Content-Type", "text/html")
|
||||||
err = page.Execute(writer, map[string]interface{}{
|
err = page.Execute(writer, map[string]interface{}{
|
||||||
"User": user,
|
"User": oidcToken.Name(),
|
||||||
"LogoutURL": logoutUrl.String(),
|
"LogoutURL": logoutUrl.String(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -74,6 +82,6 @@ func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reque
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIndexHandler(logoutUrl string, serverAddr string) *indexHandler {
|
func NewIndexHandler(logoutUrl string, serverAddr string, keySet *jwk.Set) *indexHandler {
|
||||||
return &indexHandler{logoutUrl: logoutUrl, serverAddr: serverAddr}
|
return &indexHandler{logoutUrl: logoutUrl, serverAddr: serverAddr, keySet: keySet}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/go-openapi/runtime/client"
|
"github.com/go-openapi/runtime/client"
|
||||||
"github.com/lestrrat-go/jwx/jwk"
|
"github.com/lestrrat-go/jwx/jwk"
|
||||||
"github.com/lestrrat-go/jwx/jwt"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"git.cacert.org/oidc_login/app/services"
|
"git.cacert.org/oidc_login/app/services"
|
||||||
|
@ -17,10 +16,6 @@ const (
|
||||||
sessionKeyAccessToken = iota
|
sessionKeyAccessToken = iota
|
||||||
sessionKeyRefreshToken
|
sessionKeyRefreshToken
|
||||||
sessionKeyIdToken
|
sessionKeyIdToken
|
||||||
sessionKeyUserId
|
|
||||||
sessionKeyRoles
|
|
||||||
sessionKeyEmail
|
|
||||||
sessionKeyUsername
|
|
||||||
sessionRedirectTarget
|
sessionRedirectTarget
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,17 +24,24 @@ type oidcCallbackHandler struct {
|
||||||
oauth2Config *oauth2.Config
|
oauth2Config *oauth2.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *oidcCallbackHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
func (c *oidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if request.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.URL.Path != "/callback" {
|
if r.URL.Path != "/callback" {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code := request.URL.Query().Get("code")
|
errorText := r.URL.Query().Get("error")
|
||||||
|
errorDescription := r.URL.Query().Get("error_description")
|
||||||
|
if errorText != "" {
|
||||||
|
c.RenderErrorTemplate(w, r, errorText, errorDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
httpClient, err := client.TLSClient(client.TLSClientOptions{InsecureSkipVerify: true})
|
httpClient, err := client.TLSClient(client.TLSClientOptions{InsecureSkipVerify: true})
|
||||||
|
@ -47,28 +49,29 @@ func (c *oidcCallbackHandler) ServeHTTP(writer http.ResponseWriter, request *htt
|
||||||
|
|
||||||
tok, err := c.oauth2Config.Exchange(ctx, code)
|
tok, err := c.oauth2Config.Exchange(ctx, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Error(err)
|
log.Error(err)
|
||||||
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := services.GetSessionStore().Get(request, "resource_session")
|
session, err := services.GetSessionStore().Get(r, "resource_session")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Values[sessionKeyAccessToken] = tok.AccessToken
|
session.Values[sessionKeyAccessToken] = tok.AccessToken
|
||||||
session.Values[sessionKeyRefreshToken] = tok.RefreshToken
|
session.Values[sessionKeyRefreshToken] = tok.RefreshToken
|
||||||
session.Values[sessionKeyIdToken] = tok.Extra("id_token").(string)
|
|
||||||
|
|
||||||
idToken := tok.Extra("id_token")
|
idToken := tok.Extra("id_token").(string)
|
||||||
if parsedIdToken, err := jwt.ParseString(idToken.(string), jwt.WithKeySet(c.keySet), jwt.WithOpenIDClaims()); err != nil {
|
session.Values[sessionKeyIdToken] = idToken
|
||||||
logrus.Error(err)
|
|
||||||
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
if oidcToken, err := ParseIdToken(idToken, c.keySet); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
logrus.Infof(`
|
log.Infof(`
|
||||||
ID Token
|
ID Token
|
||||||
========
|
========
|
||||||
|
|
||||||
|
@ -80,36 +83,33 @@ Not valid before: %s
|
||||||
Not valid after: %s
|
Not valid after: %s
|
||||||
|
|
||||||
`,
|
`,
|
||||||
parsedIdToken.Subject(),
|
oidcToken.Subject(),
|
||||||
parsedIdToken.Audience(),
|
oidcToken.Audience(),
|
||||||
parsedIdToken.IssuedAt(),
|
oidcToken.IssuedAt(),
|
||||||
parsedIdToken.Issuer(),
|
oidcToken.Issuer(),
|
||||||
parsedIdToken.NotBefore(),
|
oidcToken.NotBefore(),
|
||||||
parsedIdToken.Expiration(),
|
oidcToken.Expiration(),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.Values[sessionKeyUserId] = parsedIdToken.Subject()
|
|
||||||
|
|
||||||
if roles, ok := parsedIdToken.Get("Groups"); ok {
|
|
||||||
session.Values[sessionKeyRoles] = roles
|
|
||||||
}
|
|
||||||
if username, ok := parsedIdToken.Get("Username"); ok {
|
|
||||||
session.Values[sessionKeyUsername] = username
|
|
||||||
}
|
|
||||||
if email, ok := parsedIdToken.Get("Email"); ok {
|
|
||||||
session.Values[sessionKeyEmail] = email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err = session.Save(request, writer); err != nil {
|
|
||||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
if err = session.Save(r, w); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
if redirectTarget, ok := session.Values[sessionRedirectTarget]; ok {
|
if redirectTarget, ok := session.Values[sessionRedirectTarget]; ok {
|
||||||
writer.Header().Set("Location", redirectTarget.(string))
|
w.Header().Set("Location", redirectTarget.(string))
|
||||||
} else {
|
} else {
|
||||||
writer.Header().Set("Location", "/")
|
w.Header().Set("Location", "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.WriteHeader(http.StatusFound)
|
w.WriteHeader(http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oidcCallbackHandler) RenderErrorTemplate(w http.ResponseWriter, r *http.Request, errorText string, errorDescription string) {
|
||||||
|
if errorDescription != "" {
|
||||||
|
http.Error(w, errorDescription, http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, errorText, http.StatusForbidden)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCallbackHandler(keySet *jwk.Set, oauth2Config *oauth2.Config) *oidcCallbackHandler {
|
func NewCallbackHandler(keySet *jwk.Set, oauth2Config *oauth2.Config) *oidcCallbackHandler {
|
||||||
|
|
|
@ -133,7 +133,7 @@ func main() {
|
||||||
authMiddleware := handlers.Authenticate(oauth2Config, config.MustString("oidc.client-id"))
|
authMiddleware := handlers.Authenticate(oauth2Config, config.MustString("oidc.client-id"))
|
||||||
|
|
||||||
serverAddr := fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port"))
|
serverAddr := fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port"))
|
||||||
indexHandler := handlers.NewIndexHandler(discoveryResponse.EndSessionEndpoint, serverAddr)
|
indexHandler := handlers.NewIndexHandler(discoveryResponse.EndSessionEndpoint, serverAddr, keySet)
|
||||||
callbackHandler := handlers.NewCallbackHandler(keySet, oauth2Config)
|
callbackHandler := handlers.NewCallbackHandler(keySet, oauth2Config)
|
||||||
afterLogoutHandler := handlers.NewAfterLogoutHandler(logger)
|
afterLogoutHandler := handlers.NewAfterLogoutHandler(logger)
|
||||||
|
|
||||||
|
|
|
@ -86,7 +86,10 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("error initializing login handler: %v", err)
|
logger.Fatalf("error initializing login handler: %v", err)
|
||||||
}
|
}
|
||||||
consentHandler := handlers.NewConsentHandler(logger, handlerContext)
|
consentHandler, err := handlers.NewConsentHandler(logger, handlerContext)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("error initializing consent handler: %v", err)
|
||||||
|
}
|
||||||
logoutHandler := handlers.NewLogoutHandler(logger, handlerContext)
|
logoutHandler := handlers.NewLogoutHandler(logger, handlerContext)
|
||||||
logoutSuccessHandler := handlers.NewLogoutSuccessHandler()
|
logoutSuccessHandler := handlers.NewLogoutSuccessHandler()
|
||||||
errorHandler := handlers.NewErrorHandler()
|
errorHandler := handlers.NewErrorHandler()
|
||||||
|
|
|
@ -2,40 +2,166 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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/client/admin"
|
||||||
"github.com/ory/hydra-client-go/models"
|
"github.com/ory/hydra-client-go/models"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"git.cacert.org/oidc_login/idp/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type consentHandler struct {
|
type consentHandler struct {
|
||||||
adminClient *admin.Client
|
adminClient *admin.Client
|
||||||
logger *log.Logger
|
bundle *i18n.Bundle
|
||||||
|
consentTemplate *template.Template
|
||||||
|
logger *log.Logger
|
||||||
|
messageCatalog *services.MessageCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConsentInformation struct {
|
||||||
|
ConsentChecked bool `form:"consent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
consentChallenge := r.URL.Query().Get("consent_challenge")
|
challenge := r.URL.Query().Get("consent_challenge")
|
||||||
consentRequest, err := h.adminClient.AcceptConsentRequest(
|
h.logger.Debugf("received consent challenge %s", challenge)
|
||||||
admin.NewAcceptConsentRequestParams().WithConsentChallenge(consentChallenge).WithBody(
|
accept := r.Header.Get("Accept-Language")
|
||||||
&models.AcceptConsentRequest{
|
localizer := i18n.NewLocalizer(h.bundle, accept)
|
||||||
GrantAccessTokenAudience: nil,
|
|
||||||
GrantScope: []string{"openid", "offline"},
|
// retrieve consent information
|
||||||
HandledAt: models.NullTime(time.Now()),
|
consentData, err := h.adminClient.GetConsentRequest(
|
||||||
Remember: true,
|
admin.NewGetConsentRequestParams().WithConsentChallenge(challenge))
|
||||||
RememberFor: 86400,
|
|
||||||
}).WithTimeout(time.Second * 10))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Panic(err)
|
h.logger.Error("error getting consent information: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
trans := h.messageCatalog.LookupMessage
|
||||||
|
|
||||||
|
// render consent form
|
||||||
|
client := consentData.GetPayload().Client
|
||||||
|
err = h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{
|
||||||
|
"Title": trans("TitleRequestConsent", nil, localizer),
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
"errors": map[string]string{},
|
||||||
|
"client": client,
|
||||||
|
"requestedScope": h.mapRequestedScope(consentData.GetPayload().RequestedScope, localizer),
|
||||||
|
"LabelSubmit": trans("LabelSubmit", nil, localizer),
|
||||||
|
"LabelConsent": trans("LabelConsent", nil, localizer),
|
||||||
|
"IntroMoreInformation": template.HTML(trans("IntroConsentMoreInformation", map[string]interface{}{
|
||||||
|
"client": client.ClientName,
|
||||||
|
"clientLink": client.ClientURI,
|
||||||
|
}, localizer)),
|
||||||
|
"IntroConsentRequested": template.HTML(trans("IntroConsentRequested", map[string]interface{}{
|
||||||
|
"client": client.ClientName,
|
||||||
|
}, 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 {
|
||||||
|
idTokenData := make(map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for _, scope := range consentData.GetPayload().RequestedScope {
|
||||||
|
switch scope {
|
||||||
|
case "email":
|
||||||
|
idTokenData[openid.EmailKey] = "john@theripper.mil"
|
||||||
|
idTokenData[openid.EmailVerifiedKey] = true
|
||||||
|
break
|
||||||
|
case "profile":
|
||||||
|
idTokenData[openid.GivenNameKey] = "John"
|
||||||
|
idTokenData[openid.FamilyNameKey] = "The ripper"
|
||||||
|
idTokenData[openid.MiddleNameKey] = ""
|
||||||
|
idTokenData[openid.NameKey] = "John the Ripper"
|
||||||
|
idTokenData[openid.BirthdateKey] = "1970-01-01"
|
||||||
|
idTokenData[openid.ZoneinfoKey] = "Europe/London"
|
||||||
|
idTokenData[openid.LocaleKey] = "en_UK"
|
||||||
|
idTokenData["https://cacert.localhost/groups"] = []string{"admin", "user"}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData := &models.ConsentRequestSession{
|
||||||
|
AccessToken: nil,
|
||||||
|
IDToken: idTokenData,
|
||||||
|
}
|
||||||
|
consentRequest, err := h.adminClient.AcceptConsentRequest(
|
||||||
|
admin.NewAcceptConsentRequestParams().WithConsentChallenge(challenge).WithBody(
|
||||||
|
&models.AcceptConsentRequest{
|
||||||
|
GrantAccessTokenAudience: nil,
|
||||||
|
GrantScope: consentData.GetPayload().RequestedScope,
|
||||||
|
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)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
w.Header().Add("Location", *consentRequest.GetPayload().RedirectTo)
|
|
||||||
w.WriteHeader(http.StatusFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConsentHandler(logger *log.Logger, ctx context.Context) *consentHandler {
|
type scopeWithLabel struct {
|
||||||
return &consentHandler{
|
Name string
|
||||||
logger: logger,
|
Label string
|
||||||
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
}
|
||||||
}
|
|
||||||
|
func (h *consentHandler) mapRequestedScope(scope models.StringSlicePipeDelimiter, localizer *i18n.Localizer) []*scopeWithLabel {
|
||||||
|
result := make([]*scopeWithLabel, 0)
|
||||||
|
for _, scopeName := range scope {
|
||||||
|
result = append(result, &scopeWithLabel{Name: scopeName, Label: h.messageCatalog.LookupMessage(
|
||||||
|
fmt.Sprintf("Scope-%s-Description", scopeName), nil, localizer)})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConsentHandler(logger *log.Logger, ctx context.Context) (*consentHandler, error) {
|
||||||
|
consentTemplate, err := template.ParseFiles("templates/base.gohtml", "templates/consent.gohtml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &consentHandler{
|
||||||
|
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
||||||
|
bundle: ctx.Value(services.CtxI18nBundle).(*i18n.Bundle),
|
||||||
|
consentTemplate: consentTemplate,
|
||||||
|
logger: logger,
|
||||||
|
messageCatalog: ctx.Value(services.CtxI18nCatalog).(*services.MessageCatalog),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,11 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-openapi/runtime"
|
||||||
"github.com/go-playground/form/v4"
|
"github.com/go-playground/form/v4"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
|
@ -19,13 +19,25 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginHandler struct {
|
type loginHandler struct {
|
||||||
loginTemplate *template.Template
|
|
||||||
bundle *i18n.Bundle
|
|
||||||
messageCatalog map[string]*i18n.Message
|
|
||||||
adminClient *admin.Client
|
adminClient *admin.Client
|
||||||
|
bundle *i18n.Bundle
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
loginTemplate *template.Template
|
||||||
|
messageCatalog *services.MessageCatalog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type acrType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoCredentials acrType = "none"
|
||||||
|
ClientCertificate acrType = "cert"
|
||||||
|
ClientCertificateOTP acrType = "cert+otp"
|
||||||
|
ClientCertificateToken acrType = "cert+token"
|
||||||
|
Password acrType = "password"
|
||||||
|
PasswordOTP acrType = "password+otp"
|
||||||
|
PasswordToken acrType = "password+token"
|
||||||
|
)
|
||||||
|
|
||||||
type LoginInformation struct {
|
type LoginInformation struct {
|
||||||
Email string `form:"email" validate:"required,email"`
|
Email string `form:"email" validate:"required,email"`
|
||||||
Password string `form:"password" validate:"required"`
|
Password string `form:"password" validate:"required"`
|
||||||
|
@ -34,13 +46,12 @@ type LoginInformation struct {
|
||||||
func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
var err error
|
var err error
|
||||||
challenge := r.URL.Query().Get("login_challenge")
|
challenge := r.URL.Query().Get("login_challenge")
|
||||||
h.logger.Debugf("received challenge %s\n", challenge)
|
h.logger.Debugf("received login challenge %s\n", challenge)
|
||||||
validate := validator.New()
|
validate := validator.New()
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
// GET should render login form
|
// render login form
|
||||||
|
|
||||||
err = h.loginTemplate.Lookup("base").Execute(w, map[string]interface{}{
|
err = h.loginTemplate.Lookup("base").Execute(w, map[string]interface{}{
|
||||||
"Title": "Title",
|
"Title": "Title",
|
||||||
csrf.TemplateTag: csrf.TemplateField(r),
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
@ -56,7 +67,6 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
// POST should perform the action
|
|
||||||
var loginInfo LoginInformation
|
var loginInfo LoginInformation
|
||||||
|
|
||||||
// validate input
|
// validate input
|
||||||
|
@ -72,7 +82,7 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
errors := make(map[string]string)
|
errors := make(map[string]string)
|
||||||
for _, err := range err.(validator.ValidationErrors) {
|
for _, err := range err.(validator.ValidationErrors) {
|
||||||
accept := r.Header.Get("Accept-Language")
|
accept := r.Header.Get("Accept-Language")
|
||||||
errors[err.Field()] = h.lookupErrorMessage(err.Tag(), err.Field(), err.Value(), i18n.NewLocalizer(h.bundle, accept))
|
errors[err.Field()] = h.messageCatalog.LookupErrorMessage(err.Tag(), err.Field(), err.Value(), i18n.NewLocalizer(h.bundle, accept))
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.loginTemplate.Lookup("base").Execute(w, map[string]interface{}{
|
err = h.loginTemplate.Lookup("base").Execute(w, map[string]interface{}{
|
||||||
|
@ -98,14 +108,15 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
subject := "a-user-with-an-id"
|
subject := "a-user-with-an-id"
|
||||||
loginRequest, err := h.adminClient.AcceptLoginRequest(
|
loginRequest, err := h.adminClient.AcceptLoginRequest(
|
||||||
admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{
|
admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{
|
||||||
Acr: "no-creds",
|
Acr: string(NoCredentials),
|
||||||
Remember: true,
|
Remember: true,
|
||||||
RememberFor: 0,
|
RememberFor: 0,
|
||||||
Subject: &subject,
|
Subject: &subject,
|
||||||
}).WithTimeout(time.Second * 10))
|
}).WithTimeout(time.Second * 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("error getting logout requests: %v", err)
|
h.logger.Errorf("error getting login request: %#v", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, err.Error(), err.(*runtime.APIError).Code)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
|
w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
|
||||||
w.WriteHeader(http.StatusFound)
|
w.WriteHeader(http.StatusFound)
|
||||||
|
@ -116,45 +127,16 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *loginHandler) lookupErrorMessage(tag string, field string, value interface{}, l *i18n.Localizer) string {
|
|
||||||
var message *i18n.Message
|
|
||||||
message, ok := h.messageCatalog[fmt.Sprintf("%s-%s", field, tag)]
|
|
||||||
if !ok {
|
|
||||||
h.logger.Infof("no specific error message %s-%s", field, tag)
|
|
||||||
message, ok = h.messageCatalog[tag]
|
|
||||||
if !ok {
|
|
||||||
h.logger.Infof("no specific error message %s", tag)
|
|
||||||
message, ok = h.messageCatalog["unknown"]
|
|
||||||
if !ok {
|
|
||||||
h.logger.Error("no default translation found")
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
translation, err := l.Localize(&i18n.LocalizeConfig{
|
|
||||||
DefaultMessage: message,
|
|
||||||
TemplateData: map[string]interface{}{
|
|
||||||
"Value": value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error(err)
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
return translation
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLoginHandler(logger *log.Logger, ctx context.Context) (*loginHandler, error) {
|
func NewLoginHandler(logger *log.Logger, ctx context.Context) (*loginHandler, error) {
|
||||||
loginTemplate, err := template.ParseFiles("templates/base.html", "templates/login.html")
|
loginTemplate, err := template.ParseFiles("templates/base.gohtml", "templates/login.gohtml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &loginHandler{
|
return &loginHandler{
|
||||||
|
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
||||||
|
bundle: ctx.Value(services.CtxI18nBundle).(*i18n.Bundle),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
loginTemplate: loginTemplate,
|
loginTemplate: loginTemplate,
|
||||||
bundle: ctx.Value(services.CtxI18nBundle).(*i18n.Bundle),
|
messageCatalog: ctx.Value(services.CtxI18nCatalog).(*services.MessageCatalog),
|
||||||
messageCatalog: ctx.Value(services.CtxI18nCatalog).(map[string]*i18n.Message),
|
|
||||||
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
@ -16,6 +17,56 @@ const (
|
||||||
CtxI18nCatalog
|
CtxI18nCatalog
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MessageCatalog struct {
|
||||||
|
messages map[string]*i18n.Message
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageCatalog) LookupErrorMessage(tag string, field string, value interface{}, localizer *i18n.Localizer) string {
|
||||||
|
var message *i18n.Message
|
||||||
|
message, ok := m.messages[fmt.Sprintf("%s-%s", field, tag)]
|
||||||
|
if !ok {
|
||||||
|
m.logger.Infof("no specific error message %s-%s", field, tag)
|
||||||
|
message, ok = m.messages[tag]
|
||||||
|
if !ok {
|
||||||
|
m.logger.Infof("no specific error message %s", tag)
|
||||||
|
message, ok = m.messages["unknown"]
|
||||||
|
if !ok {
|
||||||
|
m.logger.Error("no default translation found")
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
translation, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
|
DefaultMessage: message,
|
||||||
|
TemplateData: map[string]interface{}{
|
||||||
|
"Value": value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error(err)
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageCatalog) LookupMessage(id string, templateData map[string]interface{}, localizer *i18n.Localizer) string {
|
||||||
|
if message, ok := m.messages[id]; ok {
|
||||||
|
translation, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
|
DefaultMessage: message,
|
||||||
|
TemplateData: templateData,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error(err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return translation
|
||||||
|
} else {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func InitI18n(ctx context.Context, logger *log.Logger) context.Context {
|
func InitI18n(ctx context.Context, logger *log.Logger) context.Context {
|
||||||
bundle := i18n.NewBundle(language.English)
|
bundle := i18n.NewBundle(language.English)
|
||||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
@ -23,33 +74,69 @@ func InitI18n(ctx context.Context, logger *log.Logger) context.Context {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnln("message bundle de.toml not found")
|
logger.Warnln("message bundle de.toml not found")
|
||||||
}
|
}
|
||||||
catalog := initMessageCatalog()
|
catalog := initMessageCatalog(logger)
|
||||||
ctx = context.WithValue(ctx, CtxI18nBundle, bundle)
|
ctx = context.WithValue(ctx, CtxI18nBundle, bundle)
|
||||||
ctx = context.WithValue(ctx, CtxI18nCatalog, catalog)
|
ctx = context.WithValue(ctx, CtxI18nCatalog, catalog)
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func initMessageCatalog() map[string]*i18n.Message {
|
func initMessageCatalog(logger *log.Logger) *MessageCatalog {
|
||||||
messageCatalog := make(map[string]*i18n.Message)
|
messages := make(map[string]*i18n.Message)
|
||||||
messageCatalog["unknown"] = &i18n.Message{
|
messages["unknown"] = &i18n.Message{
|
||||||
ID: "ErrorUnknown",
|
ID: "ErrorUnknown",
|
||||||
Other: "Unknown error",
|
Other: "Unknown error",
|
||||||
}
|
}
|
||||||
messageCatalog["email"] = &i18n.Message{
|
messages["email"] = &i18n.Message{
|
||||||
ID: "ErrorEmail",
|
ID: "ErrorEmail",
|
||||||
Other: "Please enter a valid email address.",
|
Other: "Please enter a valid email address.",
|
||||||
}
|
}
|
||||||
messageCatalog["Email-required"] = &i18n.Message{
|
messages["Email-required"] = &i18n.Message{
|
||||||
ID: "ErrorEmailRequired",
|
ID: "ErrorEmailRequired",
|
||||||
Other: "Please enter an email address.",
|
Other: "Please enter an email address.",
|
||||||
}
|
}
|
||||||
messageCatalog["required"] = &i18n.Message{
|
messages["required"] = &i18n.Message{
|
||||||
ID: "ErrorRequired",
|
ID: "ErrorRequired",
|
||||||
Other: "Please enter a value",
|
Other: "Please enter a value",
|
||||||
}
|
}
|
||||||
messageCatalog["Password-required"] = &i18n.Message{
|
messages["Password-required"] = &i18n.Message{
|
||||||
ID: "ErrorPasswordRequired",
|
ID: "ErrorPasswordRequired",
|
||||||
Other: "Please enter a password.",
|
Other: "Please enter a password.",
|
||||||
}
|
}
|
||||||
return messageCatalog
|
messages["TitleRequestConsent"] = &i18n.Message{
|
||||||
|
ID: "TitleRequestConsent",
|
||||||
|
Other: "Application requests your consent",
|
||||||
|
}
|
||||||
|
messages["LabelSubmit"] = &i18n.Message{
|
||||||
|
ID: "LabelSubmit",
|
||||||
|
Other: "Submit",
|
||||||
|
}
|
||||||
|
messages["LabelConsent"] = &i18n.Message{
|
||||||
|
ID: "LabelConsent",
|
||||||
|
Other: "I hereby agree that the application may get the requested permissions.",
|
||||||
|
}
|
||||||
|
messages["IntroConsentRequested"] = &i18n.Message{
|
||||||
|
ID: "IntroConsentRequested",
|
||||||
|
Other: "The <strong>{{ .client }}</strong> application wants your consent for the requested set of permissions.",
|
||||||
|
}
|
||||||
|
messages["IntroConsentMoreInformation"] = &i18n.Message{
|
||||||
|
ID: "IntroConsentMoreInformation",
|
||||||
|
Other: "You can find more information about <strong>{{ .client }}</strong> at <a href=\"{{ .clientLink }}\">its description page</a>.",
|
||||||
|
}
|
||||||
|
messages["Scope-openid-Description"] = &i18n.Message{
|
||||||
|
ID: "Scope-openid-Description",
|
||||||
|
Other: "Request information about your identity.",
|
||||||
|
}
|
||||||
|
messages["Scope-offline_access-Description"] = &i18n.Message{
|
||||||
|
ID: "Scope-offline_access-Description",
|
||||||
|
Other: "Keep access to your information until you revoke the permission.",
|
||||||
|
}
|
||||||
|
messages["Scope-profile-Description"] = &i18n.Message{
|
||||||
|
ID: "Scope-profile-Description",
|
||||||
|
Other: "Access your user profile information including your name, birth date and locale.",
|
||||||
|
}
|
||||||
|
messages["Scope-email-Description"] = &i18n.Message{
|
||||||
|
ID: "Scope-email-Description",
|
||||||
|
Other: "Access your primary email address.",
|
||||||
|
}
|
||||||
|
return &MessageCatalog{messages: messages, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
10
templates/base.gohtml
Normal file
10
templates/base.gohtml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{{ define "base" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><title>{{ .Title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
{{ template "content" . }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
|
@ -1,10 +0,0 @@
|
||||||
{{ define "base" }}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><title>{{ .Title }}</title></head>
|
|
||||||
<body>
|
|
||||||
<h1>{{ .Title }}</h1>
|
|
||||||
{{ template "content" . }}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{ end }}
|
|
24
templates/consent.gohtml
Normal file
24
templates/consent.gohtml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<p>{{ .IntroConsentRequested }}</p>
|
||||||
|
{{ if .client.LogoURI }}
|
||||||
|
<p>
|
||||||
|
<img src="{{ .client.LogoURI }}" alt="{{ .client.ClientName }}"/>
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
<p>{{ .IntroMoreInformation }}</p>
|
||||||
|
<form method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<ul>
|
||||||
|
{{ range .requestedScope }}
|
||||||
|
<li>{{ .Label }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input type="checkbox" name="consent" id="consent" value="true"/> <label
|
||||||
|
for="consent">{{ .LabelConsent }}</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit">{{ .LabelSubmit }}</button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
Reference in a new issue