Skip to content

Commit

Permalink
feat(session): respect 2fa enforcement in whoami
Browse files Browse the repository at this point in the history
  • Loading branch information
aeneasr committed Oct 19, 2021
1 parent 19a6bcc commit 3a82c88
Show file tree
Hide file tree
Showing 13 changed files with 470 additions and 35 deletions.
10 changes: 10 additions & 0 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const (
ViperKeySessionName = "session.cookie.name"
ViperKeySessionPath = "session.cookie.path"
ViperKeySessionPersistentCookie = "session.cookie.persistent"
ViperKeySessionWhoAmIAAL = "session.whoami.required_aal"
ViperKeyCookieSameSite = "cookies.same_site"
ViperKeyCookieDomain = "cookies.domain"
ViperKeyCookiePath = "cookies.path"
Expand All @@ -111,6 +112,7 @@ const (
ViperKeySelfServiceSettingsAfter = "selfservice.flows.settings.after"
ViperKeySelfServiceSettingsRequestLifespan = "selfservice.flows.settings.lifespan"
ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter = "selfservice.flows.settings.privileged_session_max_age"
ViperKeySelfServiceSettingsRequiredAAL = "selfservice.flows.settings.required_aal"
ViperKeySelfServiceRecoveryAfter = "selfservice.flows.recovery.after"
ViperKeySelfServiceRecoveryEnabled = "selfservice.flows.recovery.enabled"
ViperKeySelfServiceRecoveryUI = "selfservice.flows.recovery.ui_url"
Expand Down Expand Up @@ -941,6 +943,14 @@ func (p *Config) CookieDomain() string {
return p.p.String(ViperKeyCookieDomain)
}

func (p *Config) SessionWhoAmIAAL() string {
return p.p.String(ViperKeySessionWhoAmIAAL)
}

func (p *Config) SelfServiceSettingsRequiredAAL() string {
return p.p.String(ViperKeySelfServiceSettingsRequiredAAL)
}

func (p *Config) CookieSameSiteMode() http.SameSite {
switch p.p.StringF(ViperKeyCookieSameSite, "Lax") {
case "Lax":
Expand Down
24 changes: 24 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,16 @@
}
}
},
"featureRequiredAal": {
"title": "Required Authenticator Assurance Level",
"description": "Sets what Authenticator Assurance Level (used for 2FA) is required to access this feature. If set to `highest_available` then this endpoint requires the highest AAL the identity has set up. If set to `aal1` then the identity can access this feature without 2FA.",
"type": "string",
"enum": [
"aal1",
"highest_available"
],
"default": "highest_available"
},
"selfServiceAfterSettings": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -840,6 +850,9 @@
"1s"
]
},
"required_aal": {
"$ref": "#/definitions/featureRequiredAal"
},
"after": {
"$ref": "#/definitions/selfServiceAfterSettings"
}
Expand Down Expand Up @@ -2007,6 +2020,17 @@
"type": "object",
"additionalProperties": false,
"properties": {
"whoami": {
"title": "WhoAmI / ToSession Settings",
"description": "Control how the `/sessions/whoami` endpoint is behaving.",
"type": "object",
"properties": {
"required_aal": {
"$ref": "#/definitions/featureRequiredAal"
}
},
"additionalProperties": false
},
"lifespan": {
"title": "Session Lifespan",
"description": "Defines how long a session is active. Once that lifespan has been reached, the user needs to sign in again.",
Expand Down
33 changes: 33 additions & 0 deletions identity/aal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package identity

func DetermineAAL(cts []CredentialsType) AuthenticatorAssuranceLevel {
aal := NoAuthenticatorAssuranceLevel

var firstFactor bool
var secondFactor bool
for _, a := range cts {
switch a {
case CredentialsTypeRecoveryLink:
fallthrough
case CredentialsTypeOIDC:
fallthrough
case CredentialsTypePassword:
firstFactor = true
case CredentialsTypeTOTP:
secondFactor = true
case CredentialsTypeLookup:
secondFactor = true
case CredentialsTypeWebAuthn:
secondFactor = true
}
}

if firstFactor && secondFactor {
aal = AuthenticatorAssuranceLevel2
} else if firstFactor {
aal = AuthenticatorAssuranceLevel1
}

// Using only the second factor is not enough for any type of assurance.
return aal
}
110 changes: 110 additions & 0 deletions identity/aal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package identity

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestDetermineAAL(t *testing.T) {

for _, tc := range []struct {
d string
methods []CredentialsType
expected AuthenticatorAssuranceLevel
}{
{
d: "no amr means no assurance",
expected: NoAuthenticatorAssuranceLevel,
},
{
d: "password is aal1",
methods: []CredentialsType{CredentialsTypePassword},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "oidc is aal1",
methods: []CredentialsType{CredentialsTypeOIDC},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "recovery is aal1",
methods: []CredentialsType{CredentialsTypeRecoveryLink},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "mix of password, oidc, recovery is still aal1",
methods: []CredentialsType{
CredentialsTypeRecoveryLink, CredentialsTypeOIDC, CredentialsTypePassword,
},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "just totp is aal0",
methods: []CredentialsType{
CredentialsTypeTOTP,
},
expected: NoAuthenticatorAssuranceLevel,
},
{
d: "password + totp is aal2",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeTOTP,
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "password + lookup is aal2",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeLookup,
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "password + webauthn is aal2",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeWebAuthn,
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "oidc + totp is aal2",
methods: []CredentialsType{
CredentialsTypeOIDC,
CredentialsTypeTOTP,
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "oidc + lookup is aal2",
methods: []CredentialsType{
CredentialsTypeOIDC,
CredentialsTypeLookup,
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + totp is aal2",
methods: []CredentialsType{
CredentialsTypeRecoveryLink,
CredentialsTypeTOTP,
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + lookup is aal2",
methods: []CredentialsType{
CredentialsTypeRecoveryLink,
CredentialsTypeLookup,
},
expected: AuthenticatorAssuranceLevel2,
},
} {
t.Run("case="+tc.d, func(t *testing.T) {
assert.Equal(t, tc.expected, DetermineAAL(tc.methods))
})
}
}
8 changes: 8 additions & 0 deletions internal/testhelpers/handler_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,21 @@ func MockHydrateCookieClient(t *testing.T, c *http.Client, u string) {
}

func MockSessionCreateHandlerWithIdentity(t *testing.T, reg mockDeps, i *identity.Identity) (httprouter.Handle, *session.Session) {
return MockSessionCreateHandlerWithIdentityAndAMR(t, reg, i, []identity.CredentialsType{"password"})
}

func MockSessionCreateHandlerWithIdentityAndAMR(t *testing.T, reg mockDeps, i *identity.Identity, methods []identity.CredentialsType) (httprouter.Handle, *session.Session) {
var sess session.Session
require.NoError(t, faker.FakeData(&sess))
// require AuthenticatedAt to be time.Now() as we always compare it to the current time
sess.AuthenticatedAt = time.Now().UTC()
sess.IssuedAt = time.Now().UTC()
sess.ExpiresAt = time.Now().UTC().Add(time.Hour * 24)
sess.Active = true
for _, method := range methods {
sess.CompletedLoginFor(method)
}
sess.SetAuthenticatorAssuranceLevel()

if reg.Config(context.Background()).Source().String(config.ViperKeyDefaultIdentitySchemaURL) == internal.UnsetDefaultIdentitySchema {
reg.Config(context.Background()).MustSet(config.ViperKeyDefaultIdentitySchemaURL, "file://./stub/fake-session.schema.json")
Expand Down
26 changes: 23 additions & 3 deletions selfservice/flow/settings/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ type initializeSelfServiceSettingsFlowWithoutBrowser struct {
// Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make
// you vulnerable to a variety of CSRF attacks.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor or change the configuration.
//
// This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
Expand Down Expand Up @@ -210,6 +215,11 @@ type initializeSelfServiceSettingsFlowForBrowsers struct {
// If this endpoint is called via an AJAX request, the response contains the settings flow without any redirects
// or a 403 forbidden error if no valid session was set.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor (happens automatically for server-side browser flows) or change the configuration.
//
// This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed.
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
Expand Down Expand Up @@ -277,6 +287,11 @@ type getSelfServiceSettingsFlow struct {
// or the Ory Kratos Session Token are set. The public endpoint does not return 404 status codes
// but instead 403 or 500 to improve data privacy.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor or change the configuration.
//
// You can access this endpoint without credentials when using Ory Kratos' Admin API.
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
Expand Down Expand Up @@ -375,20 +390,25 @@ type submitSelfServiceSettingsFlowBody struct{}
// - HTTP 302 redirect to a fresh settings flow if the original flow expired with the appropriate error messages set;
// - HTTP 400 on form validation errors.
// - HTTP 401 when the endpoint is called without a valid session token.
// - HTTP 403 when `selfservice.flows.settings.privileged_session_max_age` was reached.
// - HTTP 403 when `selfservice.flows.settings.privileged_session_max_age` was reached or the session's AAL is too low.
// Implies that the user needs to re-authenticate.
//
// Browser flows without HTTP Header `Accept` or with `Accept: text/*` respond with
// - a HTTP 302 redirect to the post/after settings URL or the `return_to` value if it was set and if the flow succeeded;
// - a HTTP 302 redirect to the Settings UI URL with the flow ID containing the validation errors otherwise.
// - a HTTP 302 redirect to the login endpoint when `selfservice.flows.settings.privileged_session_max_age` was reached.
// - a HTTP 302 redirect to the login endpoint when `selfservice.flows.settings.privileged_session_max_age` was reached or the session's AAL is too low.
//
// Browser flows with HTTP Header `Accept: application/json` respond with
// - HTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;
// - HTTP 302 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;
// - HTTP 403 when the page is accessed without a session cookie.
// - HTTP 403 when the page is accessed without a session cookie or the session's AAL is too low.
// - HTTP 400 on form validation errors.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor (happens automatically for server-side browser flows) or change the configuration.
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
//
// Consumes:
Expand Down
16 changes: 16 additions & 0 deletions session/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ type toSession struct {
// // console.log(session)
// ```
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor or change the configuration.
//
// This endpoint is useful for:
//
// - AJAX calls. Remember to send credentials and set up CORS correctly!
Expand All @@ -143,6 +148,7 @@ type toSession struct {
// Responses:
// 200: session
// 401: jsonError
// 403: jsonError
// 500: jsonError
func (h *Handler) whoami(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
s, err := h.r.SessionManager().FetchFromRequest(r.Context(), r)
Expand All @@ -152,6 +158,16 @@ func (h *Handler) whoami(w http.ResponseWriter, r *http.Request, ps httprouter.P
return
}

if err := h.r.SessionManager().DoesSessionSatisfy(r.Context(), s, h.r.Config(r.Context()).SessionWhoAmIAAL()); errors.Is(err, ErrAALNotSatisfied) {
h.r.Audit().WithRequest(r).WithError(err).Info("Session was found but AAL is not satisfied for calling this endpoint.")
h.r.Writer().WriteError(w, r, err)
return
} else if err != nil {
h.r.Audit().WithRequest(r).WithError(err).Info("No valid session cookie found.")
h.r.Writer().WriteError(w, r, herodot.ErrUnauthorized.WithWrap(err).WithReasonf("Unable to determine AAL."))
return
}

// s.Devices = nil
s.Identity = s.Identity.CopyWithoutCredentials()

Expand Down

0 comments on commit 3a82c88

Please sign in to comment.