2019-05-15 14:03:05 +02:00
/ *
2019-05-24 15:13:15 +02:00
Copyright ( c ) JSC iCore .
2019-05-15 14:03:05 +02:00
2019-05-24 15:13:15 +02:00
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree .
2019-05-15 14:03:05 +02:00
* /
// 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"
"net/http"
"net/url"
"strings"
"time"
2019-05-24 15:13:15 +02:00
"github.com/i-core/rlog"
"github.com/i-core/werther/internal/hydra"
2019-05-15 14:03:05 +02:00
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"go.uber.org/zap"
)
const loginTmplName = "login.tmpl"
// Config is a Hydra configuration.
type Config struct {
2019-05-24 15:13:15 +02:00
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" `
2019-08-06 13:19:02 +02:00
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)" `
2019-05-15 14:03:05 +02:00
}
// 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 , 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 , 0 ) , h . tr ) )
apply ( http . MethodPost , "/login" , newLoginEndHandler ( hydra . NewLoginReqDoer ( h . HydraURL , sessionTTL ) , h . um , h . tr ) )
apply ( http . MethodGet , "/consent" , newConsentHandler ( hydra . NewConsentReqDoer ( h . HydraURL , sessionTTL ) , h . um , h . ClaimScopes ) )
apply ( http . MethodGet , "/logout" , newLogoutHandler ( hydra . NewLogoutReqDoer ( h . HydraURL ) ) )
}
// 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 ) {
2019-05-24 15:13:15 +02:00
log := rlog . FromContext ( r . Context ( ) ) . Sugar ( )
2019-05-15 14:03:05 +02:00
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 )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , 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 , 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 ) {
2019-05-24 15:13:15 +02:00
log := rlog . FromContext ( r . Context ( ) ) . Sugar ( )
2019-05-15 14:03:05 +02:00
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 : 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 , 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 , 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 , 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 ) {
2019-05-24 15:13:15 +02:00
log := rlog . FromContext ( r . Context ( ) ) . Sugar ( )
2019-05-15 14:03:05 +02:00
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 ) {
2019-05-24 15:13:15 +02:00
log := rlog . FromContext ( r . Context ( ) ) . Sugar ( )
2019-05-15 14:03:05 +02:00
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 )
}
}