From b2cbcf3913c1a322ab85af9567938fc0356acba8 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Tue, 26 Jul 2022 15:43:39 +1000 Subject: [PATCH] fix(handlers): consent session prevents standard flow (#3668) This fixes an issue where consent sessions prevent the standard workflow. --- internal/authentication/const.go | 4 +- internal/authorization/access_control_rule.go | 2 +- internal/authorization/authorizer.go | 37 +-- internal/authorization/authorizer_test.go | 29 +-- internal/authorization/const.go | 6 +- internal/authorization/util.go | 8 +- internal/authorization/util_test.go | 21 +- internal/commands/acl.go | 6 +- internal/handlers/const.go | 9 +- internal/handlers/handler_firstfactor.go | 5 +- .../handlers/handler_oidc_authorization.go | 16 +- .../handler_oidc_authorization_consent.go | 221 +++++++++++------- internal/handlers/handler_oidc_consent.go | 132 +++++++---- internal/handlers/handler_sign_duo.go | 28 +-- internal/handlers/handler_sign_totp.go | 12 +- internal/handlers/handler_sign_webauthn.go | 10 +- internal/handlers/response.go | 174 ++++++-------- internal/handlers/types.go | 4 + internal/model/oidc.go | 14 +- internal/model/types.go | 28 --- internal/oidc/client.go | 2 +- internal/oidc/store.go | 2 +- internal/oidc/types.go | 11 +- internal/oidc/types_test.go | 2 +- internal/session/types.go | 4 - internal/storage/sql_provider.go | 6 +- web/src/hooks/ConsentID.ts | 8 + web/src/hooks/RedirectionURL.ts | 2 + web/src/hooks/Workflow.ts | 8 + web/src/services/Consent.ts | 23 +- web/src/services/FirstFactor.ts | 6 + web/src/services/OneTimePassword.ts | 8 +- web/src/services/PushNotification.ts | 10 +- .../LoginPortal/ConsentView/ConsentView.tsx | 71 +++--- .../FirstFactor/FirstFactorForm.tsx | 4 +- web/src/views/LoginPortal/LoginPortal.tsx | 30 ++- .../SecondFactor/OneTimePasswordMethod.tsx | 5 +- .../SecondFactor/PushNotificationMethod.tsx | 5 +- 38 files changed, 544 insertions(+), 429 deletions(-) create mode 100644 web/src/hooks/ConsentID.ts create mode 100644 web/src/hooks/Workflow.ts diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 8ee6568ad..61a5d246a 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -11,9 +11,9 @@ const ( // NotAuthenticated if the user is not authenticated yet. NotAuthenticated Level = iota // OneFactor if the user has passed first factor only. - OneFactor Level = iota + OneFactor // TwoFactor if the user has passed two factors. - TwoFactor Level = iota + TwoFactor ) const ( diff --git a/internal/authorization/access_control_rule.go b/internal/authorization/access_control_rule.go index 3bcda3cc4..077e8fe8d 100644 --- a/internal/authorization/access_control_rule.go +++ b/internal/authorization/access_control_rule.go @@ -27,7 +27,7 @@ func NewAccessControlRule(pos int, rule schema.ACLRule, networksMap map[string][ Methods: schemaMethodsToACL(rule.Methods), Networks: schemaNetworksToACL(rule.Networks, networksMap, networksCacheMap), Subjects: schemaSubjectsToACL(rule.Subjects), - Policy: PolicyToLevel(rule.Policy), + Policy: StringToLevel(rule.Policy), } } diff --git a/internal/authorization/authorizer.go b/internal/authorization/authorizer.go index fc054d2ae..3b6e75f74 100644 --- a/internal/authorization/authorizer.go +++ b/internal/authorization/authorizer.go @@ -9,39 +9,48 @@ import ( type Authorizer struct { defaultPolicy Level rules []*AccessControlRule + mfa bool configuration *schema.Configuration } // NewAuthorizer create an instance of authorizer with a given access control configuration. -func NewAuthorizer(configuration *schema.Configuration) *Authorizer { - return &Authorizer{ - defaultPolicy: PolicyToLevel(configuration.AccessControl.DefaultPolicy), +func NewAuthorizer(configuration *schema.Configuration) (authorizer *Authorizer) { + authorizer = &Authorizer{ + defaultPolicy: StringToLevel(configuration.AccessControl.DefaultPolicy), rules: NewAccessControlRules(configuration.AccessControl), configuration: configuration, } -} -// IsSecondFactorEnabled return true if at least one policy is set to second factor. -func (p Authorizer) IsSecondFactorEnabled() bool { - if p.defaultPolicy == TwoFactor { - return true + if authorizer.defaultPolicy == TwoFactor { + authorizer.mfa = true + + return authorizer } - for _, rule := range p.rules { + for _, rule := range authorizer.rules { if rule.Policy == TwoFactor { - return true + authorizer.mfa = true + + return authorizer } } - if p.configuration.IdentityProviders.OIDC != nil { - for _, client := range p.configuration.IdentityProviders.OIDC.Clients { + if authorizer.configuration.IdentityProviders.OIDC != nil { + for _, client := range authorizer.configuration.IdentityProviders.OIDC.Clients { if client.Policy == twoFactor { - return true + authorizer.mfa = true + + return authorizer } } } - return false + return authorizer +} + +// IsSecondFactorEnabled return true if at least one policy is set to second factor. +func (p Authorizer) IsSecondFactorEnabled() bool { + return p.mfa } // GetRequiredLevel retrieve the required level of authorization to access the object. diff --git a/internal/authorization/authorizer_test.go b/internal/authorization/authorizer_test.go index 99c7b64dc..c4e71ceb8 100644 --- a/internal/authorization/authorizer_test.go +++ b/internal/authorization/authorizer_test.go @@ -865,12 +865,12 @@ func (s *AuthorizerSuite) TestShouldMatchResourceWithSubjectRules() { } func (s *AuthorizerSuite) TestPolicyToLevel() { - s.Assert().Equal(Bypass, PolicyToLevel(bypass)) - s.Assert().Equal(OneFactor, PolicyToLevel(oneFactor)) - s.Assert().Equal(TwoFactor, PolicyToLevel(twoFactor)) - s.Assert().Equal(Denied, PolicyToLevel(deny)) + s.Assert().Equal(Bypass, StringToLevel(bypass)) + s.Assert().Equal(OneFactor, StringToLevel(oneFactor)) + s.Assert().Equal(TwoFactor, StringToLevel(twoFactor)) + s.Assert().Equal(Denied, StringToLevel(deny)) - s.Assert().Equal(Denied, PolicyToLevel("whatever")) + s.Assert().Equal(Denied, StringToLevel("whatever")) } func TestRunSuite(t *testing.T) { @@ -929,7 +929,8 @@ func TestAuthorizerIsSecondFactorEnabledRuleWithNoOIDC(t *testing.T) { authorizer := NewAuthorizer(config) assert.False(t, authorizer.IsSecondFactorEnabled()) - authorizer.rules[0].Policy = TwoFactor + config.AccessControl.Rules[0].Policy = twoFactor + authorizer = NewAuthorizer(config) assert.True(t, authorizer.IsSecondFactorEnabled()) } @@ -958,22 +959,24 @@ func TestAuthorizerIsSecondFactorEnabledRuleWithOIDC(t *testing.T) { authorizer := NewAuthorizer(config) assert.False(t, authorizer.IsSecondFactorEnabled()) - authorizer.rules[0].Policy = TwoFactor + config.AccessControl.Rules[0].Policy = twoFactor + authorizer = NewAuthorizer(config) assert.True(t, authorizer.IsSecondFactorEnabled()) - authorizer.rules[0].Policy = OneFactor + config.AccessControl.Rules[0].Policy = oneFactor + authorizer = NewAuthorizer(config) assert.False(t, authorizer.IsSecondFactorEnabled()) config.IdentityProviders.OIDC.Clients[0].Policy = twoFactor - + authorizer = NewAuthorizer(config) assert.True(t, authorizer.IsSecondFactorEnabled()) - authorizer.rules[0].Policy = OneFactor + config.AccessControl.Rules[0].Policy = oneFactor config.IdentityProviders.OIDC.Clients[0].Policy = oneFactor - + authorizer = NewAuthorizer(config) assert.False(t, authorizer.IsSecondFactorEnabled()) - authorizer.defaultPolicy = TwoFactor - + config.AccessControl.DefaultPolicy = twoFactor + authorizer = NewAuthorizer(config) assert.True(t, authorizer.IsSecondFactorEnabled()) } diff --git a/internal/authorization/const.go b/internal/authorization/const.go index b2e5ca5ac..c86440207 100644 --- a/internal/authorization/const.go +++ b/internal/authorization/const.go @@ -7,11 +7,11 @@ const ( // Bypass bypass level. Bypass Level = iota // OneFactor one factor level. - OneFactor Level = iota + OneFactor // TwoFactor two factor level. - TwoFactor Level = iota + TwoFactor // Denied denied level. - Denied Level = iota + Denied ) const ( diff --git a/internal/authorization/util.go b/internal/authorization/util.go index 793c81d2e..877e46ca5 100644 --- a/internal/authorization/util.go +++ b/internal/authorization/util.go @@ -9,8 +9,8 @@ import ( "github.com/authelia/authelia/v4/internal/configuration/schema" ) -// PolicyToLevel converts a string policy to int authorization level. -func PolicyToLevel(policy string) Level { +// StringToLevel converts a string policy to int authorization level. +func StringToLevel(policy string) Level { switch policy { case bypass: return Bypass @@ -25,8 +25,8 @@ func PolicyToLevel(policy string) Level { return Denied } -// LevelToPolicy converts a int authorization level to string policy. -func LevelToPolicy(level Level) (policy string) { +// LevelToString converts a int authorization level to string policy. +func LevelToString(level Level) (policy string) { switch level { case Bypass: return bypass diff --git a/internal/authorization/util_test.go b/internal/authorization/util_test.go index 7106beab6..39061bc3b 100644 --- a/internal/authorization/util_test.go +++ b/internal/authorization/util_test.go @@ -11,6 +11,25 @@ import ( "github.com/authelia/authelia/v4/internal/configuration/schema" ) +func TestLevelToString(t *testing.T) { + testCases := []struct { + have Level + expected string + }{ + {Bypass, "bypass"}, + {OneFactor, "one_factor"}, + {TwoFactor, "two_factor"}, + {Denied, "deny"}, + {99, "deny"}, + } + + for _, tc := range testCases { + t.Run("Expected_"+tc.expected, func(t *testing.T) { + assert.Equal(t, tc.expected, LevelToString(tc.have)) + }) + } +} + func TestShouldNotParseInvalidSubjects(t *testing.T) { subjectsSchema := [][]string{{"groups:z"}, {"group:z", "users:b"}} subjectsACL := schemaSubjectsToACL(subjectsSchema) @@ -184,7 +203,7 @@ func TestShouldParseACLNetworks(t *testing.T) { assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1/128"]) } -func TestShouldReturnCorrectValidationLevel(t *testing.T) { +func TestIsAuthLevelSufficient(t *testing.T) { assert.False(t, IsAuthLevelSufficient(authentication.NotAuthenticated, Denied)) assert.False(t, IsAuthLevelSufficient(authentication.OneFactor, Denied)) assert.False(t, IsAuthLevelSufficient(authentication.TwoFactor, Denied)) diff --git a/internal/commands/acl.go b/internal/commands/acl.go index 528e12fb3..182c8c026 100644 --- a/internal/commands/acl.go +++ b/internal/commands/acl.go @@ -167,11 +167,11 @@ func accessControlCheckWriteOutput(object authorization.Object, subject authoriz switch { case appliedPos != 0 && (potentialPos == 0 || (potentialPos > appliedPos)): - fmt.Printf("\nThe policy '%s' from rule #%d will be applied to this request.\n\n", authorization.LevelToPolicy(applied.Rule.Policy), appliedPos) + fmt.Printf("\nThe policy '%s' from rule #%d will be applied to this request.\n\n", authorization.LevelToString(applied.Rule.Policy), appliedPos) case potentialPos != 0 && appliedPos != 0: - fmt.Printf("\nThe policy '%s' from rule #%d will potentially be applied to this request. If not policy '%s' from rule #%d will be.\n\n", authorization.LevelToPolicy(potential.Rule.Policy), potentialPos, authorization.LevelToPolicy(applied.Rule.Policy), appliedPos) + fmt.Printf("\nThe policy '%s' from rule #%d will potentially be applied to this request. If not policy '%s' from rule #%d will be.\n\n", authorization.LevelToString(potential.Rule.Policy), potentialPos, authorization.LevelToString(applied.Rule.Policy), appliedPos) case potentialPos != 0: - fmt.Printf("\nThe policy '%s' from rule #%d will potentially be applied to this request. Otherwise the policy '%s' from the default policy will be.\n\n", authorization.LevelToPolicy(potential.Rule.Policy), potentialPos, defaultPolicy) + fmt.Printf("\nThe policy '%s' from rule #%d will potentially be applied to this request. Otherwise the policy '%s' from the default policy will be.\n\n", authorization.LevelToString(potential.Rule.Policy), potentialPos, defaultPolicy) default: fmt.Printf("\nThe policy '%s' from the default policy will be applied to this request as no rules matched the request.\n\n", defaultPolicy) } diff --git a/internal/handlers/const.go b/internal/handlers/const.go index a43fa75b9..f2514a080 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -47,6 +47,10 @@ const ( messagePasswordWeak = "Your supplied password does not meet the password policy requirements" ) +const ( + workflowOpenIDConnect = "openid_connect" +) + const ( logFmtErrParseRequestBody = "Failed to parse %s request body: %+v" logFmtErrWriteResponseBody = "Failed to write %s response body for user '%s': %+v" @@ -72,11 +76,6 @@ const ( auth = "auth" ) -const ( - accept = "accept" - reject = "reject" -) - const authPrefix = "Basic " const ldapPasswordComplexityCode = "0000052D." diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index 5628ea5a9..ecf143c0b 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -73,7 +73,6 @@ func FirstFactorPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.Re userSession := ctx.GetSession() newSession := session.NewDefaultUserSession() - newSession.ConsentChallengeID = userSession.ConsentChallengeID // Reset all values from previous session except OIDC workflow before regenerating the cookie. if err = ctx.SaveSession(newSession); err != nil { @@ -135,8 +134,8 @@ func FirstFactorPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.Re successful = true - if userSession.ConsentChallengeID != nil { - handleOIDCWorkflowResponse(ctx) + if bodyJSON.Workflow == workflowOpenIDConnect { + handleOIDCWorkflowResponse(ctx, bodyJSON.TargetURL) } else { Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups) } diff --git a/internal/handlers/handler_oidc_authorization.go b/internal/handlers/handler_oidc_authorization.go index 9e7470ce9..8d16146a5 100644 --- a/internal/handlers/handler_oidc_authorization.go +++ b/internal/handlers/handler_oidc_authorization.go @@ -61,26 +61,12 @@ func OpenIDConnectAuthorizationGET(ctx *middlewares.AutheliaCtx, rw http.Respons userSession := ctx.GetSession() - var subject model.NullUUID - - if userSession.Username != "" { - if subject.UUID, err = ctx.Providers.OpenIDConnect.Store.GetSubject(ctx, client.GetSectorIdentifier(), userSession.Username); err != nil { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred retrieving subject for user '%s': %+v", requester.GetID(), client.GetID(), userSession.Username, err) - - ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not retrieve the subject.")) - - return - } - - subject.Valid = true - } - var ( consent *model.OAuth2ConsentSession handled bool ) - if consent, handled = handleOIDCAuthorizationConsent(ctx, issuer, client, userSession, subject, rw, r, requester); handled { + if consent, handled = handleOIDCAuthorizationConsent(ctx, issuer, client, userSession, rw, r, requester); handled { return } diff --git a/internal/handlers/handler_oidc_authorization_consent.go b/internal/handlers/handler_oidc_authorization_consent.go index a4419d293..1a4b40bd6 100644 --- a/internal/handlers/handler_oidc_authorization_consent.go +++ b/internal/handlers/handler_oidc_authorization_consent.go @@ -3,6 +3,8 @@ package handlers import ( "fmt" "net/http" + "net/url" + "path" "strings" "github.com/google/uuid" @@ -19,85 +21,98 @@ import ( ) func handleOIDCAuthorizationConsent(ctx *middlewares.AutheliaCtx, rootURI string, client *oidc.Client, - userSession session.UserSession, subject model.NullUUID, + userSession session.UserSession, rw http.ResponseWriter, r *http.Request, requester fosite.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { - if userSession.ConsentChallengeID != nil { - ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' proceeding to lookup consent by challenge id '%s'", requester.GetID(), client.GetID(), userSession.ConsentChallengeID) + var ( + issuer *url.URL + subject uuid.UUID + err error + ) - return handleOIDCAuthorizationConsentWithChallengeID(ctx, rootURI, client, userSession, rw, r, requester) + if issuer, err = url.Parse(rootURI); err != nil { + ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not safely determine the issuer.")) + + return nil, true } - if !subject.Valid { - return handleOIDCAuthorizationConsentGenerate(ctx, rootURI, client, userSession, subject, rw, r, requester) + if !strings.HasSuffix(issuer.Path, "/") { + issuer.Path += "/" } - return handleOIDCAuthorizationConsentOrGenerate(ctx, rootURI, client, userSession, subject, rw, r, requester) + // This prevents the consent request from being generated until the authentication level is sufficient. + if !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) || userSession.Username == "" { + redirectURL := getOIDCAuthorizationRedirectURL(issuer, requester) + + ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' is being redirected due to insufficient authentication", requester.GetID(), client.GetID()) + + http.Redirect(rw, r, redirectURL.String(), http.StatusFound) + + return nil, true + } + + if subject, err = ctx.Providers.OpenIDConnect.Store.GetSubject(ctx, client.GetSectorIdentifier(), userSession.Username); err != nil { + ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred retrieving subject identifier for user '%s' and sector identifier '%s': %+v", requester.GetID(), client.GetID(), userSession.Username, client.GetSectorIdentifier(), err) + + ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not retrieve the subject.")) + + return nil, true + } + + var consentIDBytes []byte + + if consentIDBytes = ctx.QueryArgs().Peek("consent_id"); len(consentIDBytes) != 0 { + var consentID uuid.UUID + + if consentID, err = uuid.Parse(string(consentIDBytes)); err != nil { + ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Consent Session ID was Malformed.")) + + return nil, true + } + + ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' proceeding to lookup consent by challenge id '%s'", requester.GetID(), client.GetID(), consentID) + + return handleOIDCAuthorizationConsentWithChallengeID(ctx, issuer, client, userSession, subject, consentID, rw, r, requester) + } + + return handleOIDCAuthorizationConsentGenerate(ctx, issuer, client, userSession, subject, rw, r, requester) } -func handleOIDCAuthorizationConsentWithChallengeID(ctx *middlewares.AutheliaCtx, rootURI string, client *oidc.Client, - userSession session.UserSession, +func handleOIDCAuthorizationConsentWithChallengeID(ctx *middlewares.AutheliaCtx, issuer *url.URL, client *oidc.Client, + userSession session.UserSession, subject, challengeID uuid.UUID, rw http.ResponseWriter, r *http.Request, requester fosite.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { var ( err error ) - if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, *userSession.ConsentChallengeID); err != nil { + if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, challengeID); err != nil { ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred during consent session lookup: %+v", requester.GetID(), requester.GetClient().GetID(), err) ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Failed to lookup consent session.")) - userSession.ConsentChallengeID = nil + return nil, true + } - if err = ctx.SaveSession(userSession); err != nil { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred unlinking consent session challenge id: %+v", requester.GetID(), requester.GetClient().GetID(), err) - } + if err = verifyOIDCUserAuthorizedForConsent(ctx, client, userSession, consent, subject); err != nil { + ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not process consent session with challenge id '%s': could not authorize the user user '%s' for this consent session: %v", requester.GetID(), client.GetID(), consent.ChallengeID, userSession.Username, err) + + ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("The user is not authorized to perform consent.")) return nil, true } - if !consent.Subject.Valid { - if consent.Subject.UUID, err = ctx.Providers.OpenIDConnect.Store.GetSubject(ctx, client.GetSectorIdentifier(), userSession.Username); err != nil { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred retrieving subject for user '%s': %+v", requester.GetID(), client.GetID(), userSession.Username, err) - - ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not retrieve the subject.")) - - return nil, true - } - - consent.Subject.Valid = true - - if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionSubject(ctx, *consent); err != nil { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred updating consent session subject for user '%s': %+v", requester.GetID(), client.GetID(), userSession.Username, err) - - ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not update the consent session subject.")) - - return nil, true - } - } - if consent.Responded() { - userSession.ConsentChallengeID = nil - - if err = ctx.SaveSession(userSession); err != nil { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred saving session: %+v", requester.GetID(), client.GetID(), err) - - ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not save the session.")) - - return nil, true - } - if consent.Granted { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: this consent session with challenge id '%s' was already granted", requester.GetID(), client.GetID(), consent.ChallengeID.String()) + ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: this consent session with challenge id '%s' was already granted", requester.GetID(), client.GetID(), consent.ChallengeID) ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Authorization already granted.")) return nil, true } - ctx.Logger.Debugf("Authorization Request with id '%s' loaded consent session with id '%d' and challenge id '%s' for client id '%s' and subject '%s' and scopes '%s'", requester.GetID(), consent.ID, consent.ChallengeID.String(), client.GetID(), consent.Subject.String(), strings.Join(requester.GetRequestedScopes(), " ")) + ctx.Logger.Debugf("Authorization Request with id '%s' loaded consent session with id '%d' and challenge id '%s' for client id '%s' and subject '%s' and scopes '%s'", requester.GetID(), consent.ID, consent.ChallengeID, client.GetID(), consent.Subject.UUID, strings.Join(requester.GetRequestedScopes(), " ")) if consent.IsDenied() { - ctx.Logger.Warnf("Authorization Request with id '%s' and challenge id '%s' for client id '%s' and subject '%s' and scopes '%s' was not denied by the user durng the consent session", requester.GetID(), consent.ChallengeID.String(), client.GetID(), consent.Subject.String(), strings.Join(requester.GetRequestedScopes(), " ")) + ctx.Logger.Warnf("Authorization Request with id '%s' and challenge id '%s' for client id '%s' and subject '%s' and scopes '%s' was not denied by the user durng the consent session", requester.GetID(), consent.ChallengeID, client.GetID(), consent.Subject.UUID, strings.Join(requester.GetRequestedScopes(), " ")) ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrAccessDenied) @@ -107,13 +122,13 @@ func handleOIDCAuthorizationConsentWithChallengeID(ctx *middlewares.AutheliaCtx, return consent, false } - handleOIDCAuthorizationConsentRedirect(ctx, rootURI, client, userSession, rw, r, requester) + handleOIDCAuthorizationConsentRedirect(ctx, issuer, consent, client, userSession, rw, r, requester) return consent, true } -func handleOIDCAuthorizationConsentOrGenerate(ctx *middlewares.AutheliaCtx, rootURI string, client *oidc.Client, - userSession session.UserSession, subject model.NullUUID, +func handleOIDCAuthorizationConsentGenerate(ctx *middlewares.AutheliaCtx, issuer *url.URL, client *oidc.Client, + userSession session.UserSession, subject uuid.UUID, rw http.ResponseWriter, r *http.Request, requester fosite.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { var ( err error @@ -121,7 +136,7 @@ func handleOIDCAuthorizationConsentOrGenerate(ctx *middlewares.AutheliaCtx, root scopes, audience := getOIDCExpectedScopesAndAudienceFromRequest(requester) - if consent, err = getOIDCPreConfiguredConsent(ctx, client.GetID(), subject.UUID, scopes, audience); err != nil { + if consent, err = getOIDCPreConfiguredConsent(ctx, client.GetID(), subject, scopes, audience); err != nil { ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' had error looking up pre-configured consent sessions: %+v", requester.GetID(), requester.GetClient().GetID(), err) ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not lookup the consent session.")) @@ -137,14 +152,6 @@ func handleOIDCAuthorizationConsentOrGenerate(ctx *middlewares.AutheliaCtx, root ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' proceeding to generate a new consent due to unsuccessful lookup of pre-configured consent", requester.GetID(), client.GetID()) - return handleOIDCAuthorizationConsentGenerate(ctx, rootURI, client, userSession, subject, rw, r, requester) -} - -func handleOIDCAuthorizationConsentGenerate(ctx *middlewares.AutheliaCtx, rootURI string, client *oidc.Client, - userSession session.UserSession, subject model.NullUUID, - rw http.ResponseWriter, r *http.Request, requester fosite.AuthorizeRequester) (consent *model.OAuth2ConsentSession, handled bool) { - var err error - if consent, err = model.NewOAuth2ConsentSession(subject, requester); err != nil { ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred generating consent: %+v", requester.GetID(), requester.GetClient().GetID(), err) @@ -161,34 +168,81 @@ func handleOIDCAuthorizationConsentGenerate(ctx *middlewares.AutheliaCtx, rootUR return nil, true } - userSession.ConsentChallengeID = &consent.ChallengeID - - if err = ctx.SaveSession(userSession); err != nil { - ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred saving user session for consent: %+v", requester.GetID(), client.GetID(), err) - - ctx.Providers.OpenIDConnect.Fosite.WriteAuthorizeError(rw, requester, fosite.ErrServerError.WithHint("Could not save the user session.")) - - return nil, true - } - - handleOIDCAuthorizationConsentRedirect(ctx, rootURI, client, userSession, rw, r, requester) + handleOIDCAuthorizationConsentRedirect(ctx, issuer, consent, client, userSession, rw, r, requester) return consent, true } -func handleOIDCAuthorizationConsentRedirect(ctx *middlewares.AutheliaCtx, destination string, client *oidc.Client, +func handleOIDCAuthorizationConsentRedirect(ctx *middlewares.AutheliaCtx, issuer *url.URL, consent *model.OAuth2ConsentSession, client *oidc.Client, userSession session.UserSession, rw http.ResponseWriter, r *http.Request, requester fosite.AuthorizeRequester) { - if client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) { - ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' authentication level '%s' is sufficient for client level '%s'", requester.GetID(), client.GetID(), authentication.LevelToString(userSession.AuthenticationLevel), authorization.LevelToPolicy(client.Policy)) + var location *url.URL - destination = fmt.Sprintf("%s/consent", destination) + if client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) { + location, _ = url.Parse(issuer.String()) + location.Path = path.Join(location.Path, "/consent") + + query := location.Query() + query.Set("consent_id", consent.ChallengeID.String()) + + location.RawQuery = query.Encode() + + ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' authentication level '%s' is sufficient for client level '%s'", requester.GetID(), client.GetID(), authentication.LevelToString(userSession.AuthenticationLevel), authorization.LevelToString(client.Policy)) } else { - ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' authentication level '%s' is insufficient for client level '%s'", requester.GetID(), client.GetID(), authentication.LevelToString(userSession.AuthenticationLevel), authorization.LevelToPolicy(client.Policy)) + location = getOIDCAuthorizationRedirectURL(issuer, requester) + + ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' authentication level '%s' is insufficient for client level '%s'", requester.GetID(), client.GetID(), authentication.LevelToString(userSession.AuthenticationLevel), authorization.LevelToString(client.Policy)) } - ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' is being redirected to '%s'", requester.GetID(), client.GetID(), destination) + ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' is being redirected to '%s'", requester.GetID(), client.GetID(), location) - http.Redirect(rw, r, destination, http.StatusFound) + http.Redirect(rw, r, location.String(), http.StatusFound) +} + +func verifyOIDCUserAuthorizedForConsent(ctx *middlewares.AutheliaCtx, client *oidc.Client, userSession session.UserSession, consent *model.OAuth2ConsentSession, subject uuid.UUID) (err error) { + var sid, csid uint32 + + csid = consent.Subject.UUID.ID() + + if !consent.Subject.Valid || csid == 0 { + return fmt.Errorf("the consent subject is null for consent session with id '%d'", consent.ID) + } + + if client == nil { + if client, err = ctx.Providers.OpenIDConnect.Store.GetFullClient(consent.ClientID); err != nil { + return fmt.Errorf("failed to retrieve client: %w", err) + } + } + + if sid = subject.ID(); sid == 0 { + if subject, err = ctx.Providers.OpenIDConnect.Store.GetSubject(ctx, client.GetSectorIdentifier(), userSession.Username); err != nil { + return fmt.Errorf("failed to lookup subject: %w", err) + } + + sid = subject.ID() + } + + if csid != sid { + return fmt.Errorf("the consent subject identifier '%s' isn't owned by user '%s' who has a subject identifier of '%s' with sector identifier '%s'", consent.Subject.UUID, userSession.Username, subject, client.GetSectorIdentifier()) + } + + return nil +} + +func getOIDCAuthorizationRedirectURL(issuer *url.URL, requester fosite.AuthorizeRequester) (redirectURL *url.URL) { + redirectURL, _ = url.Parse(issuer.String()) + + authorizationURL, _ := url.Parse(issuer.String()) + + authorizationURL.Path = path.Join(authorizationURL.Path, oidc.AuthorizationPath) + authorizationURL.RawQuery = requester.GetRequestForm().Encode() + + query := redirectURL.Query() + query.Set("rd", authorizationURL.String()) + query.Set("workflow", workflowOpenIDConnect) + + redirectURL.RawQuery = query.Encode() + + return redirectURL } func getOIDCExpectedScopesAndAudienceFromRequest(requester fosite.Requester) (scopes, audience []string) { @@ -203,19 +257,6 @@ func getOIDCExpectedScopesAndAudience(clientID string, scopes, audience []string return scopes, audience } -func getOIDCPreConfiguredConsentFromClientAndConsent(ctx *middlewares.AutheliaCtx, client fosite.Client, consent *model.OAuth2ConsentSession) (preConfigConsent *model.OAuth2ConsentSession, err error) { - if consent == nil || !consent.Subject.Valid { - return nil, fmt.Errorf("invalid consent provided for pre-configured consent lookup") - } - - scopes, audience := getOIDCExpectedScopesAndAudience(client.GetID(), consent.RequestedScopes, consent.RequestedAudience) - - // We can skip this error as it's handled at the authorization endpoint. - preConfigConsent, _ = getOIDCPreConfiguredConsent(ctx, client.GetID(), consent.Subject.UUID, scopes, audience) - - return preConfigConsent, nil -} - func getOIDCPreConfiguredConsent(ctx *middlewares.AutheliaCtx, clientID string, subject uuid.UUID, scopes, audience []string) (consent *model.OAuth2ConsentSession, err error) { var ( rows *storage.ConsentSessionRows diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index 35e163224..aa6bc3f31 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -3,8 +3,13 @@ package handlers import ( "encoding/json" "fmt" + "net/url" + "path" + "strings" "time" + "github.com/google/uuid" + "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" @@ -14,7 +19,19 @@ import ( // OpenIDConnectConsentGET handles requests to provide consent for OpenID Connect. func OpenIDConnectConsentGET(ctx *middlewares.AutheliaCtx) { - userSession, consent, client, handled := oidcConsentGetSessionsAndClient(ctx) + var ( + consentID uuid.UUID + err error + ) + + if consentID, err = uuid.Parse(string(ctx.RequestCtx.QueryArgs().Peek("consent_id"))); err != nil { + ctx.Logger.Errorf("Unable to convert '%s' into a UUID: %+v", ctx.RequestCtx.QueryArgs().Peek("consent_id"), err) + ctx.ReplyForbidden() + + return + } + + userSession, consent, client, handled := oidcConsentGetSessionsAndClient(ctx, consentID) if handled { return } @@ -26,26 +43,35 @@ func OpenIDConnectConsentGET(ctx *middlewares.AutheliaCtx) { return } - if err := ctx.SetJSONBody(client.GetConsentResponseBody(consent)); err != nil { + if err = ctx.SetJSONBody(client.GetConsentResponseBody(consent)); err != nil { ctx.Error(fmt.Errorf("unable to set JSON body: %v", err), "Operation failed") } } +//nolint:gocyclo // OpenIDConnectConsentPOST handles consent responses for OpenID Connect. func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) { var ( - body oidc.ConsentPostRequestBody - err error + consentID uuid.UUID + bodyJSON oidc.ConsentPostRequestBody + err error ) - if err = json.Unmarshal(ctx.Request.Body(), &body); err != nil { - ctx.Logger.Errorf("Failed to parse JSON body in consent POST: %+v", err) + if err = json.Unmarshal(ctx.Request.Body(), &bodyJSON); err != nil { + ctx.Logger.Errorf("Failed to parse JSON bodyJSON in consent POST: %+v", err) ctx.SetJSONError(messageOperationFailed) return } - userSession, consent, client, handled := oidcConsentGetSessionsAndClient(ctx) + if consentID, err = uuid.Parse(bodyJSON.ConsentID); err != nil { + ctx.Logger.Errorf("Unable to convert '%s' into a UUID: %+v", ctx.RequestCtx.QueryArgs().Peek("consent_id"), err) + ctx.ReplyForbidden() + + return + } + + userSession, consent, client, handled := oidcConsentGetSessionsAndClient(ctx, consentID) if handled { return } @@ -57,36 +83,23 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) { return } - if consent.ClientID != body.ClientID { + if consent.ClientID != bodyJSON.ClientID { ctx.Logger.Errorf("User '%s' consented to scopes of another client (%s) than expected (%s). Beware this can be a sign of attack", - userSession.Username, body.ClientID, consent.ClientID) + userSession.Username, bodyJSON.ClientID, consent.ClientID) ctx.SetJSONError(messageOperationFailed) return } - var ( - externalRootURL string - authorized = true - ) - - switch body.AcceptOrReject { - case accept: - if externalRootURL, err = ctx.ExternalRootURL(); err != nil { - ctx.Logger.Errorf("Could not determine the external URL during consent session processing with challenge id '%s' for user '%s': %v", consent.ChallengeID.String(), userSession.Username, err) - ctx.SetJSONError(messageOperationFailed) - - return - } - - if body.PreConfigure { + if bodyJSON.Consent { + if bodyJSON.PreConfigure { if client.PreConfiguredConsentDuration == nil { - ctx.Logger.Warnf("Consent session with challenge id '%s' for user '%s': consent pre-configuration was requested and was ignored because it is not permitted on this client", consent.ChallengeID.String(), userSession.Username) + ctx.Logger.Warnf("Consent session with id '%s' for user '%s': consent pre-configuration was requested and was ignored because it is not permitted on this client", consent.ChallengeID, userSession.Username) } else { expiresAt := time.Now().Add(*client.PreConfiguredConsentDuration) consent.ExpiresAt = &expiresAt - ctx.Logger.Debugf("Consent session with challenge id '%s' for user '%s': pre-configured and set to expire at %v", consent.ChallengeID.String(), userSession.Username, consent.ExpiresAt) + ctx.Logger.Debugf("Consent session with id '%s' for user '%s': pre-configured and set to expire at %v", consent.ChallengeID, userSession.Username, consent.ExpiresAt) } } @@ -96,45 +109,68 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) { if !utils.IsStringInSlice(consent.ClientID, consent.GrantedAudience) { consent.GrantedAudience = append(consent.GrantedAudience, consent.ClientID) } - case reject: - authorized = false - default: - ctx.Logger.Warnf("User '%s' tried to reply to consent with an unexpected verb '%s'", userSession.Username, body.AcceptOrReject) - ctx.ReplyBadRequest() + } + + var externalRootURL string + + if externalRootURL, err = ctx.ExternalRootURL(); err != nil { + ctx.Logger.Errorf("Could not determine the external URL during consent session processing with id '%s' for user '%s': %v", consent.ChallengeID, userSession.Username, err) + ctx.SetJSONError(messageOperationFailed) return } - if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, authorized); err != nil { + if err = ctx.Providers.StorageProvider.SaveOAuth2ConsentSessionResponse(ctx, *consent, bodyJSON.Consent); err != nil { ctx.Logger.Errorf("Failed to save the consent session response to the database: %+v", err) ctx.SetJSONError(messageOperationFailed) return } - response := oidc.ConsentPostResponseBody{RedirectURI: fmt.Sprintf("%s%s?%s", externalRootURL, oidc.AuthorizationPath, consent.Form)} + var ( + redirectURI *url.URL + query url.Values + ) + + if redirectURI, err = url.ParseRequestURI(externalRootURL); err != nil { + ctx.Logger.Errorf("Failed to parse the consent redirect URL: %+v", err) + ctx.SetJSONError(messageOperationFailed) + + return + } + + if !strings.HasSuffix(redirectURI.Path, "/") { + redirectURI.Path += "/" + } + + if query, err = url.ParseQuery(consent.Form); err != nil { + ctx.Logger.Errorf("Failed to parse the consent form values: %+v", err) + ctx.SetJSONError(messageOperationFailed) + + return + } + + query.Set("consent_id", consent.ChallengeID.String()) + + redirectURI.Path = path.Join(redirectURI.Path, oidc.AuthorizationPath) + redirectURI.RawQuery = query.Encode() + + response := oidc.ConsentPostResponseBody{RedirectURI: redirectURI.String()} if err = ctx.SetJSONBody(response); err != nil { - ctx.Error(fmt.Errorf("unable to set JSON body in response"), "Operation failed") + ctx.Error(fmt.Errorf("unable to set JSON bodyJSON in response"), "Operation failed") } } -func oidcConsentGetSessionsAndClient(ctx *middlewares.AutheliaCtx) (userSession session.UserSession, consent *model.OAuth2ConsentSession, client *oidc.Client, handled bool) { +func oidcConsentGetSessionsAndClient(ctx *middlewares.AutheliaCtx, consentID uuid.UUID) (userSession session.UserSession, consent *model.OAuth2ConsentSession, client *oidc.Client, handled bool) { var ( err error ) userSession = ctx.GetSession() - if userSession.ConsentChallengeID == nil { - ctx.Logger.Errorf("Cannot consent for user '%s' when OIDC consent session has not been initiated", userSession.Username) - ctx.ReplyForbidden() - - return userSession, nil, nil, true - } - - if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, *userSession.ConsentChallengeID); err != nil { - ctx.Logger.Errorf("Unable to load consent session with challenge id '%s': %v", userSession.ConsentChallengeID.String(), err) + if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, consentID); err != nil { + ctx.Logger.Errorf("Unable to load consent session with challenge id '%s': %v", consentID, err) ctx.ReplyForbidden() return userSession, nil, nil, true @@ -147,5 +183,13 @@ func oidcConsentGetSessionsAndClient(ctx *middlewares.AutheliaCtx) (userSession return userSession, nil, nil, true } + if err = verifyOIDCUserAuthorizedForConsent(ctx, client, userSession, consent, uuid.UUID{}); err != nil { + ctx.Logger.Errorf("Could not authorize the user user '%s' for the consent session with challenge id '%s' on client with id '%s': %v", userSession.Username, consent.ChallengeID, client.GetID(), err) + + ctx.ReplyForbidden() + + return userSession, nil, nil, true + } + return userSession, consent, client, false } diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go index 6099b65a4..704ce4320 100644 --- a/internal/handlers/handler_sign_duo.go +++ b/internal/handlers/handler_sign_duo.go @@ -16,11 +16,11 @@ import ( func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { return func(ctx *middlewares.AutheliaCtx) { var ( - requestBody signDuoRequestBody + bodyJSON = &signDuoRequestBody{} device, method string ) - if err := ctx.ParseBody(&requestBody); err != nil { + if err := ctx.ParseBody(bodyJSON); err != nil { ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDuo, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -35,10 +35,10 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { if err != nil { ctx.Logger.Debugf("Error identifying preferred device for user %s: %s", userSession.Username, err) ctx.Logger.Debugf("Starting Duo PreAuth for initial device selection of user: %s", userSession.Username) - device, method, err = HandleInitialDeviceSelection(ctx, &userSession, duoAPI, requestBody.TargetURL) + device, method, err = HandleInitialDeviceSelection(ctx, &userSession, duoAPI, bodyJSON) } else { ctx.Logger.Debugf("Starting Duo PreAuth to check preferred device of user: %s", userSession.Username) - device, method, err = HandlePreferredDeviceCheck(ctx, &userSession, duoAPI, duoDevice.Device, duoDevice.Method, requestBody.TargetURL) + device, method, err = HandlePreferredDeviceCheck(ctx, &userSession, duoAPI, duoDevice.Device, duoDevice.Method, bodyJSON) } if err != nil { @@ -52,7 +52,7 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { ctx.Logger.Debugf("Starting Duo Auth attempt for %s with device %s and method %s from IP %s", userSession.Username, device, method, remoteIP) - values, err := SetValues(userSession, device, method, remoteIP, requestBody.TargetURL, requestBody.Passcode) + values, err := SetValues(userSession, device, method, remoteIP, bodyJSON.TargetURL, bodyJSON.Passcode) if err != nil { ctx.Logger.Errorf("Failed to set values for Duo Auth Call for user '%s': %+v", userSession.Username, err) @@ -85,12 +85,12 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { return } - HandleAllow(ctx, requestBody.TargetURL) + HandleAllow(ctx, bodyJSON) } } // HandleInitialDeviceSelection handler for retrieving all available devices. -func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, targetURL string) (device string, method string, err error) { +func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, bodyJSON *signDuoRequestBody) (device string, method string, err error) { result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI) if err != nil { ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err) @@ -119,7 +119,7 @@ func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *ses return "", "", nil case allow: ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username) - HandleAllow(ctx, targetURL) + HandleAllow(ctx, bodyJSON) return "", "", nil case auth: @@ -135,7 +135,7 @@ func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *ses } // HandlePreferredDeviceCheck handler to check if the saved device and method is still valid. -func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, device string, method string, targetURL string) (string, string, error) { +func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, device string, method string, bodyJSON *signDuoRequestBody) (string, string, error) { result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI) if err != nil { ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err) @@ -165,7 +165,7 @@ func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *sessi return "", "", nil case allow: ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username) - HandleAllow(ctx, targetURL) + HandleAllow(ctx, bodyJSON) return "", "", nil case auth: @@ -243,7 +243,7 @@ func HandleAutoSelection(ctx *middlewares.AutheliaCtx, devices []DuoDevice, user } // HandleAllow handler for successful logins. -func HandleAllow(ctx *middlewares.AutheliaCtx, targetURL string) { +func HandleAllow(ctx *middlewares.AutheliaCtx, bodyJSON *signDuoRequestBody) { userSession := ctx.GetSession() err := ctx.Providers.SessionProvider.RegenerateSession(ctx.RequestCtx) @@ -266,10 +266,10 @@ func HandleAllow(ctx *middlewares.AutheliaCtx, targetURL string) { return } - if userSession.ConsentChallengeID != nil { - handleOIDCWorkflowResponse(ctx) + if bodyJSON.Workflow == workflowOpenIDConnect { + handleOIDCWorkflowResponse(ctx, bodyJSON.TargetURL) } else { - Handle2FAResponse(ctx, targetURL) + Handle2FAResponse(ctx, bodyJSON.TargetURL) } } diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index 12ba13fd8..98e04561e 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -7,9 +7,9 @@ import ( // TimeBasedOneTimePasswordPOST validate the TOTP passcode provided by the user. func TimeBasedOneTimePasswordPOST(ctx *middlewares.AutheliaCtx) { - requestBody := signTOTPRequestBody{} + bodyJSON := signTOTPRequestBody{} - if err := ctx.ParseBody(&requestBody); err != nil { + if err := ctx.ParseBody(&bodyJSON); err != nil { ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeTOTP, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -28,7 +28,7 @@ func TimeBasedOneTimePasswordPOST(ctx *middlewares.AutheliaCtx) { return } - isValid, err := ctx.Providers.TOTP.Validate(requestBody.Token, config) + isValid, err := ctx.Providers.TOTP.Validate(bodyJSON.Token, config) if err != nil { ctx.Logger.Errorf("Failed to perform TOTP verification: %+v", err) @@ -78,9 +78,9 @@ func TimeBasedOneTimePasswordPOST(ctx *middlewares.AutheliaCtx) { return } - if userSession.ConsentChallengeID != nil { - handleOIDCWorkflowResponse(ctx) + if bodyJSON.Workflow == workflowOpenIDConnect { + handleOIDCWorkflowResponse(ctx, bodyJSON.TargetURL) } else { - Handle2FAResponse(ctx, requestBody.TargetURL) + Handle2FAResponse(ctx, bodyJSON.TargetURL) } } diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go index bd1709959..d4007e4e2 100644 --- a/internal/handlers/handler_sign_webauthn.go +++ b/internal/handlers/handler_sign_webauthn.go @@ -84,10 +84,10 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { err error w *webauthn.WebAuthn - requestBody signWebauthnRequestBody + bodyJSON signWebauthnRequestBody ) - if err = ctx.ParseBody(&requestBody); err != nil { + if err = ctx.ParseBody(&bodyJSON); err != nil { ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeWebauthn, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -197,9 +197,9 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { return } - if userSession.ConsentChallengeID != nil { - handleOIDCWorkflowResponse(ctx) + if bodyJSON.Workflow == workflowOpenIDConnect { + handleOIDCWorkflowResponse(ctx, bodyJSON.TargetURL) } else { - Handle2FAResponse(ctx, requestBody.TargetURL) + Handle2FAResponse(ctx, bodyJSON.TargetURL) } } diff --git a/internal/handlers/response.go b/internal/handlers/response.go index 4ff8a2276..c8fb3007e 100644 --- a/internal/handlers/response.go +++ b/internal/handlers/response.go @@ -9,50 +9,48 @@ import ( "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/middlewares" - "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" "github.com/authelia/authelia/v4/internal/utils" ) // handleOIDCWorkflowResponse handle the redirection upon authentication in the OIDC workflow. -func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx) { +func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx, targetURI string) { + if len(targetURI) == 0 { + ctx.Error(fmt.Errorf("unable to parse target URL %s: empty value", targetURI), messageAuthenticationFailed) + + return + } + + var ( + targetURL *url.URL + err error + ) + + if targetURL, err = url.ParseRequestURI(targetURI); err != nil { + ctx.Error(fmt.Errorf("unable to parse target URL %s: %w", targetURI, err), messageAuthenticationFailed) + + return + } + + var ( + id string + client *oidc.Client + ) + + if id = targetURL.Query().Get("client_id"); len(id) == 0 { + ctx.Error(fmt.Errorf("unable to get client id from from URL '%s'", targetURL), messageAuthenticationFailed) + + return + } + + if client, err = ctx.Providers.OpenIDConnect.Store.GetFullClient(id); err != nil { + ctx.Error(fmt.Errorf("unable to get client for client with id '%s' from URL '%s': %w", id, targetURL, err), messageAuthenticationFailed) + + return + } + userSession := ctx.GetSession() - if userSession.ConsentChallengeID == nil { - ctx.Logger.Errorf("Unable to handle OIDC workflow response because the user session doesn't contain a consent challenge id") - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - - externalRootURL, err := ctx.ExternalRootURL() - if err != nil { - ctx.Logger.Errorf("Unable to determine external Base URL: %v", err) - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - - consent, err := ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, *userSession.ConsentChallengeID) - if err != nil { - ctx.Logger.Errorf("Unable to load consent session from database: %v", err) - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - - client, err := ctx.Providers.OpenIDConnect.Store.GetFullClient(consent.ClientID) - if err != nil { - ctx.Logger.Errorf("Unable to find client for the consent session: %v", err) - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - if !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) { ctx.Logger.Warnf("OpenID Connect client '%s' requires 2FA, cannot be redirected yet", client.ID) ctx.ReplyOK() @@ -60,57 +58,18 @@ func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx) { return } - if consent.Subject.UUID, err = ctx.Providers.OpenIDConnect.Store.GetSubject(ctx, client.GetSectorIdentifier(), userSession.Username); err != nil { - ctx.Logger.Errorf("Unable to find subject for the consent session: %v", err) - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - - consent.Subject.Valid = true - - var preConsent *model.OAuth2ConsentSession - - if preConsent, err = getOIDCPreConfiguredConsentFromClientAndConsent(ctx, client, consent); err != nil { - ctx.Logger.Errorf("Unable to lookup pre-configured consent for the consent session: %v", err) - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - - if userSession.ConsentChallengeID != nil && preConsent == nil { - if err = ctx.SetJSONBody(redirectResponse{Redirect: fmt.Sprintf("%s/consent", externalRootURL)}); err != nil { - ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) - } - - return - } - - if userSession.ConsentChallengeID != nil { - userSession.ConsentChallengeID = nil - - if err = ctx.SaveSession(userSession); err != nil { - ctx.Logger.Errorf("Unable to update user session: %v", err) - - respondUnauthorized(ctx, messageOperationFailed) - - return - } - } - - if err = ctx.SetJSONBody(redirectResponse{Redirect: fmt.Sprintf("%s%s?%s", externalRootURL, oidc.AuthorizationPath, consent.Form)}); err != nil { + if err = ctx.SetJSONBody(redirectResponse{Redirect: targetURL.String()}); err != nil { ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) } } // Handle1FAResponse handle the redirection upon 1FA authentication. func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod string, username string, groups []string) { - if targetURI == "" { + var err error + + if len(targetURI) == 0 { if !ctx.Providers.Authorizer.IsSecondFactorEnabled() && ctx.Configuration.DefaultRedirectionURL != "" { - err := ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}) - if err != nil { + if err = ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}); err != nil { ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) } } else { @@ -120,9 +79,11 @@ func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod st return } - targetURL, err := url.ParseRequestURI(targetURI) - if err != nil { + var targetURL *url.URL + + if targetURL, err = url.ParseRequestURI(targetURI); err != nil { ctx.Error(fmt.Errorf("unable to parse target URL %s: %s", targetURI, err), messageAuthenticationFailed) + return } @@ -143,63 +104,66 @@ func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod st return } - safeRedirection := utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) - - if !safeRedirection { + if !utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) { ctx.Logger.Debugf("Redirection URL %s is not safe", targetURI) if !ctx.Providers.Authorizer.IsSecondFactorEnabled() && ctx.Configuration.DefaultRedirectionURL != "" { - err := ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}) - if err != nil { + if err = ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}); err != nil { ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) } - } else { - ctx.ReplyOK() + + return } + ctx.ReplyOK() + return } ctx.Logger.Debugf("Redirection URL %s is safe", targetURI) - err = ctx.SetJSONBody(redirectResponse{Redirect: targetURI}) - if err != nil { + if err = ctx.SetJSONBody(redirectResponse{Redirect: targetURI}); err != nil { ctx.Logger.Errorf("Unable to set redirection URL in body: %s", err) } } // Handle2FAResponse handle the redirection upon 2FA authentication. func Handle2FAResponse(ctx *middlewares.AutheliaCtx, targetURI string) { - if targetURI == "" { - if ctx.Configuration.DefaultRedirectionURL != "" { - err := ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}) - if err != nil { - ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) - } - } else { + var err error + + if len(targetURI) == 0 { + if len(ctx.Configuration.DefaultRedirectionURL) == 0 { ctx.ReplyOK() + + return + } + + if err = ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}); err != nil { + ctx.Logger.Errorf("Unable to set default redirection URL in body: %s", err) } return } - safe, err := utils.IsRedirectionURISafe(targetURI, ctx.Configuration.Session.Domain) + var safe bool - if err != nil { + if safe, err = utils.IsRedirectionURISafe(targetURI, ctx.Configuration.Session.Domain); err != nil { ctx.Error(fmt.Errorf("unable to check target URL: %s", err), messageMFAValidationFailed) + return } if safe { ctx.Logger.Debugf("Redirection URL %s is safe", targetURI) - err := ctx.SetJSONBody(redirectResponse{Redirect: targetURI}) - if err != nil { + if err = ctx.SetJSONBody(redirectResponse{Redirect: targetURI}); err != nil { ctx.Logger.Errorf("Unable to set redirection URL in body: %s", err) } - } else { - ctx.ReplyOK() + + return } + + ctx.ReplyOK() } func markAuthenticationAttempt(ctx *middlewares.AutheliaCtx, successful bool, bannedUntil *time.Time, username string, authType string, errAuth error) (err error) { diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 4431c49da..b2be6e512 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -18,16 +18,19 @@ type configurationBody struct { type signTOTPRequestBody struct { Token string `json:"token" valid:"required"` TargetURL string `json:"targetURL"` + Workflow string `json:"workflow"` } // signWebauthnRequestBody model of the request body of Webauthn authentication endpoint. type signWebauthnRequestBody struct { TargetURL string `json:"targetURL"` + Workflow string `json:"workflow"` } type signDuoRequestBody struct { TargetURL string `json:"targetURL"` Passcode string `json:"passcode"` + Workflow string `json:"workflow"` } // preferred2FAMethodBody the selected 2FA method. @@ -40,6 +43,7 @@ type firstFactorRequestBody struct { Username string `json:"username" valid:"required"` Password string `json:"password" valid:"required"` TargetURL string `json:"targetURL"` + Workflow string `json:"workflow"` RequestMethod string `json:"requestMethod"` KeepMeLoggedIn *bool `json:"keepMeLoggedIn"` // KeepMeLoggedIn: Cannot require this field because of https://github.com/asaskevich/govalidator/pull/329 diff --git a/internal/model/oidc.go b/internal/model/oidc.go index 5b7b9f786..f41816c94 100644 --- a/internal/model/oidc.go +++ b/internal/model/oidc.go @@ -17,10 +17,12 @@ import ( ) // NewOAuth2ConsentSession creates a new OAuth2ConsentSession. -func NewOAuth2ConsentSession(subject NullUUID, r fosite.Requester) (consent *OAuth2ConsentSession, err error) { +func NewOAuth2ConsentSession(subject uuid.UUID, r fosite.Requester) (consent *OAuth2ConsentSession, err error) { + valid := subject.ID() != 0 + consent = &OAuth2ConsentSession{ ClientID: r.GetClient().GetID(), - Subject: subject, + Subject: uuid.NullUUID{UUID: subject, Valid: valid}, Form: r.GetRequestForm().Encode(), RequestedAt: r.GetRequestedAt(), RequestedScopes: StringSlicePipeDelimited(r.GetRequestedScopes()), @@ -84,10 +86,10 @@ func NewOAuth2BlacklistedJTI(jti string, exp time.Time) (jtiBlacklist OAuth2Blac // OAuth2ConsentSession stores information about an OAuth2.0 Consent. type OAuth2ConsentSession struct { - ID int `db:"id"` - ChallengeID uuid.UUID `db:"challenge_id"` - ClientID string `db:"client_id"` - Subject NullUUID `db:"subject"` + ID int `db:"id"` + ChallengeID uuid.UUID `db:"challenge_id"` + ClientID string `db:"client_id"` + Subject uuid.NullUUID `db:"subject"` Authorized bool `db:"authorized"` Granted bool `db:"granted"` diff --git a/internal/model/types.go b/internal/model/types.go index b326e76af..674e6aa43 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -7,37 +7,9 @@ import ( "fmt" "net" - "github.com/google/uuid" - "github.com/authelia/authelia/v4/internal/utils" ) -// NullUUID is a nullable uuid.UUID. -type NullUUID struct { - uuid.UUID - Valid bool -} - -// Value is the NullUUID implementation of the databases/sql driver.Valuer. -func (u NullUUID) Value() (value driver.Value, err error) { - if !u.Valid { - return nil, nil - } - - return u.UUID.Value() -} - -// Scan is the NullUUID implementation of the sql.Scanner. -func (u *NullUUID) Scan(src interface{}) (err error) { - if src == nil { - u.UUID, u.Valid = uuid.UUID{}, false - - return nil - } - - return u.UUID.Scan(src) -} - // NewIP easily constructs a new IP. func NewIP(value net.IP) (ip IP) { return IP{IP: value} diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 0be6ce4f0..c5b7cb975 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -27,7 +27,7 @@ func NewClient(config schema.OpenIDConnectClientConfiguration) (client *Client) UserinfoSigningAlgorithm: config.UserinfoSigningAlgorithm, - Policy: authorization.PolicyToLevel(config.Policy), + Policy: authorization.StringToLevel(config.Policy), PreConfiguredConsentDuration: config.PreConfiguredConsentDuration, } diff --git a/internal/oidc/store.go b/internal/oidc/store.go index 4a66ee9be..2331df0fa 100644 --- a/internal/oidc/store.go +++ b/internal/oidc/store.go @@ -28,7 +28,7 @@ func NewOpenIDConnectStore(config *schema.OpenIDConnectConfiguration, provider s } for _, client := range config.Clients { - policy := authorization.PolicyToLevel(client.Policy) + policy := authorization.StringToLevel(client.Policy) logger.Debugf("Registering client %s with policy %s (%v)", client.ID, client.Policy, policy) store.clients[client.ID] = NewClient(client) diff --git a/internal/oidc/types.go b/internal/oidc/types.go index a5a26c5e1..051318b38 100644 --- a/internal/oidc/types.go +++ b/internal/oidc/types.go @@ -41,7 +41,7 @@ func NewSessionWithAuthorizeRequest(issuer, kid, username string, amr []string, session = &model.OpenIDSession{ DefaultSession: &openid.DefaultSession{ Claims: &jwt.IDTokenClaims{ - Subject: consent.Subject.String(), + Subject: consent.Subject.UUID.String(), Issuer: issuer, AuthTime: authTime, RequestedAt: consent.RequestedAt, @@ -57,7 +57,7 @@ func NewSessionWithAuthorizeRequest(issuer, kid, username string, amr []string, "kid": kid, }, }, - Subject: consent.Subject.String(), + Subject: consent.Subject.UUID.String(), Username: username, }, Extra: map[string]interface{}{}, @@ -142,9 +142,10 @@ type ConsentGetResponseBody struct { // ConsentPostRequestBody schema of the request body of the consent POST endpoint. type ConsentPostRequestBody struct { - ClientID string `json:"client_id"` - AcceptOrReject string `json:"accept_or_reject"` - PreConfigure bool `json:"pre_configure"` + ClientID string `json:"client_id"` + ConsentID string `json:"consent_id"` + Consent bool `json:"consent"` + PreConfigure bool `json:"pre_configure"` } // ConsentPostResponseBody schema of the response body of the consent POST endpoint. diff --git a/internal/oidc/types_test.go b/internal/oidc/types_test.go index 9702bae21..936804f83 100644 --- a/internal/oidc/types_test.go +++ b/internal/oidc/types_test.go @@ -56,7 +56,7 @@ func TestNewSessionWithAuthorizeRequest(t *testing.T) { consent := &model.OAuth2ConsentSession{ ChallengeID: uuid.New(), RequestedAt: requested, - Subject: model.NullUUID{UUID: subject, Valid: true}, + Subject: uuid.NullUUID{UUID: subject, Valid: true}, } session := NewSessionWithAuthorizeRequest(issuer, "primary", "john", amr, extra, authAt, consent, request) diff --git a/internal/session/types.go b/internal/session/types.go index b73937d73..e4a046f89 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -7,7 +7,6 @@ import ( "github.com/fasthttp/session/v2" "github.com/fasthttp/session/v2/providers/redis" "github.com/go-webauthn/webauthn/webauthn" - "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/authelia/authelia/v4/internal/authentication" @@ -43,9 +42,6 @@ type UserSession struct { // Webauthn holds the session registration data for this session. Webauthn *webauthn.SessionData - // ConsentChallengeID is the OpenID Connect Consent Session challenge ID. - ConsentChallengeID *uuid.UUID - // This boolean is set to true after identity verification and checked // while doing the query actually updating the password. PasswordResetUsername *string diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go index 7e19e674c..bb5ac9895 100644 --- a/internal/storage/sql_provider.go +++ b/internal/storage/sql_provider.go @@ -398,7 +398,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSession(ctx context.Context, consent mode consent.ChallengeID, consent.ClientID, consent.Subject, consent.Authorized, consent.Granted, consent.RequestedAt, consent.RespondedAt, consent.ExpiresAt, consent.Form, consent.RequestedScopes, consent.GrantedScopes, consent.RequestedAudience, consent.GrantedAudience); err != nil { - return fmt.Errorf("error inserting oauth2 consent session with challenge id '%s' for subject '%s': %w", consent.ChallengeID.String(), consent.Subject.String(), err) + return fmt.Errorf("error inserting oauth2 consent session with challenge id '%s' for subject '%s': %w", consent.ChallengeID.String(), consent.Subject.UUID.String(), err) } return nil @@ -407,7 +407,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSession(ctx context.Context, consent mode // SaveOAuth2ConsentSessionSubject updates an OAuth2.0 consent session with the subject. func (p *SQLProvider) SaveOAuth2ConsentSessionSubject(ctx context.Context, consent model.OAuth2ConsentSession) (err error) { if _, err = p.db.ExecContext(ctx, p.sqlUpdateOAuth2ConsentSessionSubject, consent.Subject, consent.ID); err != nil { - return fmt.Errorf("error updating oauth2 consent session subject with id '%d' and challenge id '%s' for subject '%s': %w", consent.ID, consent.ChallengeID, consent.Subject, err) + return fmt.Errorf("error updating oauth2 consent session subject with id '%d' and challenge id '%s' for subject '%s': %w", consent.ID, consent.ChallengeID, consent.Subject.UUID, err) } return nil @@ -416,7 +416,7 @@ func (p *SQLProvider) SaveOAuth2ConsentSessionSubject(ctx context.Context, conse // SaveOAuth2ConsentSessionResponse updates an OAuth2.0 consent session with the response. func (p *SQLProvider) SaveOAuth2ConsentSessionResponse(ctx context.Context, consent model.OAuth2ConsentSession, authorized bool) (err error) { if _, err = p.db.ExecContext(ctx, p.sqlUpdateOAuth2ConsentSessionResponse, authorized, consent.ExpiresAt, consent.GrantedScopes, consent.GrantedAudience, consent.ID); err != nil { - return fmt.Errorf("error updating oauth2 consent session (authorized '%t') with id '%d' and challenge id '%s' for subject '%s': %w", authorized, consent.ID, consent.ChallengeID, consent.Subject, err) + return fmt.Errorf("error updating oauth2 consent session (authorized '%t') with id '%d' and challenge id '%s' for subject '%s': %w", authorized, consent.ID, consent.ChallengeID, consent.Subject.UUID, err) } return nil diff --git a/web/src/hooks/ConsentID.ts b/web/src/hooks/ConsentID.ts new file mode 100644 index 000000000..b780fc601 --- /dev/null +++ b/web/src/hooks/ConsentID.ts @@ -0,0 +1,8 @@ +import queryString from "query-string"; +import { useLocation } from "react-router-dom"; + +export function useConsentID() { + const location = useLocation(); + const queryParams = queryString.parse(location.search); + return queryParams && "consent_id" in queryParams ? (queryParams["consent_id"] as string) : undefined; +} diff --git a/web/src/hooks/RedirectionURL.ts b/web/src/hooks/RedirectionURL.ts index 9b9ae1e47..9f5369ffa 100644 --- a/web/src/hooks/RedirectionURL.ts +++ b/web/src/hooks/RedirectionURL.ts @@ -3,6 +3,8 @@ import { useLocation } from "react-router-dom"; export function useRedirectionURL() { const location = useLocation(); + const queryParams = queryString.parse(location.search); + return queryParams && "rd" in queryParams ? (queryParams["rd"] as string) : undefined; } diff --git a/web/src/hooks/Workflow.ts b/web/src/hooks/Workflow.ts new file mode 100644 index 000000000..f4a56a4cf --- /dev/null +++ b/web/src/hooks/Workflow.ts @@ -0,0 +1,8 @@ +import queryString from "query-string"; +import { useLocation } from "react-router-dom"; + +export function useWorkflow() { + const location = useLocation(); + const queryParams = queryString.parse(location.search); + return queryParams && "workflow" in queryParams ? (queryParams["workflow"] as string) : undefined; +} diff --git a/web/src/services/Consent.ts b/web/src/services/Consent.ts index 36601feb5..716224861 100644 --- a/web/src/services/Consent.ts +++ b/web/src/services/Consent.ts @@ -3,7 +3,8 @@ import { Post, Get } from "@services/Client"; interface ConsentPostRequestBody { client_id: string; - accept_or_reject: "accept" | "reject"; + consent_id?: string; + consent: boolean; pre_configure: boolean; } @@ -11,7 +12,7 @@ interface ConsentPostResponseBody { redirect_uri: string; } -interface ConsentGetResponseBody { +export interface ConsentGetResponseBody { client_id: string; client_description: string; scopes: string[]; @@ -19,20 +20,26 @@ interface ConsentGetResponseBody { pre_configuration: boolean; } -export function getConsentResponse() { - return Get(ConsentPath); +export function getConsentResponse(consentID: string) { + return Get(ConsentPath + "?consent_id=" + consentID); } -export function acceptConsent(clientID: string, preConfigure: boolean) { +export function acceptConsent(preConfigure: boolean, clientID: string, consentID?: string) { const body: ConsentPostRequestBody = { client_id: clientID, - accept_or_reject: "accept", + consent_id: consentID, + consent: true, pre_configure: preConfigure, }; return Post(ConsentPath, body); } -export function rejectConsent(clientID: string) { - const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "reject", pre_configure: false }; +export function rejectConsent(clientID: string, consentID?: string) { + const body: ConsentPostRequestBody = { + client_id: clientID, + consent_id: consentID, + consent: false, + pre_configure: false, + }; return Post(ConsentPath, body); } diff --git a/web/src/services/FirstFactor.ts b/web/src/services/FirstFactor.ts index 120e9d54f..9d86827b5 100644 --- a/web/src/services/FirstFactor.ts +++ b/web/src/services/FirstFactor.ts @@ -8,6 +8,7 @@ interface PostFirstFactorBody { keepMeLoggedIn: boolean; targetURL?: string; requestMethod?: string; + workflow?: string; } export async function postFirstFactor( @@ -16,6 +17,7 @@ export async function postFirstFactor( rememberMe: boolean, targetURL?: string, requestMethod?: string, + workflow?: string, ) { const data: PostFirstFactorBody = { username, @@ -31,6 +33,10 @@ export async function postFirstFactor( data.requestMethod = requestMethod; } + if (workflow) { + data.workflow = workflow; + } + const res = await PostWithOptionalResponse(FirstFactorPath, data); return res ? res : ({} as SignInResponse); } diff --git a/web/src/services/OneTimePassword.ts b/web/src/services/OneTimePassword.ts index b188956c3..51e9f3164 100644 --- a/web/src/services/OneTimePassword.ts +++ b/web/src/services/OneTimePassword.ts @@ -5,12 +5,18 @@ import { SignInResponse } from "@services/SignIn"; interface CompleteTOTPSigninBody { token: string; targetURL?: string; + workflow?: string; } -export function completeTOTPSignIn(passcode: string, targetURL: string | undefined) { +export function completeTOTPSignIn(passcode: string, targetURL?: string, workflow?: string) { const body: CompleteTOTPSigninBody = { token: `${passcode}` }; if (targetURL) { body.targetURL = targetURL; } + + if (workflow) { + body.workflow = workflow; + } + return PostWithOptionalResponse(CompleteTOTPSignInPath, body); } diff --git a/web/src/services/PushNotification.ts b/web/src/services/PushNotification.ts index 7a73f9082..f76e22e02 100644 --- a/web/src/services/PushNotification.ts +++ b/web/src/services/PushNotification.ts @@ -7,13 +7,19 @@ import { Get, PostWithOptionalResponse } from "@services/Client"; interface CompletePushSigninBody { targetURL?: string; + workflow?: string; } -export function completePushNotificationSignIn(targetURL: string | undefined) { +export function completePushNotificationSignIn(targetURL?: string, workflow?: string) { const body: CompletePushSigninBody = {}; if (targetURL) { body.targetURL = targetURL; } + + if (workflow) { + body.workflow = workflow; + } + return PostWithOptionalResponse(CompletePushNotificationSignInPath, body); } @@ -35,6 +41,7 @@ export interface DuoDevice { display_name: string; capabilities: string[]; } + export async function initiateDuoDeviceSelectionProcess() { return Get(InitiateDuoDeviceSelectionPath); } @@ -43,6 +50,7 @@ export interface DuoDevicePostRequest { device: string; method: string; } + export async function completeDuoDeviceSelectionProcess(device: DuoDevicePostRequest) { return PostWithOptionalResponse(CompleteDuoDeviceSelectionPath, { device: device.device, method: device.method }); } diff --git a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx index 83727e70e..e0167de56 100644 --- a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx +++ b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx @@ -19,12 +19,12 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { IndexRoute } from "@constants/Routes"; -import { useConsentResponse } from "@hooks/Consent"; +import { useConsentID } from "@hooks/ConsentID"; import { useNotifications } from "@hooks/NotificationsContext"; import { useRedirector } from "@hooks/Redirector"; import { useUserInfoGET } from "@hooks/UserInfo"; import LoginLayout from "@layouts/LoginLayout"; -import { acceptConsent, rejectConsent } from "@services/Consent"; +import { acceptConsent, ConsentGetResponseBody, getConsentResponse, rejectConsent } from "@services/Consent"; import LoadingPage from "@views/LoadingPage/LoadingPage"; export interface Props {} @@ -48,12 +48,13 @@ function scopeNameToAvatar(id: string) { const ConsentView = function (props: Props) { const styles = useStyles(); + const { t: translate } = useTranslation(); const navigate = useNavigate(); const redirect = useRedirector(); + const consentID = useConsentID(); const { createErrorNotification, resetNotification } = useNotifications(); - const [resp, fetch, , err] = useConsentResponse(); - const { t: translate } = useTranslation(); - + const [response, setResponse] = useState(undefined); + const [error, setError] = useState(undefined); const [preConfigure, setPreConfigure] = useState(false); const handlePreConfigureChanged = () => { @@ -66,22 +67,30 @@ const ConsentView = function (props: Props) { fetchUserInfo(); }, [fetchUserInfo]); + useEffect(() => { + if (consentID) { + getConsentResponse(consentID) + .then((r) => { + setResponse(r); + }) + .catch((error) => { + setError(error); + }); + } + }, [consentID]); + + useEffect(() => { + if (error) { + navigate(IndexRoute); + console.error(`Unable to display consent screen: ${error.message}`); + } + }, [navigate, resetNotification, createErrorNotification, error]); + useEffect(() => { if (fetchUserInfoError) { createErrorNotification("There was an issue retrieving user preferences"); } - }, [fetchUserInfoError, createErrorNotification]); - - useEffect(() => { - if (err) { - navigate(IndexRoute); - console.error(`Unable to display consent screen: ${err.message}`); - } - }, [navigate, resetNotification, createErrorNotification, err]); - - useEffect(() => { - fetch(); - }, [fetch]); + }, [fetchUserInfoError, resetNotification, createErrorNotification]); const translateScopeNameToDescription = (id: string): string => { switch (id) { @@ -102,10 +111,10 @@ const ConsentView = function (props: Props) { const handleAcceptConsent = async () => { // This case should not happen in theory because the buttons are disabled when response is undefined. - if (!resp) { + if (!response) { return; } - const res = await acceptConsent(resp.client_id, preConfigure); + const res = await acceptConsent(preConfigure, response.client_id, consentID); if (res.redirect_uri) { redirect(res.redirect_uri); } else { @@ -114,10 +123,10 @@ const ConsentView = function (props: Props) { }; const handleRejectConsent = async () => { - if (!resp) { + if (!response) { return; } - const res = await rejectConsent(resp.client_id); + const res = await rejectConsent(response.client_id, consentID); if (res.redirect_uri) { redirect(res.redirect_uri); } else { @@ -126,7 +135,7 @@ const ConsentView = function (props: Props) { }; return ( - + - {resp !== undefined && resp.client_description !== "" - ? resp.client_description - : resp?.client_id} + {response !== undefined && response.client_description !== "" + ? response.client_description + : response?.client_id} @@ -156,7 +165,7 @@ const ConsentView = function (props: Props) {
- {resp?.scopes.map((scope: string) => ( + {response?.scopes.map((scope: string) => ( {scopeNameToAvatar(scope)} @@ -167,7 +176,7 @@ const ConsentView = function (props: Props) {
- {resp?.pre_configuration ? ( + {response?.pre_configuration ? ( navigate(url), [navigate]); + const redirect = useCallback( + (pathname: string, search?: string) => { + if (search) { + navigate({ pathname: pathname, search: search }); + } else { + navigate({ pathname: pathname }); + } + }, + [navigate], + ); // Fetch the state when portal is mounted. useEffect(() => { @@ -119,23 +130,25 @@ const LoginPortal = function (props: Props) { return; } - const redirectionSuffix = redirectionURL - ? `?rd=${encodeURIComponent(redirectionURL)}${requestMethod ? `&rm=${requestMethod}` : ""}` - : ""; + const search = redirectionURL + ? `?rd=${encodeURIComponent(redirectionURL)}${requestMethod ? `&rm=${requestMethod}` : ""}${ + workflow ? `&workflow=${workflow}` : "" + }` + : undefined; if (state.authentication_level === AuthenticationLevel.Unauthenticated) { setFirstFactorDisabled(false); - redirect(`${IndexRoute}${redirectionSuffix}`); + redirect(IndexRoute, search); } else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) { if (configuration.available_methods.size === 0) { redirect(AuthenticatedRoute); } else { if (userInfo.method === SecondFactorMethod.Webauthn) { - redirect(`${SecondFactorRoute}${SecondFactorWebauthnSubRoute}${redirectionSuffix}`); + redirect(`${SecondFactorRoute}${SecondFactorWebauthnSubRoute}`, search); } else if (userInfo.method === SecondFactorMethod.MobilePush) { - redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}${redirectionSuffix}`); + redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`, search); } else { - redirect(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}${redirectionSuffix}`); + redirect(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`, search); } } } @@ -144,6 +157,7 @@ const LoginPortal = function (props: Props) { state, redirectionURL, requestMethod, + workflow, redirect, userInfo, setFirstFactorDisabled, diff --git a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx index 40c5c8314..8f9309603 100644 --- a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration"; +import { useWorkflow } from "@hooks/Workflow"; import { completeTOTPSignIn } from "@services/OneTimePassword"; import { AuthenticationLevel } from "@services/State"; import LoadingPage from "@views/LoadingPage/LoadingPage"; @@ -33,6 +34,7 @@ const OneTimePasswordMethod = function (props: Props) { props.authenticationLevel === AuthenticationLevel.TwoFactor ? State.Success : State.Idle, ); const redirectionURL = useRedirectionURL(); + const workflow = useWorkflow(); const { t: translate } = useTranslation(); const { onSignInSuccess, onSignInError } = props; @@ -67,7 +69,7 @@ const OneTimePasswordMethod = function (props: Props) { try { setState(State.InProgress); - const res = await completeTOTPSignIn(passcodeStr, redirectionURL); + const res = await completeTOTPSignIn(passcodeStr, redirectionURL, workflow); setState(State.Success); onSignInSuccessCallback(res ? res.redirect : undefined); } catch (err) { @@ -81,6 +83,7 @@ const OneTimePasswordMethod = function (props: Props) { onSignInSuccessCallback, passcode, redirectionURL, + workflow, resp, props.authenticationLevel, props.registered, diff --git a/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx b/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx index 6a080348a..1efd9a087 100644 --- a/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx @@ -8,6 +8,7 @@ import PushNotificationIcon from "@components/PushNotificationIcon"; import SuccessIcon from "@components/SuccessIcon"; import { useIsMountedRef } from "@hooks/Mounted"; import { useRedirectionURL } from "@hooks/RedirectionURL"; +import { useWorkflow } from "@hooks/Workflow"; import { completePushNotificationSignIn, completeDuoDeviceSelectionProcess, @@ -44,6 +45,7 @@ const PushNotificationMethod = function (props: Props) { const styles = useStyles(); const [state, setState] = useState(State.SignInInProgress); const redirectionURL = useRedirectionURL(); + const workflow = useWorkflow(); const mounted = useIsMountedRef(); const [enroll_url, setEnrollUrl] = useState(""); const [devices, setDevices] = useState([] as SelectableDevice[]); @@ -93,7 +95,7 @@ const PushNotificationMethod = function (props: Props) { try { setState(State.SignInInProgress); - const res = await completePushNotificationSignIn(redirectionURL); + const res = await completePushNotificationSignIn(redirectionURL, workflow); // If the request was initiated and the user changed 2FA method in the meantime, // the process is interrupted to avoid updating state of unmounted component. if (!mounted.current) return; @@ -136,6 +138,7 @@ const PushNotificationMethod = function (props: Props) { props.authenticationLevel, props.duoSelfEnrollment, redirectionURL, + workflow, mounted, onSignInErrorCallback, onSignInSuccessCallback,