-
-
Notifications
You must be signed in to change notification settings - Fork 954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: reduce db lookups in whoami for aal check #3372
Changes from 5 commits
8504775
496a130
0415a80
6ed6ecf
b854129
a483d7b
5034468
da2623a
8fedaf1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,8 @@ import ( | |
"testing" | ||
"time" | ||
|
||
"github.com/gofrs/uuid" | ||
|
||
"github.com/ory/x/sqlxx" | ||
|
||
"github.com/ory/kratos/internal/testhelpers" | ||
|
@@ -65,10 +67,72 @@ func TestManager(t *testing.T) { | |
|
||
t.Run("method=Create", func(t *testing.T) { | ||
t.Run("case=should create identity and track extension fields", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits("[email protected]", "") | ||
original.Traits = newTraits(email, "") | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
checkExtensionFieldsForIdentities(t, "[email protected]", original) | ||
checkExtensionFieldsForIdentities(t, email, original) | ||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=correctly set AAL", func(t *testing.T) { | ||
t.Run("case=should set AAL to 0 if no credentials are available", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=should set AAL to 1 if password is set", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
original.Credentials = map[identity.CredentialsType]identity.Credentials{ | ||
identity.CredentialsTypePassword: { | ||
Type: identity.CredentialsTypePassword, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), | ||
}, | ||
} | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
original.Credentials = map[identity.CredentialsType]identity.Credentials{ | ||
identity.CredentialsTypePassword: { | ||
Type: identity.CredentialsTypePassword, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), | ||
}, | ||
identity.CredentialsTypeTOTP: { | ||
Type: identity.CredentialsTypeTOTP, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), | ||
}, | ||
} | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=should set AAL to 0 if only TOTP is set", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
original.Credentials = map[identity.CredentialsType]identity.Credentials{ | ||
identity.CredentialsTypeTOTP: { | ||
Type: identity.CredentialsTypeTOTP, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), | ||
}, | ||
} | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, original.AvailableAAL) | ||
}) | ||
}) | ||
|
||
t.Run("case=should expose validation errors with option", func(t *testing.T) { | ||
|
@@ -100,6 +164,62 @@ func TestManager(t *testing.T) { | |
checkExtensionFieldsForIdentities(t, "[email protected]", original) | ||
}) | ||
|
||
t.Run("case=should set AAL to 1 if password is set", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
original.Credentials = map[identity.CredentialsType]identity.Credentials{ | ||
identity.CredentialsTypePassword: { | ||
Type: identity.CredentialsTypePassword, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), | ||
}, | ||
} | ||
require.NoError(t, reg.IdentityManager().Update(context.Background(), original, identity.ManagerAllowWriteProtectedTraits)) | ||
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
original.Credentials = map[identity.CredentialsType]identity.Credentials{ | ||
identity.CredentialsTypePassword: { | ||
Type: identity.CredentialsTypePassword, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), | ||
}, | ||
} | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL) | ||
require.NoError(t, reg.IdentityManager().Update(context.Background(), original, identity.ManagerAllowWriteProtectedTraits)) | ||
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, original.AvailableAAL, "Updating without changes should not change AAL") | ||
original.Credentials[identity.CredentialsTypeTOTP] = identity.Credentials{ | ||
Type: identity.CredentialsTypeTOTP, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), | ||
} | ||
require.NoError(t, reg.IdentityManager().Update(context.Background(), original, identity.ManagerAllowWriteProtectedTraits)) | ||
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=should set AAL to 0 if only TOTP is set", func(t *testing.T) { | ||
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits(email, "") | ||
require.NoError(t, reg.IdentityManager().Create(context.Background(), original)) | ||
original.Credentials = map[identity.CredentialsType]identity.Credentials{ | ||
identity.CredentialsTypeTOTP: { | ||
Type: identity.CredentialsTypeTOTP, | ||
Identifiers: []string{email}, | ||
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), | ||
}, | ||
} | ||
require.NoError(t, reg.IdentityManager().Update(context.Background(), original, identity.ManagerAllowWriteProtectedTraits)) | ||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, original.AvailableAAL) | ||
}) | ||
|
||
t.Run("case=should not update protected traits without option", func(t *testing.T) { | ||
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) | ||
original.Traits = newTraits("[email protected]", "") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does this lack the new column? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because it's not exposed in JSON |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
{ | ||
"id": "0149ce5f-76a8-4efe-b2e3-431b8c6cceb6", | ||
"schema_id": "default", | ||
"schema_url": "https://www.ory.sh/schemas/ZGVmYXVsdA", | ||
"state": "active", | ||
"traits": { | ||
"email": "[email protected]" | ||
}, | ||
"metadata_public": { | ||
"foo": "bar" | ||
}, | ||
"metadata_admin": { | ||
"baz": "bar" | ||
}, | ||
"created_at": "2013-10-07T08:23:19Z", | ||
"updated_at": "2013-10-07T08:23:19Z" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, metadata_public, metadata_admin, | ||
available_aal) | ||
VALUES ('0149ce5f-76a8-4efe-b2e3-431b8c6cceb6', '884f556e-eb3a-4b9f-bee3-11345642c6c0', 'default', | ||
'{"email":"[email protected]"}', '2013-10-07 08:23:19', '2013-10-07 08:23:19', '{"foo":"bar"}', '{"baz":"bar"}', | ||
'aal1'); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
ALTER TABLE identities DROP COLUMN available_aal; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
ALTER TABLE identities ADD COLUMN available_aal VARCHAR(4) NOT NULL DEFAULT 'aal0'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, absolutely. However as I understood the people from CRDB, this should not be an issue due to the way migrations work. We still need to verify that though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's now nullable |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -205,7 +205,10 @@ func (h *Handler) whoami(w http.ResponseWriter, r *http.Request, ps httprouter.P | |
} | ||
|
||
var aalErr *ErrAALNotSatisfied | ||
if err := h.r.SessionManager().DoesSessionSatisfy(r, s, c.SessionWhoAmIAAL(r.Context())); errors.As(err, &aalErr) { | ||
if err := h.r.SessionManager().DoesSessionSatisfy(r, s, c.SessionWhoAmIAAL(r.Context()), | ||
// For the time being we want to update the AAL in the database if it is unset. | ||
UpsertAAL, | ||
Comment on lines
+209
to
+210
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A nullable column makes this unnecessary, because we can distinguish the "not yet computed" from the "zero" case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that this state could also happen during an import, which is why I think this fallback is required. |
||
); errors.As(err, &aalErr) { | ||
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 | ||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -63,6 +63,7 @@ func NewManagerHTTP(r managerHTTPDependencies) *ManagerHTTP { | |||||||||
|
||||||||||
type options struct { | ||||||||||
requestURL string | ||||||||||
upsertAAL bool | ||||||||||
} | ||||||||||
|
||||||||||
type ManagerOptions func(*options) | ||||||||||
|
@@ -74,6 +75,12 @@ func WithRequestURL(requestURL string) ManagerOptions { | |||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
// UpsertAAL will update the available AAL of the identity if it was previoulsy unset. This is used to migrate | ||||||||||
// identities from older versions of Ory Kratos. | ||||||||||
func UpsertAAL(opts *options) { | ||||||||||
opts.upsertAAL = true | ||||||||||
} | ||||||||||
|
||||||||||
func (s *ManagerHTTP) UpsertAndIssueCookie(ctx context.Context, w http.ResponseWriter, r *http.Request, ss *Session) (err error) { | ||||||||||
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.UpsertAndIssueCookie") | ||||||||||
defer otelx.End(span, &err) | ||||||||||
|
@@ -225,15 +232,7 @@ func (s *ManagerHTTP) FetchFromRequest(ctx context.Context, r *http.Request) (_ | |||||||||
return nil, errors.WithStack(NewErrNoCredentialsForSession()) | ||||||||||
} | ||||||||||
|
||||||||||
expand := identity.ExpandDefault | ||||||||||
if s.r.Config().SessionWhoAmIAAL(r.Context()) == config.HighestAvailableAAL { | ||||||||||
// When the session endpoint requires the highest AAL, we fetch all credentials immediately to save a | ||||||||||
// query later in "DoesSessionSatisfy". This is a SQL optimization, because the identity manager fetches | ||||||||||
// the data in parallel, which is a bit faster than fetching it in sequence. | ||||||||||
expand = identity.ExpandEverything | ||||||||||
} | ||||||||||
|
||||||||||
se, err := s.r.SessionPersister().GetSessionByToken(ctx, token, ExpandEverything, expand) | ||||||||||
se, err := s.r.SessionPersister().GetSessionByToken(ctx, token, ExpandEverything, identity.ExpandDefault) | ||||||||||
if err != nil { | ||||||||||
if errors.Is(err, herodot.ErrNotFound) || errors.Is(err, sqlcon.ErrNoRows) { | ||||||||||
return nil, errors.WithStack(NewErrNoActiveSessionFound()) | ||||||||||
|
@@ -295,31 +294,50 @@ func (s *ManagerHTTP) DoesSessionSatisfy(r *http.Request, sess *Session, request | |||||||||
return nil | ||||||||||
} | ||||||||||
case config.HighestAvailableAAL: | ||||||||||
i := sess.Identity | ||||||||||
if i == nil { | ||||||||||
i, err = s.r.IdentityPool().GetIdentity(ctx, sess.IdentityID, identity.ExpandCredentials) | ||||||||||
if sess.Identity == nil { | ||||||||||
sess.Identity, err = s.r.IdentityPool().GetIdentity(ctx, sess.IdentityID, identity.ExpandNothing) | ||||||||||
if err != nil { | ||||||||||
return err | ||||||||||
} | ||||||||||
sess.Identity = i | ||||||||||
} else if len(i.Credentials) == 0 { | ||||||||||
// If credentials are not expanded, we load them here. | ||||||||||
if err := s.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials); err != nil { | ||||||||||
} | ||||||||||
|
||||||||||
i := sess.Identity | ||||||||||
available := i.AvailableAAL | ||||||||||
if len(available) == 0 || available == identity.NoAuthenticatorAssuranceLevel { | ||||||||||
// Available is 0 if the identity was created before the AAL feature was introduced, or if the identity | ||||||||||
// was directly created in the persister and not the identity manager. | ||||||||||
// | ||||||||||
// aal0 indicates that the AAL state of the identity is probably unknown. | ||||||||||
// | ||||||||||
// In either case, we need to fetch the credentials from the database to determine the AAL. | ||||||||||
Comment on lines
+307
to
+312
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment is not accurate anymore. |
||||||||||
if len(i.Credentials) == 0 { | ||||||||||
// The identity was apparently fetched without credentials. Let's hydrate them. | ||||||||||
if err := s.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials); err != nil { | ||||||||||
return err | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
available = identity.NoAuthenticatorAssuranceLevel | ||||||||||
if firstCount, err := s.r.IdentityManager().CountActiveFirstFactorCredentials(ctx, i); err != nil { | ||||||||||
return err | ||||||||||
} else if firstCount > 0 { | ||||||||||
available = identity.AuthenticatorAssuranceLevel1 | ||||||||||
} | ||||||||||
|
||||||||||
if secondCount, err := s.r.IdentityManager().CountActiveMultiFactorCredentials(ctx, i); err != nil { | ||||||||||
return err | ||||||||||
} else if secondCount > 0 { | ||||||||||
available = identity.AuthenticatorAssuranceLevel2 | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
available := identity.NoAuthenticatorAssuranceLevel | ||||||||||
if firstCount, err := s.r.IdentityManager().CountActiveFirstFactorCredentials(ctx, i); err != nil { | ||||||||||
return err | ||||||||||
} else if firstCount > 0 { | ||||||||||
available = identity.AuthenticatorAssuranceLevel1 | ||||||||||
} | ||||||||||
i.AvailableAAL = available | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
if secondCount, err := s.r.IdentityManager().CountActiveMultiFactorCredentials(ctx, i); err != nil { | ||||||||||
return err | ||||||||||
} else if secondCount > 0 { | ||||||||||
available = identity.AuthenticatorAssuranceLevel2 | ||||||||||
// This is the migration strategy for identities that already exist. | ||||||||||
if managerOpts.upsertAAL { | ||||||||||
Comment on lines
+326
to
+327
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. During the "migration" we just recompute it for everyone. Users that don't log in during the migration period stay at AAL0 forever. I think a nullable column is the way to go instead.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup - it's now nullable. This parameter however is still here because we don't always want to perform an INSERT or UPDATE statement when this method is called, so there has to be a way to control it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, makes sense. |
||||||||||
if _, err := s.r.SessionPersister().GetConnection(ctx).Where("id = ? AND nid = ?", i.ID, i.NID).UpdateQuery(i, "available_aal"); err != nil { | ||||||||||
return err | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
if sess.AuthenticatorAssuranceLevel >= available { | ||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it makes more sense to make this a function on the identity, and provide the manager as a dependency there (potentially as an interface). The logic is duplicated in the session manager well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done