hydra-werther/internal/identp/identp.go

336 lines
14 KiB
Go

/*
Copyright (c) JSC iCore.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
// between ORY Hydra and Werther Identity Provider.
package identp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/i-core/rlog"
"github.com/i-core/werther/internal/hydra"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"go.uber.org/zap"
)
const loginTmplName = "login.tmpl"
// Config is a Hydra configuration.
type Config struct {
HydraURL string `envconfig:"hydra_url" required:"true" desc:"an admin URL of ORY Hydra Server"`
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a user session's TTL"`
ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,https%3A%2F%2Fgithub.com%2Fi-core%2Fwerther%2Fclaims%2Froles:roles" desc:"a mapping of OpenID Connect claims to scopes (all claims are URL encoded)"`
FakeTLSTermination bool `envconfig:"fake_tls_termination" default:"false" desc:"Fake tls termination by adding \"X-Forwarded-Proto: https\" to http headers "`
}
// UserManager is an interface that is used for authentication and providing user's claims.
type UserManager interface {
authenticator
oidcClaimsFinder
}
// authenticator is an interface that is used for a user authentication.
//
// Authenticate returns false if the username or password is invalid.
type authenticator interface {
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
}
// oidcClaimsFinder is an interface that is used for searching OpenID Connect claims for the given username.
type oidcClaimsFinder interface {
FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error)
}
// TemplateRenderer renders a template with data and writes it to a http.ResponseWriter.
type TemplateRenderer interface {
RenderTemplate(w http.ResponseWriter, r *http.Request, name string, data interface{}) error
}
// LoginTmplData is a data that is needed for rendering the login page.
type LoginTmplData struct {
CSRFToken string
Challenge string
LoginURL string
IsInvalidCredentials bool
IsInternalError bool
}
// Handler provides HTTP handlers that implement [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
// between ORY Hydra and Werther Identity Provider.
type Handler struct {
Config
um UserManager
tr TemplateRenderer
}
// NewHandler creates a new Handler.
//
// The template's renderer must be able to render a template with name "login.tmpl".
// The template is a template of the login page. It receives struct LoginTmplData as template's data.
func NewHandler(cnf Config, um UserManager, tr TemplateRenderer) *Handler {
return &Handler{Config: cnf, um: um, tr: tr}
}
// AddRoutes registers all required routes for Login & Consent Provider.
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
sessionTTL := int(h.SessionTTL.Seconds())
apply(http.MethodGet, "/login", newLoginStartHandler(hydra.NewLoginReqDoer(h.HydraURL, h.FakeTLSTermination, 0), h.tr))
apply(http.MethodPost, "/login", newLoginEndHandler(hydra.NewLoginReqDoer(h.HydraURL, h.FakeTLSTermination, sessionTTL), h.um, h.tr))
apply(http.MethodGet, "/consent", newConsentHandler(hydra.NewConsentReqDoer(h.HydraURL, h.FakeTLSTermination, sessionTTL), h.um, h.ClaimScopes))
apply(http.MethodGet, "/logout", newLogoutHandler(hydra.NewLogoutReqDoer(h.HydraURL, h.FakeTLSTermination)))
}
// oa2LoginReqAcceptor is an interface that is used for accepting an OAuth2 login request.
type oa2LoginReqAcceptor interface {
AcceptLoginRequest(challenge string, remember bool, subject string) (string, error)
}
// oa2LoginReqProcessor is an interface that is used for creating and accepting an OAuth2 login request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2LoginReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
oa2LoginReqAcceptor
}
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown login challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown login challenge", http.StatusBadRequest)
return
case hydra.ErrChallengeExpired:
log.Debugw("Login challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Login challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to initiate an OAuth2 login request", zap.Error(err), "challenge", challenge)
errMsg := fmt.Sprintf("%s - %s - %s", http.StatusText(http.StatusInternalServerError), err, errors.Cause(err))
http.Error(w, errMsg, http.StatusInternalServerError)
return
}
log.Infow("A login request is initiated", "challenge", challenge, "username", ri.Subject)
if ri.Skip {
redirectURI, err := rproc.AcceptLoginRequest(challenge, false, ri.Subject)
if err != nil {
log.Infow("Failed to accept an OAuth login request", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirectURI, http.StatusFound)
return
}
data := LoginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
}
if err := tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := rlog.FromContext(r.Context()).Sugar()
r.ParseForm()
challenge := r.Form.Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
data := LoginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
}
username, password := r.Form.Get("username"), r.Form.Get("password")
switch ok, err := auther.Authenticate(r.Context(), username, password); {
case err != nil:
data.IsInternalError = true
log.Infow("Failed to authenticate a login request via the OAuth2 provider",
zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
case !ok:
data.IsInvalidCredentials = true
log.Debugw("Invalid credentials", zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
log.Infow("A username is authenticated", "challenge", challenge, "username", username)
remember := r.Form.Get("remember") != ""
redirectTo, err := ra.AcceptLoginRequest(challenge, remember, username)
if err != nil {
data.IsInternalError = true
log.Infow("Failed to accept a login request via the OAuth2 provider", zap.Error(err))
if err := tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2ConsentReqAcceptor is an interface that is used for creating and accepting an OAuth2 consent request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2ConsentReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error)
}
func newConsentHandler(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder, claimScopes map[string]string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
log.Debug("No consent challenge that is needed by the OAuth2 provider")
http.Error(w, "No consent challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown consent challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown consent challenge", http.StatusBadRequest)
return
case hydra.ErrChallengeExpired:
log.Debugw("Consent challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Consent challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 consent request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A consent request is initiated", "challenge", challenge, "username", ri.Subject)
claims, err := cfinder.FindOIDCClaims(r.Context(), ri.Subject)
if err != nil {
log.Infow("Failed to find user's OIDC claims", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Found user's OIDC claims", "claims", claims)
// Remove claims that are not in the requested scopes.
for claim := range claims {
var found bool
// We need to escape a claim due to ClaimScopes' keys contain URL encoded claims.
// It is because of config option's format compatibility.
if scope, ok := claimScopes[url.QueryEscape(claim)]; ok {
for _, rscope := range ri.RequestedScopes {
if rscope == scope {
found = true
break
}
}
}
if !found {
delete(claims, claim)
log.Debugw("Deleted the OIDC claim because it's not in requested scopes", "claim", claim)
}
}
redirectTo, err := rproc.AcceptConsentRequest(challenge, !ri.Skip, ri.RequestedScopes, claims)
if err != nil {
log.Infow("Failed to accept a consent request to the OAuth2 provider", zap.Error(err), "scopes", ri.RequestedScopes, "claims", claims)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Accepted the consent request to the OAuth2 provider", "scopes", ri.RequestedScopes, "claims", claims)
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2LogoutReqProcessor is an interface that is used for creating and accepting an OAuth2 logout request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
type oa2LogoutReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
AcceptLogoutRequest(challenge string) (string, error)
}
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("logout_challenge")
if challenge == "" {
log.Debug("No logout challenge that is needed by the OAuth2 provider")
http.Error(w, "No logout challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown logout challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown logout challenge", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 logout request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A logout request is initiated", "challenge", challenge, "username", ri.Subject)
redirectTo, err := rproc.AcceptLogoutRequest(challenge)
if err != nil {
log.Infow("Failed to accept the logout request to the OAuth2 provider", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Accepted the logout request to the OAuth2 provider")
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}