From 62f05b3ff2f27784709b9bb09f642fefb451c8d0 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 27 Apr 2026 19:07:43 +0200 Subject: [PATCH] Invalidate sessions on password change and reset Session validity was decoupled from credential rotation: changing a password (logged-in flow) or completing a forgot-password reset left every existing iam_sessions row valid until its idle TTL. A user who saw their account compromised on another device had no way to actually evict that device by rotating the password. Inside the same DB transaction as the password update, expire the identity's other active sessions: - ChangePassword keeps the caller's current session and revokes every other session for the identity, so the user is not logged out of the browser they just used. - ResetPassword has no caller session (the user is anonymous, authenticated only by a stateless token) and revokes all of the identity's active sessions. The session middleware already rejects rows with expire_reason set, so revoked sessions are kicked out on the next request without any middleware change. Signed-off-by: Bryan Frimin --- pkg/coredata/session.go | 24 +++++++++++++++++++ pkg/iam/account_service.go | 7 +++++- pkg/iam/auth_service.go | 5 ++++ .../api/connect/v1/session_resolvers.go | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/coredata/session.go b/pkg/coredata/session.go index 2c80cf475..9f44a051f 100644 --- a/pkg/coredata/session.go +++ b/pkg/coredata/session.go @@ -343,6 +343,30 @@ WHERE return result.RowsAffected(), nil } +func (s *Sessions) ExpireAllForIdentity(ctx context.Context, conn pg.Querier, identityID gid.GID) (int64, error) { + q := ` +UPDATE iam_sessions +SET + expired_at = NOW(), + updated_at = NOW(), + expire_reason = 'revoked' +WHERE + identity_id = @identity_id + AND expire_reason IS NULL +` + + args := pgx.StrictNamedArgs{ + "identity_id": identityID, + } + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return 0, fmt.Errorf("cannot query sessions: %w", err) + } + + return result.RowsAffected(), nil +} + func (s *Session) LoadByRootSessionIDAndMembershipID( ctx context.Context, conn pg.Querier, diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 96387c38c..5aefe8868 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -235,7 +235,7 @@ func (s *AccountService) ListPendingInvitations( return page.NewPage(invitations, cursor), nil } -func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, req *ChangePasswordRequest) error { +func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, currentSessionID gid.GID, req *ChangePasswordRequest) error { if err := req.Validate(); err != nil { return fmt.Errorf("invalid request: %w", err) } @@ -275,6 +275,11 @@ func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, return fmt.Errorf("cannot update identity: %w", err) } + sessions := coredata.Sessions{} + if _, err := sessions.ExpireAllForIdentityExceptOneSession(ctx, tx, identity.ID, currentSessionID); err != nil { + return fmt.Errorf("cannot expire other sessions: %w", err) + } + // TODO: email to notify identity that their password has been changed return nil diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index d59d74c2f..91b3f8bb9 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -279,6 +279,11 @@ func (s AuthService) ResetPassword( return fmt.Errorf("cannot update identity: %w", err) } + sessions := coredata.Sessions{} + if _, err := sessions.ExpireAllForIdentity(ctx, tx, identity.ID); err != nil { + return fmt.Errorf("cannot expire sessions: %w", err) + } + return nil }, ) diff --git a/pkg/server/api/connect/v1/session_resolvers.go b/pkg/server/api/connect/v1/session_resolvers.go index 7f5b05a79..ac475037e 100644 --- a/pkg/server/api/connect/v1/session_resolvers.go +++ b/pkg/server/api/connect/v1/session_resolvers.go @@ -318,10 +318,12 @@ func (r *mutationResolver) VerifyEmail(ctx context.Context, input types.VerifyEm // ChangePassword is the resolver for the changePassword field. func (r *mutationResolver) ChangePassword(ctx context.Context, input types.ChangePasswordInput) (*types.ChangePasswordPayload, error) { identity := authn.IdentityFromContext(ctx) + session := authn.SessionFromContext(ctx) err := r.iam.AccountService.ChangePassword( ctx, identity.ID, + session.ID, &iam.ChangePasswordRequest{ CurrentPassword: input.CurrentPassword, NewPassword: input.NewPassword,