From 0116506330822f0dac159004aedc056884e7ceed Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 1 Apr 2022 22:18:58 +1100 Subject: [PATCH] feat(oidc): implement amr claim (#2969) This adds the amr claim which stores methods used to authenticate with Authelia by the users session. --- docs/configuration/identity-providers/oidc.md | 50 +++-- .../handlers/handler_oidc_authorization.go | 20 +- internal/handlers/handler_oidc_consent.go | 4 +- internal/handlers/handler_sign_duo.go | 2 +- internal/handlers/handler_sign_totp.go | 2 +- internal/handlers/handler_sign_webauthn.go | 5 +- internal/handlers/oidc.go | 3 +- internal/handlers/oidc_test.go | 5 +- internal/handlers/response.go | 3 +- internal/model/oidc.go | 14 ++ internal/oidc/amr.go | 81 ++++++++ internal/oidc/amr_test.go | 189 ++++++++++++++++++ internal/oidc/client.go | 6 +- internal/oidc/client_test.go | 4 +- internal/oidc/const.go | 94 +++++++++ internal/oidc/store.go | 1 + internal/oidc/types.go | 4 +- internal/oidc/types_test.go | 24 ++- internal/session/provider_test.go | 168 +++++++++++++++- internal/session/types.go | 20 +- internal/session/user_session.go | 28 ++- .../compose/oidc-client/docker-compose.yml | 2 +- .../example/compose/oidc-client/entrypoint.sh | 2 +- 23 files changed, 663 insertions(+), 68 deletions(-) create mode 100644 internal/model/oidc.go create mode 100644 internal/oidc/amr.go create mode 100644 internal/oidc/amr_test.go diff --git a/docs/configuration/identity-providers/oidc.md b/docs/configuration/identity-providers/oidc.md index 08da3aceb..1c1dc0278 100644 --- a/docs/configuration/identity-providers/oidc.md +++ b/docs/configuration/identity-providers/oidc.md @@ -421,19 +421,20 @@ does. _**Important Note:** The claim `sub` is planned to be changed in the future to a randomly unique value to identify the individual user. Please use the claim `preferred_username` instead._ -| Claim | JWT Type | Authelia Attribute | Description | -|:------------------:|:-------------:|:------------------:|:---------------------------------------------:| -| sub | string | username | The username the user used to login with | -| scope | string | scopes | Granted scopes (space delimited) | -| scp | array[string] | scopes | Granted scopes | -| iss | string | hostname | The issuer name, determined by URL | -| at_hash | string | _N/A_ | Access Token Hash | -| aud | array[string] | _N/A_ | Audience | -| exp | number | _N/A_ | Expires | -| auth_time | number | _N/A_ | The time the user authenticated with Authelia | -| rat | number | _N/A_ | The time when the token was requested | -| iat | number | _N/A_ | The time when the token was issued | -| jti | string(uuid) | _N/A_ | JWT Identifier | +| Claim | JWT Type | Authelia Attribute | Description | +|:---------:|:-------------:|:------------------:|:-----------------------------------------------------------:| +| sub | string | username | A unique value linked to the user who logged in | +| scope | string | scopes | Granted scopes (space delimited) | +| scp | array[string] | scopes | Granted scopes | +| iss | string | hostname | The issuer name, determined by URL | +| at_hash | string | _N/A_ | Access Token Hash | +| aud | array[string] | _N/A_ | Audience | +| exp | number | _N/A_ | Expires | +| auth_time | number | _N/A_ | The time the user authenticated with Authelia | +| rat | number | _N/A_ | The time when the token was requested | +| iat | number | _N/A_ | The time when the token was issued | +| jti | string(uuid) | _N/A_ | JWT Identifier | +| amr | array[string] | _N/A_ | An [RFC8176] list of authentication method reference values | ### groups @@ -462,6 +463,28 @@ This scope includes the profile information the authentication backend reports a | preferred_username | string | username | The username the user used to login with | | name | string | display_name | The users display name | +## Authentication Method References + +Authelia currently supports adding the `amr` claim to the [ID Token](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) +utilizing the [RFC8176] Authentication Method Reference values. + +The values this claim has are not strictly defined by the [OpenID Connect] specification. As such, some backends may +expect a specification other than [RFC8176] for this purpose. If you have such an application and wish for us to support +it then you're encouraged to create an issue. + +Below is a list of the potential values we place in the claim and their meaning: + +| Value | Description | Factor | Channel | +|:-----:|:----------------------------------------------------------------:|:------:|:--------:| +| mfa | User used multiple factors to login (see factor column) | N/A | N/A | +| mca | User used multiple channels to login (see channel column) | N/A | N/A | +| user | User confirmed they were present when using their hardware key | N/A | N/A | +| pin | User confirmed they are the owner of the hardware key with a pin | N/A | N/A | +| pwd | User used a username and password to login | Know | Browser | +| otp | User used TOTP to login | Have | Browser | +| hwk | User used a hardware key to login | Have | Browser | +| sms | User used Duo to login | Have | External | + ## Endpoint Implementations This is a table of the endpoints we currently support and their paths. This can be requrired information for some RP's, @@ -482,3 +505,4 @@ Authelia via https://auth.example.com, the discovery URL is https://auth.example [OpenID Connect]: https://openid.net/connect/ [token lifespan]: https://docs.apigee.com/api-platform/antipatterns/oauth-long-expiration +[RFC8176]: https://datatracker.ietf.org/doc/html/rfc8176 \ No newline at end of file diff --git a/internal/handlers/handler_oidc_authorization.go b/internal/handlers/handler_oidc_authorization.go index dfaa3ffb3..88f407568 100644 --- a/internal/handlers/handler_oidc_authorization.go +++ b/internal/handlers/handler_oidc_authorization.go @@ -9,7 +9,9 @@ import ( "github.com/ory/fosite" + "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/session" ) @@ -97,7 +99,7 @@ func oidcAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWriter, r * subject := userSession.Username oidcSession := oidc.NewSessionWithAuthorizeRequest(issuer, ctx.Providers.OpenIDConnect.KeyManager.GetActiveKeyID(), - subject, userSession.Username, extraClaims, authTime, workflowCreated, requester) + subject, userSession.Username, userSession.AuthenticationMethodRefs.MarshalRFC8176(), extraClaims, authTime, workflowCreated, requester) ctx.Logger.Tracef("Authorization Request with id '%s' on client with id '%s' creating session for Authorization Response for subject '%s' with username '%s' with claims: %+v", requester.GetID(), oidcSession.ClientID, oidcSession.Subject, oidcSession.Username, oidcSession.Claims) @@ -126,14 +128,14 @@ func oidcAuthorizeHandleAuthorizationOrConsentInsufficient( ctx.Logger.Debugf("Authorization Request with id '%s' on client with id '%s' requires user '%s' provides consent for scopes '%s'", requester.GetID(), client.GetID(), userSession.Username, strings.Join(requester.GetRequestedScopes(), "', '")) - userSession.OIDCWorkflowSession = &session.OIDCWorkflowSession{ - ClientID: client.GetID(), - RequestedScopes: requester.GetRequestedScopes(), - RequestedAudience: requester.GetRequestedAudience(), - AuthURI: redirectURL, - TargetURI: requester.GetRedirectURI().String(), - RequiredAuthorizationLevel: client.Policy, - CreatedTimestamp: time.Now().Unix(), + userSession.OIDCWorkflowSession = &model.OIDCWorkflowSession{ + ClientID: client.GetID(), + RequestedScopes: requester.GetRequestedScopes(), + RequestedAudience: requester.GetRequestedAudience(), + AuthURI: redirectURL, + TargetURI: requester.GetRedirectURI().String(), + Require2FA: client.Policy == authorization.TwoFactor, + CreatedTimestamp: time.Now().Unix(), } if err := ctx.SaveSession(userSession); err != nil { diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index 2cf604c90..f07b52e47 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -28,7 +28,7 @@ func oidcConsent(ctx *middlewares.AutheliaCtx) { } if !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) { - ctx.Logger.Debugf("Insufficient permissions to give consent v2 %d -> %d", userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.RequiredAuthorizationLevel) + ctx.Logger.Debugf("Insufficient permissions to give consent during GET current level: %d, require 2FA: %t", userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.Require2FA) ctx.ReplyForbidden() return @@ -59,7 +59,7 @@ func oidcConsentPOST(ctx *middlewares.AutheliaCtx) { } if !client.IsAuthenticationLevelSufficient(userSession.AuthenticationLevel) { - ctx.Logger.Debugf("Insufficient permissions to give consent v1 %d -> %d", userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.RequiredAuthorizationLevel) + ctx.Logger.Debugf("Insufficient permissions to give consent during POST current level: %d, require 2FA: %t", userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.Require2FA) ctx.ReplyForbidden() return diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go index 52a9b5f2e..48aebbc6b 100644 --- a/internal/handlers/handler_sign_duo.go +++ b/internal/handlers/handler_sign_duo.go @@ -255,7 +255,7 @@ func HandleAllow(ctx *middlewares.AutheliaCtx, targetURL string) { return } - userSession.SetTwoFactor(ctx.Clock.Now()) + userSession.SetTwoFactorDuo(ctx.Clock.Now()) err = ctx.SaveSession(userSession) if err != nil { diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index e9b59f167..f21c2d21f 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -68,7 +68,7 @@ func SecondFactorTOTPPost(ctx *middlewares.AutheliaCtx) { return } - userSession.SetTwoFactor(ctx.Clock.Now()) + userSession.SetTwoFactorTOTP(ctx.Clock.Now()) if err = ctx.SaveSession(userSession); err != nil { ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeTOTP, userSession.Username, err) diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go index 0e821b697..53a8ba9c6 100644 --- a/internal/handlers/handler_sign_webauthn.go +++ b/internal/handlers/handler_sign_webauthn.go @@ -185,8 +185,9 @@ func SecondFactorWebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { return } - userSession.SetTwoFactor(ctx.Clock.Now()) - userSession.Webauthn = nil + userSession.SetTwoFactorWebauthn(ctx.Clock.Now(), + assertionResponse.Response.AuthenticatorData.Flags.UserPresent(), + assertionResponse.Response.AuthenticatorData.Flags.UserVerified()) if err = ctx.SaveSession(userSession); err != nil { ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the assertion challenge and authentication time", regulation.AuthTypeWebauthn, userSession.Username, err) diff --git a/internal/handlers/oidc.go b/internal/handlers/oidc.go index 6e4614081..3244ed047 100644 --- a/internal/handlers/oidc.go +++ b/internal/handlers/oidc.go @@ -3,6 +3,7 @@ package handlers import ( "github.com/ory/fosite" + "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/utils" @@ -10,7 +11,7 @@ import ( // isConsentMissing compares the requestedScopes and requestedAudience to the workflows // GrantedScopes and GrantedAudience and returns true if they do not match or the workflow is nil. -func isConsentMissing(workflow *session.OIDCWorkflowSession, requestedScopes, requestedAudience []string) (isMissing bool) { +func isConsentMissing(workflow *model.OIDCWorkflowSession, requestedScopes, requestedAudience []string) (isMissing bool) { if workflow == nil { return true } diff --git a/internal/handlers/oidc_test.go b/internal/handlers/oidc_test.go index 6ff9947f3..8a5b21b00 100644 --- a/internal/handlers/oidc_test.go +++ b/internal/handlers/oidc_test.go @@ -6,19 +6,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" "github.com/authelia/authelia/v4/internal/session" ) func TestShouldDetectIfConsentIsMissing(t *testing.T) { - var workflow *session.OIDCWorkflowSession + var workflow *model.OIDCWorkflowSession requestedScopes := []string{"openid", "profile"} requestedAudience := []string{"https://authelia.com"} assert.True(t, isConsentMissing(workflow, requestedScopes, requestedAudience)) - workflow = &session.OIDCWorkflowSession{ + workflow = &model.OIDCWorkflowSession{ GrantedScopes: []string{"openid", "profile"}, GrantedAudience: []string{"https://authelia.com"}, } diff --git a/internal/handlers/response.go b/internal/handlers/response.go index 60674de0b..e79207d05 100644 --- a/internal/handlers/response.go +++ b/internal/handlers/response.go @@ -7,6 +7,7 @@ import ( "github.com/valyala/fasthttp" + "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/utils" @@ -16,7 +17,7 @@ import ( func handleOIDCWorkflowResponse(ctx *middlewares.AutheliaCtx) { userSession := ctx.GetSession() - if !authorization.IsAuthLevelSufficient(userSession.AuthenticationLevel, userSession.OIDCWorkflowSession.RequiredAuthorizationLevel) { + if userSession.OIDCWorkflowSession.Require2FA && userSession.AuthenticationLevel != authentication.TwoFactor { ctx.Logger.Warnf("OpenID Connect client '%s' requires 2FA, cannot be redirected yet", userSession.OIDCWorkflowSession.ClientID) ctx.ReplyOK() diff --git a/internal/model/oidc.go b/internal/model/oidc.go new file mode 100644 index 000000000..d599d9f75 --- /dev/null +++ b/internal/model/oidc.go @@ -0,0 +1,14 @@ +package model + +// OIDCWorkflowSession represent an OIDC workflow session. +type OIDCWorkflowSession struct { + ClientID string + RequestedScopes []string + GrantedScopes []string + RequestedAudience []string + GrantedAudience []string + TargetURI string + AuthURI string + Require2FA bool + CreatedTimestamp int64 +} diff --git a/internal/oidc/amr.go b/internal/oidc/amr.go new file mode 100644 index 000000000..4d1338886 --- /dev/null +++ b/internal/oidc/amr.go @@ -0,0 +1,81 @@ +package oidc + +// AuthenticationMethodsReferences holds AMR information. +type AuthenticationMethodsReferences struct { + UsernameAndPassword bool + TOTP bool + Duo bool + Webauthn bool + WebauthnUserPresence bool + WebauthnUserVerified bool +} + +// FactorKnowledge returns true if a "something you know" factor of authentication was used. +func (r AuthenticationMethodsReferences) FactorKnowledge() bool { + return r.UsernameAndPassword +} + +// FactorPossession returns true if a "something you have" factor of authentication was used. +func (r AuthenticationMethodsReferences) FactorPossession() bool { + return r.TOTP || r.Webauthn || r.Duo +} + +// MultiFactorAuthentication returns true if multiple factors were used. +func (r AuthenticationMethodsReferences) MultiFactorAuthentication() bool { + return r.FactorKnowledge() && r.FactorPossession() +} + +// ChannelBrowser returns true if a browser was used to authenticate. +func (r AuthenticationMethodsReferences) ChannelBrowser() bool { + return r.UsernameAndPassword || r.TOTP || r.Webauthn +} + +// ChannelService returns true if a non-browser service was used to authenticate. +func (r AuthenticationMethodsReferences) ChannelService() bool { + return r.Duo +} + +// MultiChannelAuthentication returns true if the user used more than one channel to authenticate. +func (r AuthenticationMethodsReferences) MultiChannelAuthentication() bool { + return r.ChannelBrowser() && r.ChannelService() +} + +// MarshalRFC8176 returns the AMR claim slice of strings in the RFC8176 format. +// https://datatracker.ietf.org/doc/html/rfc8176 +func (r AuthenticationMethodsReferences) MarshalRFC8176() []string { + var amr []string + + if r.UsernameAndPassword { + amr = append(amr, AMRPasswordBasedAuthentication) + } + + if r.TOTP { + amr = append(amr, AMROneTimePassword) + } + + if r.Duo { + amr = append(amr, AMRShortMessageService) + } + + if r.Webauthn { + amr = append(amr, AMRHardwareSecuredKey) + } + + if r.WebauthnUserPresence { + amr = append(amr, AMRUserPresence) + } + + if r.WebauthnUserVerified { + amr = append(amr, AMRPersonalIdentificationNumber) + } + + if r.MultiFactorAuthentication() { + amr = append(amr, AMRMultiFactorAuthentication) + } + + if r.MultiChannelAuthentication() { + amr = append(amr, AMRMultiChannelAuthentication) + } + + return amr +} diff --git a/internal/oidc/amr_test.go b/internal/oidc/amr_test.go new file mode 100644 index 000000000..c71ca6a26 --- /dev/null +++ b/internal/oidc/amr_test.go @@ -0,0 +1,189 @@ +package oidc + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testAMRWant struct { + FactorKnowledge, FactorPossession, MultiFactorAuthentication bool + ChannelBrowser, ChannelService, MultiChannelAuthentication bool + + RFC8176 []string +} + +func TestAuthenticationMethodsReferences(t *testing.T) { + testCases := []struct { + desc string + is AuthenticationMethodsReferences + want testAMRWant + }{ + { + desc: "Username and Password", + + is: AuthenticationMethodsReferences{UsernameAndPassword: true}, + want: testAMRWant{ + FactorKnowledge: true, + FactorPossession: false, + MultiFactorAuthentication: false, + ChannelBrowser: true, + ChannelService: false, + MultiChannelAuthentication: false, + RFC8176: []string{"pwd"}, + }, + }, + { + desc: "TOTP", + + is: AuthenticationMethodsReferences{TOTP: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: true, + MultiFactorAuthentication: false, + ChannelBrowser: true, + ChannelService: false, + MultiChannelAuthentication: false, + RFC8176: []string{"otp"}, + }, + }, + { + desc: "Webauthn", + + is: AuthenticationMethodsReferences{Webauthn: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: true, + MultiFactorAuthentication: false, + ChannelBrowser: true, + ChannelService: false, + MultiChannelAuthentication: false, + RFC8176: []string{"hwk"}, + }, + }, + { + desc: "Webauthn User Presence", + + is: AuthenticationMethodsReferences{WebauthnUserPresence: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: false, + MultiFactorAuthentication: false, + ChannelBrowser: false, + ChannelService: false, + MultiChannelAuthentication: false, + RFC8176: []string{"user"}, + }, + }, + { + desc: "Webauthn User Verified", + + is: AuthenticationMethodsReferences{WebauthnUserVerified: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: false, + MultiFactorAuthentication: false, + ChannelBrowser: false, + ChannelService: false, + MultiChannelAuthentication: false, + RFC8176: []string{"pin"}, + }, + }, + { + desc: "Webauthn with User Presence and Verified", + + is: AuthenticationMethodsReferences{Webauthn: true, WebauthnUserVerified: true, WebauthnUserPresence: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: true, + MultiFactorAuthentication: false, + ChannelBrowser: true, + ChannelService: false, + MultiChannelAuthentication: false, + RFC8176: []string{"hwk", "user", "pin"}, + }, + }, + { + desc: "Duo", + + is: AuthenticationMethodsReferences{Duo: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: true, + MultiFactorAuthentication: false, + ChannelBrowser: false, + ChannelService: true, + MultiChannelAuthentication: false, + RFC8176: []string{"sms"}, + }, + }, + { + desc: "Duo Webauthn TOTP", + + is: AuthenticationMethodsReferences{Duo: true, Webauthn: true, TOTP: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: true, + MultiFactorAuthentication: false, + ChannelBrowser: true, + ChannelService: true, + MultiChannelAuthentication: true, + RFC8176: []string{"sms", "hwk", "otp", "mca"}, + }, + }, + { + desc: "Duo TOTP", + + is: AuthenticationMethodsReferences{Duo: true, TOTP: true}, + want: testAMRWant{ + FactorKnowledge: false, + FactorPossession: true, + MultiFactorAuthentication: false, + ChannelBrowser: true, + ChannelService: true, + MultiChannelAuthentication: true, + RFC8176: []string{"sms", "otp", "mca"}, + }, + }, + { + desc: "Username and Password with Duo", + + is: AuthenticationMethodsReferences{Duo: true, UsernameAndPassword: true}, + want: testAMRWant{ + FactorKnowledge: true, + FactorPossession: true, + MultiFactorAuthentication: true, + ChannelBrowser: true, + ChannelService: true, + MultiChannelAuthentication: true, + RFC8176: []string{"pwd", "sms", "mfa", "mca"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + assert.Equal(t, tc.want.FactorKnowledge, tc.is.FactorKnowledge()) + assert.Equal(t, tc.want.FactorPossession, tc.is.FactorPossession()) + assert.Equal(t, tc.want.MultiFactorAuthentication, tc.is.MultiFactorAuthentication()) + assert.Equal(t, tc.want.ChannelBrowser, tc.is.ChannelBrowser()) + assert.Equal(t, tc.want.ChannelService, tc.is.ChannelService()) + assert.Equal(t, tc.want.MultiChannelAuthentication, tc.is.MultiChannelAuthentication()) + + isRFC8176 := tc.is.MarshalRFC8176() + + for _, amr := range tc.want.RFC8176 { + t.Run(fmt.Sprintf("has all wanted/%s", amr), func(t *testing.T) { + assert.Contains(t, isRFC8176, amr) + }) + } + + for _, amr := range isRFC8176 { + t.Run(fmt.Sprintf("only has wanted/%s", amr), func(t *testing.T) { + assert.Contains(t, tc.want.RFC8176, amr) + }) + } + }) + } +} diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 4f5818bd0..22f8dc98a 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -6,7 +6,7 @@ import ( "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" - "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/model" ) // NewClient creates a new InternalClient. @@ -46,8 +46,8 @@ func (c InternalClient) GetID() string { return c.ID } -// GetConsentResponseBody returns the proper consent response body for this session.OIDCWorkflowSession. -func (c InternalClient) GetConsentResponseBody(session *session.OIDCWorkflowSession) ConsentGetResponseBody { +// GetConsentResponseBody returns the proper consent response body for this model.OIDCWorkflowSession. +func (c InternalClient) GetConsentResponseBody(session *model.OIDCWorkflowSession) ConsentGetResponseBody { body := ConsentGetResponseBody{ ClientID: c.ID, ClientDescription: c.Description, diff --git a/internal/oidc/client_test.go b/internal/oidc/client_test.go index 52b2873f3..fdad40f3e 100644 --- a/internal/oidc/client_test.go +++ b/internal/oidc/client_test.go @@ -10,7 +10,7 @@ import ( "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" - "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/model" ) func TestNewClient(t *testing.T) { @@ -79,7 +79,7 @@ func TestInternalClient_GetConsentResponseBody(t *testing.T) { c.ID = "myclient" c.Description = "My Client" - workflow := &session.OIDCWorkflowSession{ + workflow := &model.OIDCWorkflowSession{ RequestedAudience: []string{"https://example.com"}, RequestedScopes: []string{"openid", "groups"}, } diff --git a/internal/oidc/const.go b/internal/oidc/const.go index db43595ff..2d128aec5 100644 --- a/internal/oidc/const.go +++ b/internal/oidc/const.go @@ -31,3 +31,97 @@ const ( RevocationPath = "/api/oidc/revocation" UserinfoPath = "/api/oidc/userinfo" ) + +// Authentication Method Reference Values https://datatracker.ietf.org/doc/html/rfc8176 +const ( + // AMRMultiFactorAuthentication is an RFC8176 Authentication Method Reference Value that represents multiple-factor + // authentication as per NIST.800-63-2 and ISO29115. When this is present, specific authentication methods used may + // also be included. + // + // Authelia utilizes this when a user has performed any 2 AMR's with different factor values (excluding meta). + // Factor: Meta, Channel: Meta. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + // + // NIST.800-63-2: http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-2.pdf + // + // ISO29115: https://www.iso.org/standard/45138.html + AMRMultiFactorAuthentication = "mfa" + + // AMRMultiChannelAuthentication is an RFC8176 Authentication Method Reference Value that represents + // multiple-channel authentication. The authentication involves communication over more than one distinct + // communication channel. For instance, a multiple-channel authentication might involve both entering information + // into a workstation's browser and providing information on a telephone call to a pre-registered number. + // + // Authelia utilizes this when a user has performed any 2 AMR's with different channel values (excluding meta). + // Factor: Meta, Channel: Meta. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + AMRMultiChannelAuthentication = "mca" + + // AMRUserPresence is an RFC8176 Authentication Method Reference Value that represents authentication that included + // a user presence test. Evidence that the end user is present and interacting with the device. This is sometimes + // also referred to as "test of user presence" as per W3C.WD-webauthn-20170216. + // + // Authelia utilizes this when a user has used Webauthn to authenticate and the user presence flag was set. + // Factor: Meta, Channel: Meta. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + // + // W3C.WD-webauthn-20170216: https://datatracker.ietf.org/doc/html/rfc8176#ref-W3C.WD-webauthn-20170216 + AMRUserPresence = "user" + + // AMRPersonalIdentificationNumber is an RFC8176 Authentication Method Reference Value that represents + // authentication that included a personal Identification Number (PIN) as per RFC4949 or pattern (not restricted to + // containing only numbers) that a user enters to unlock a key on the device. This mechanism should have a way to + // deter an attacker from obtaining the PIN by trying repeated guesses. + // + // Authelia utilizes this when a user has used Webauthn to authenticate and the user verified flag was set. + // Factor: Meta, Channel: Meta. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + // + // RFC4949: https://datatracker.ietf.org/doc/html/rfc4949 + AMRPersonalIdentificationNumber = "pin" + + // AMRPasswordBasedAuthentication is an RFC8176 Authentication Method Reference Value that represents password-based + // authentication as per RFC4949. + // + // Authelia utilizes this when a user has performed 1FA. Factor: Know, Channel: Browser. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + // + // RFC4949: https://datatracker.ietf.org/doc/html/rfc4949 + AMRPasswordBasedAuthentication = "pwd" + + // AMROneTimePassword is an RFC8176 Authentication Method Reference Value that represents authentication via a + // one-time password as per RFC4949. One-time password specifications that this authentication method applies to + // include RFC4226 and RFC6238. + // + // Authelia utilizes this when a user has used TOTP to authenticate. Factor: Have, Channel: Browser. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + // + // RFC4949: https://datatracker.ietf.org/doc/html/rfc4949 + // + // RFC4226: https://datatracker.ietf.org/doc/html/rfc4226 + // + // RFC6238: https://datatracker.ietf.org/doc/html/rfc6238 + AMROneTimePassword = "otp" + + // AMRHardwareSecuredKey is an RFC8176 Authentication Method Reference Value that + // represents authentication via a proof-of-Possession (PoP) of a hardware-secured key. + // + // Authelia utilizes this when a user has used Webauthn to authenticate. Factor: Have, Channel: Browser. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + AMRHardwareSecuredKey = "hwk" + + // AMRShortMessageService is an RFC8176 Authentication Method Reference Value that + // represents authentication via confirmation using SMS text message to the user at a registered number. + // + // Authelia utilizes this when a user has used Duo to authenticate. Factor: Have, Channel: Browser. + // + // RFC8176: https://datatracker.ietf.org/doc/html/rfc8176 + AMRShortMessageService = "sms" +) diff --git a/internal/oidc/store.go b/internal/oidc/store.go index 8be5f7630..0b9d9a867 100644 --- a/internal/oidc/store.go +++ b/internal/oidc/store.go @@ -25,6 +25,7 @@ func NewOpenIDConnectStore(configuration *schema.OpenIDConnectConfiguration) (st AccessTokens: map[string]fosite.Requester{}, RefreshTokens: map[string]storage.StoreRefreshToken{}, PKCES: map[string]fosite.Requester{}, + BlacklistedJTIs: map[string]time.Time{}, AccessTokenRequestIDs: map[string]string{}, RefreshTokenRequestIDs: map[string]string{}, }, diff --git a/internal/oidc/types.go b/internal/oidc/types.go index ab97c53e0..9129a38af 100644 --- a/internal/oidc/types.go +++ b/internal/oidc/types.go @@ -30,7 +30,7 @@ func NewSession() (session *OpenIDSession) { } // NewSessionWithAuthorizeRequest uses details from an AuthorizeRequester to generate an OpenIDSession. -func NewSessionWithAuthorizeRequest(issuer, kid, subject, username string, extra map[string]interface{}, +func NewSessionWithAuthorizeRequest(issuer, kid, subject, username string, amr []string, extra map[string]interface{}, authTime, requestedAt time.Time, requester fosite.AuthorizeRequester) (session *OpenIDSession) { if extra == nil { extra = make(map[string]interface{}) @@ -47,6 +47,8 @@ func NewSessionWithAuthorizeRequest(issuer, kid, subject, username string, extra Nonce: requester.GetRequestForm().Get("nonce"), Audience: requester.GetGrantedAudience(), Extra: extra, + + AuthenticationMethodsReferences: amr, }, Headers: &jwt.Headers{ Extra: map[string]interface{}{ diff --git a/internal/oidc/types_test.go b/internal/oidc/types_test.go index e69d570a7..f8d31dac9 100644 --- a/internal/oidc/types_test.go +++ b/internal/oidc/types_test.go @@ -49,8 +49,9 @@ func TestNewSessionWithAuthorizeRequest(t *testing.T) { requested := time.Unix(1647332518, 0) authAt := time.Unix(1647332500, 0) issuer := "https://example.com" + amr := []string{AMRPasswordBasedAuthentication} - session := NewSessionWithAuthorizeRequest(issuer, "primary", subject.String(), "john", extra, authAt, requested, request) + session := NewSessionWithAuthorizeRequest(issuer, "primary", subject.String(), "john", amr, extra, authAt, requested, request) require.NotNil(t, session) require.NotNil(t, session.Extra) @@ -58,24 +59,29 @@ func TestNewSessionWithAuthorizeRequest(t *testing.T) { require.NotNil(t, session.Headers.Extra) require.NotNil(t, session.Claims) require.NotNil(t, session.Claims.Extra) + require.NotNil(t, session.Claims.AuthenticationMethodsReferences) - assert.Equal(t, "abc123xyzauthelia", session.Claims.Nonce) - assert.Equal(t, subject.String(), session.Claims.Subject) assert.Equal(t, subject.String(), session.Subject) - assert.Equal(t, issuer, session.Claims.Issuer) - assert.Equal(t, "primary", session.Headers.Get("kid")) assert.Equal(t, "example", session.ClientID) - assert.Equal(t, requested, session.Claims.RequestedAt) - assert.Equal(t, authAt, session.Claims.AuthTime) assert.Greater(t, session.Claims.IssuedAt.Unix(), authAt.Unix()) assert.Equal(t, "john", session.Username) - require.Contains(t, session.Claims.Extra, "preferred_username") + assert.Equal(t, "abc123xyzauthelia", session.Claims.Nonce) + assert.Equal(t, subject.String(), session.Claims.Subject) + assert.Equal(t, amr, session.Claims.AuthenticationMethodsReferences) + assert.Equal(t, authAt, session.Claims.AuthTime) + assert.Equal(t, requested, session.Claims.RequestedAt) + assert.Equal(t, issuer, session.Claims.Issuer) assert.Equal(t, "john", session.Claims.Extra["preferred_username"]) - session = NewSessionWithAuthorizeRequest(issuer, "primary", subject.String(), "john", nil, authAt, requested, request) + assert.Equal(t, "primary", session.Headers.Get("kid")) + + require.Contains(t, session.Claims.Extra, "preferred_username") + + session = NewSessionWithAuthorizeRequest(issuer, "primary", subject.String(), "john", nil, nil, authAt, requested, request) require.NotNil(t, session) require.NotNil(t, session.Claims) assert.NotNil(t, session.Claims.Extra) + assert.Nil(t, session.Claims.AuthenticationMethodsReferences) } diff --git a/internal/session/provider_test.go b/internal/session/provider_test.go index e113636e4..5deb312d9 100644 --- a/internal/session/provider_test.go +++ b/internal/session/provider_test.go @@ -11,6 +11,7 @@ import ( "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/oidc" ) func TestShouldInitializerSession(t *testing.T) { @@ -93,9 +94,10 @@ func TestShouldSetSessionAuthenticationLevels(t *testing.T) { AuthenticationLevel: authentication.OneFactor, LastActivity: timeOneFactor.Unix(), FirstFactorAuthnTimestamp: timeOneFactor.Unix(), + AuthenticationMethodRefs: oidc.AuthenticationMethodsReferences{UsernameAndPassword: true}, }, session) - session.SetTwoFactor(timeTwoFactor) + session.SetTwoFactorDuo(timeTwoFactor) err = provider.SaveSession(ctx, session) require.NoError(t, err) @@ -109,6 +111,7 @@ func TestShouldSetSessionAuthenticationLevels(t *testing.T) { LastActivity: timeTwoFactor.Unix(), FirstFactorAuthnTimestamp: timeOneFactor.Unix(), SecondFactorAuthnTimestamp: timeTwoFactor.Unix(), + AuthenticationMethodRefs: oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Duo: true}, }, session) authAt, err = session.AuthenticatedTime(authorization.OneFactor) @@ -124,6 +127,169 @@ func TestShouldSetSessionAuthenticationLevels(t *testing.T) { assert.Equal(t, timeZeroFactor, authAt) } +func TestShouldSetSessionAuthenticationLevelsAMR(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + configuration := schema.SessionConfiguration{} + + timeOneFactor := time.Unix(1625048140, 0) + timeTwoFactor := time.Unix(1625048150, 0) + timeZeroFactor := time.Unix(0, 0) + + configuration.Domain = testDomain + configuration.Name = testName + configuration.Expiration = testExpiration + + provider := NewProvider(configuration, nil) + session, _ := provider.GetSession(ctx) + + session.SetOneFactor(timeOneFactor, &authentication.UserDetails{Username: testUsername}, false) + + err := provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + authAt, err := session.AuthenticatedTime(authorization.OneFactor) + assert.NoError(t, err) + assert.Equal(t, timeOneFactor, authAt) + + authAt, err = session.AuthenticatedTime(authorization.TwoFactor) + assert.NoError(t, err) + assert.Equal(t, timeZeroFactor, authAt) + + authAt, err = session.AuthenticatedTime(authorization.Denied) + assert.EqualError(t, err, "invalid authorization level") + assert.Equal(t, timeZeroFactor, authAt) + + assert.Equal(t, UserSession{ + Username: testUsername, + AuthenticationLevel: authentication.OneFactor, + LastActivity: timeOneFactor.Unix(), + FirstFactorAuthnTimestamp: timeOneFactor.Unix(), + AuthenticationMethodRefs: oidc.AuthenticationMethodsReferences{UsernameAndPassword: true}, + }, session) + + session.SetTwoFactorWebauthn(timeTwoFactor, false, false) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true}, session.AuthenticationMethodRefs) + assert.True(t, session.AuthenticationMethodRefs.MultiFactorAuthentication()) + + authAt, err = session.AuthenticatedTime(authorization.OneFactor) + assert.NoError(t, err) + assert.Equal(t, timeOneFactor, authAt) + + authAt, err = session.AuthenticatedTime(authorization.TwoFactor) + assert.NoError(t, err) + assert.Equal(t, timeTwoFactor, authAt) + + authAt, err = session.AuthenticatedTime(authorization.Denied) + assert.EqualError(t, err, "invalid authorization level") + assert.Equal(t, timeZeroFactor, authAt) + + session.SetTwoFactorWebauthn(timeTwoFactor, false, false) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorWebauthn(timeTwoFactor, false, false) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorWebauthn(timeTwoFactor, true, false) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true, WebauthnUserPresence: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorWebauthn(timeTwoFactor, true, false) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true, WebauthnUserPresence: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorWebauthn(timeTwoFactor, false, true) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true, WebauthnUserVerified: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorWebauthn(timeTwoFactor, false, true) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, Webauthn: true, WebauthnUserVerified: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorTOTP(timeTwoFactor) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, TOTP: true, Webauthn: true, WebauthnUserVerified: true}, + session.AuthenticationMethodRefs) + + session.SetTwoFactorTOTP(timeTwoFactor) + + err = provider.SaveSession(ctx, session) + require.NoError(t, err) + + session, err = provider.GetSession(ctx) + require.NoError(t, err) + + assert.Equal(t, + oidc.AuthenticationMethodsReferences{UsernameAndPassword: true, TOTP: true, Webauthn: true, WebauthnUserVerified: true}, + session.AuthenticationMethodRefs) +} + func TestShouldDestroySessionAndWipeSessionData(t *testing.T) { ctx := &fasthttp.RequestCtx{} configuration := schema.SessionConfiguration{} diff --git a/internal/session/types.go b/internal/session/types.go index c74dcd5cd..842973d3d 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -10,8 +10,9 @@ import ( "github.com/sirupsen/logrus" "github.com/authelia/authelia/v4/internal/authentication" - "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/logging" + "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/oidc" ) // ProviderConfig is the configuration used to create the session provider. @@ -37,11 +38,13 @@ type UserSession struct { FirstFactorAuthnTimestamp int64 SecondFactorAuthnTimestamp int64 + AuthenticationMethodRefs oidc.AuthenticationMethodsReferences + // Webauthn holds the session registration data for this session. Webauthn *webauthn.SessionData // Represent an OIDC workflow session initiated by the client if not null. - OIDCWorkflowSession *OIDCWorkflowSession + OIDCWorkflowSession *model.OIDCWorkflowSession // This boolean is set to true after identity verification and checked // while doing the query actually updating the password. @@ -56,19 +59,6 @@ type Identity struct { Email string } -// OIDCWorkflowSession represent an OIDC workflow session. -type OIDCWorkflowSession struct { - ClientID string - RequestedScopes []string - GrantedScopes []string - RequestedAudience []string - GrantedAudience []string - TargetURI string - AuthURI string - RequiredAuthorizationLevel authorization.Level - CreatedTimestamp int64 -} - func newRedisLogger() *redisLogger { return &redisLogger{logger: logging.Logger()} } diff --git a/internal/session/user_session.go b/internal/session/user_session.go index 96fe27888..24ee85067 100644 --- a/internal/session/user_session.go +++ b/internal/session/user_session.go @@ -17,7 +17,7 @@ func NewDefaultUserSession() UserSession { } } -// SetOneFactor sets the expected property values for one factor authentication. +// SetOneFactor sets the 1FA AMR's and expected property values for one factor authentication. func (s *UserSession) SetOneFactor(now time.Time, details *authentication.UserDetails, keepMeLoggedIn bool) { s.FirstFactorAuthnTimestamp = now.Unix() s.LastActivity = now.Unix() @@ -29,15 +29,37 @@ func (s *UserSession) SetOneFactor(now time.Time, details *authentication.UserDe s.DisplayName = details.DisplayName s.Groups = details.Groups s.Emails = details.Emails + + s.AuthenticationMethodRefs.UsernameAndPassword = true } -// SetTwoFactor sets the expected property values for two factor authentication. -func (s *UserSession) SetTwoFactor(now time.Time) { +func (s *UserSession) setTwoFactor(now time.Time) { s.SecondFactorAuthnTimestamp = now.Unix() s.LastActivity = now.Unix() s.AuthenticationLevel = authentication.TwoFactor } +// SetTwoFactorTOTP sets the relevant TOTP AMR's and sets the factor to 2FA. +func (s *UserSession) SetTwoFactorTOTP(now time.Time) { + s.setTwoFactor(now) + s.AuthenticationMethodRefs.TOTP = true +} + +// SetTwoFactorDuo sets the relevant Duo AMR's and sets the factor to 2FA. +func (s *UserSession) SetTwoFactorDuo(now time.Time) { + s.setTwoFactor(now) + s.AuthenticationMethodRefs.Duo = true +} + +// SetTwoFactorWebauthn sets the relevant Webauthn AMR's and sets the factor to 2FA. +func (s *UserSession) SetTwoFactorWebauthn(now time.Time, userPresence, userVerified bool) { + s.setTwoFactor(now) + s.AuthenticationMethodRefs.Webauthn = true + s.AuthenticationMethodRefs.WebauthnUserPresence, s.AuthenticationMethodRefs.WebauthnUserVerified = userPresence, userVerified + + s.Webauthn = nil +} + // AuthenticatedTime returns the unix timestamp this session authenticated successfully at the given level. func (s UserSession) AuthenticatedTime(level authorization.Level) (authenticatedTime time.Time, err error) { switch level { diff --git a/internal/suites/example/compose/oidc-client/docker-compose.yml b/internal/suites/example/compose/oidc-client/docker-compose.yml index 8d8b70641..40b211a5e 100644 --- a/internal/suites/example/compose/oidc-client/docker-compose.yml +++ b/internal/suites/example/compose/oidc-client/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: oidc-client: - image: ghcr.io/authelia/oidc-tester-app:master-2a82ab3 + image: ghcr.io/authelia/oidc-tester-app:master-89622a8 command: /entrypoint.sh depends_on: - authelia-backend diff --git a/internal/suites/example/compose/oidc-client/entrypoint.sh b/internal/suites/example/compose/oidc-client/entrypoint.sh index cd5a7f5be..6cf8792ed 100755 --- a/internal/suites/example/compose/oidc-client/entrypoint.sh +++ b/internal/suites/example/compose/oidc-client/entrypoint.sh @@ -2,6 +2,6 @@ while true; do - oidc-tester-app --issuer https://login.example.com:8080 --id oidc-tester-app --secret foobar --scopes openid,profile,email --redirect-domain oidc.example.com + oidc-tester-app --issuer https://login.example.com:8080 --id oidc-tester-app --secret foobar --scopes openid,profile,email --public-url https://oidc.example.com:8080 sleep 5 done \ No newline at end of file