From f0c1879c4bdbd6d578675a7bc49b6ca7d028d501 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Sun, 26 Apr 2026 21:29:59 +0200 Subject: [PATCH 01/24] Changelog. --- .../WPB-24977-allow-suspended-apps-to-keep-their-cookies | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies diff --git a/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies b/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies new file mode 100644 index 00000000000..65637c79325 --- /dev/null +++ b/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies @@ -0,0 +1 @@ +Allow suspended users to keep their cookies. From 1509a7c7a883a16aff5942d63bd2eaad2f77c381 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Sun, 26 Apr 2026 22:01:02 +0200 Subject: [PATCH 02/24] Integration test. --- integration/test/Test/Apps.hs | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 4428f5e732f..bc312e26246 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -599,3 +599,47 @@ testTeamSizeWithApps (TaggedBool testInternalApi) = do BrigI.refreshIndex domain eventually $ do checkSize (numRegulars - 1) (numApps - 1) + +testZauthAndApps :: (HasCallStack) => App () +testZauthAndApps = do + (owner, tid, []) <- createTeam OwnDomain 1 + (app, cookie) <- createIt owner tid + + refreshSucceeds app cookie + suspendApp app >> refreshFails app cookie + unsuspendApp app >> refreshSucceeds app cookie + where + createIt :: (HasCallStack, MakesValue owner) => owner -> String -> App (Value, String) + createIt owner tid = + createApp owner tid new `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + app <- resp.json %. "user" + cookie <- resp.json %. "cookie" & asString + pure (app, cookie) + where + new :: NewApp = + def + { name = "chappie", + description = "some description of this app", + category = "ai" + } + + suspendApp :: (HasCallStack, MakesValue app) => app -> App () + suspendApp app = + BrigI.setAccountStatus app "suspended" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + + unsuspendApp :: (HasCallStack, MakesValue app) => app -> App () + unsuspendApp app = + BrigI.setAccountStatus app "active" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + + refreshSucceeds :: (HasCallStack, MakesValue app) => app -> String -> App () + refreshSucceeds app cookie = + renewToken app cookie `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + + refreshFails :: (HasCallStack, MakesValue app) => app -> String -> App () + refreshFails app cookie = + renewToken app cookie `bindResponse` \resp -> do + resp.status `shouldMatchInt` 403 From 800379ab3cd8974b0714eceac3e0cdda1cab1533 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Sun, 26 Apr 2026 23:14:37 +0200 Subject: [PATCH 03/24] Reject access token refresh requests from suspended users. --- services/brig/src/Brig/User/Auth.hs | 34 ++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index d00b6340196..1dff35ceb2e 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -76,7 +76,6 @@ import Wire.ClientStore qualified as ClientStore import Wire.Events (Events) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess -import Wire.Sem.Concurrency import Wire.Sem.Metrics (Metrics) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -97,7 +96,6 @@ login :: Member UserSubsystem r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r @@ -188,7 +186,6 @@ renewAccess :: Member (Embed IO) r, Member Metrics r, Member SessionStore r, - Member (Concurrency Unsafe) r, Member CryptoSign r, Member Now r, Member AuthenticationSubsystem r, @@ -205,6 +202,7 @@ renewAccess uts at mcid = do traverse_ (checkClientId uid) mcid lift . liftSem . Log.debug $ field "user" (toByteString uid) . field "action" (val "User.renewAccess") catchSuspendInactiveUser uid ZAuth.Expired + catchSuspendedUsers uid mapExceptT liftSem $ do ck' <- nextCookie ck mcid at' <- lift $ newAccessToken (fromMaybe ck ck') at @@ -237,8 +235,6 @@ catchSuspendInactiveUser :: ( Member TinyLog r, Member UserSubsystem r, Member Events r, - Member (Concurrency 'Unsafe) r, - Member AuthenticationSubsystem r, Member UserStore r ) => UserId -> @@ -260,6 +256,28 @@ catchSuspendInactiveUser uid errval = do Left AccountNotFound -> pure () Right () -> pure () +-- | Suspended users are not allowed to pick up new session tokens, +-- even if they have a valid cookie. +-- +-- This does *not* change behavior for existing apps, because their +-- observations are the same: before, refreshing access tokens failed +-- because the cookie was invalid, now it fails with the same status +-- code if the user is suspended, whether there are valid cookies or +-- not. +catchSuspendedUsers :: + (Member UserStore r) => + UserId -> + ExceptT ZAuth.Failure (AppT r) () +catchSuspendedUsers uid = do + mb <- lift $ liftSem $ lookupStatus uid + case mb of + Nothing -> throwE ZAuth.Invalid + Just Active -> pure () + Just Suspended -> throwE ZAuth.Invalid + Just Deleted -> throwE ZAuth.Invalid -- (does not happen, but if it did, this is what we'd want to do) + Just Ephemeral -> pure () + Just PendingInvitation -> pure () + newAccess :: forall u a r. ( Member TinyLog r, @@ -268,7 +286,6 @@ newAccess :: ZAuth.UserTokenLike u, ZAuth.AccessTokenLike a, ZAuth.AccessTokenType u ~ a, - Member (Concurrency Unsafe) r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, Member AuthenticationSubsystem r, @@ -283,6 +300,9 @@ newAccess :: ExceptT LoginError (AppT r) (Access u) newAccess uid cid ct cl = do catchSuspendInactiveUser uid LoginSuspended + -- NB: no need to call `catchSuspendedUsers` here. `newAccess` is + -- called in 3 places (login, ssoLogin, legalHoldLogin), and all of + -- them reject suspended users before calling it. r <- lift $ liftSem $ newCookieLimited uid cid ct cl RevokeSameLabel case r of Left delay -> throwE $ LoginThrottled delay @@ -398,7 +418,6 @@ ssoLogin :: Member Events r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, @@ -437,7 +456,6 @@ legalHoldLogin :: Member AuthenticationSubsystem r, Member Events r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, From fb1e2fd3d7fd566895fad0b8daa9a33c57617728 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Sun, 26 Apr 2026 23:16:35 +0200 Subject: [PATCH 04/24] Do not evict cookies on suspend. Cookies can't be used by suspended accounts for refreshing access tokens (see last commit), so this is safe. --- services/brig/src/Brig/API/Auth.hs | 6 ------ services/brig/src/Brig/API/Internal.hs | 7 +------ services/brig/src/Brig/API/User.hs | 28 +++++++++++--------------- services/brig/src/Brig/Team/API.hs | 4 ---- 4 files changed, 13 insertions(+), 32 deletions(-) diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index b47366621fc..0614ae647f5 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -61,7 +61,6 @@ import Wire.EmailSubsystem (EmailSubsystem) import Wire.Error (HttpError (..)) import Wire.Events (Events) import Wire.GalleyAPIAccess -import Wire.Sem.Concurrency import Wire.Sem.Metrics (Metrics) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -82,7 +81,6 @@ accessH :: Member (Embed IO) r, Member Metrics r, Member SessionStore r, - Member (Concurrency Unsafe) r, Member CryptoSign r, Member Now r, Member AuthenticationSubsystem r, @@ -111,7 +109,6 @@ access :: Member (Embed IO) r, Member Metrics r, Member SessionStore r, - Member (Concurrency Unsafe) r, Member CryptoSign r, Member Now r, Member AuthenticationSubsystem r, @@ -142,7 +139,6 @@ login :: Member ActivationCodeStore r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r @@ -246,7 +242,6 @@ legalHoldLogin :: Member Events r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, @@ -265,7 +260,6 @@ ssoLogin :: Member UserSubsystem r, Member Events r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 6a6f17dbb80..b9d29498367 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -254,7 +254,6 @@ accountAPI :: Member RateLimit r, Member SparAPIAccess r, Member EnterpriseLoginSubsystem r, - Member (Concurrency Unsafe) r, Member ClientStore r, Member ClientSubsystem r ) => @@ -316,8 +315,7 @@ teamsAPI :: Member (Polysemy.Error UserSubsystemError) r, Member Events r, Member (Input (Local ())) r, - Member IndexedUserStore r, - Member AuthenticationSubsystem r + Member IndexedUserStore r ) => ServerT BrigIRoutes.TeamsAPI (Handler r) teamsAPI = @@ -347,7 +345,6 @@ authAPI :: Member UserSubsystem r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, @@ -785,8 +782,6 @@ getPasswordResetCode email = changeAccountStatusH :: ( Member UserSubsystem r, Member Events r, - Member (Concurrency Unsafe) r, - Member AuthenticationSubsystem r, Member UserStore r ) => UserId -> diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 4b6f1a4877c..13556f68f07 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -90,7 +90,6 @@ import Data.Json.Util import Data.LegalHold (UserLegalHoldStatus (..), defUserLegalHoldStatus) import Data.List.Extra import Data.List.NonEmpty (NonEmpty) -import Data.List.NonEmpty qualified as NonEmpty import Data.Misc import Data.Qualified import Data.Range @@ -627,14 +626,19 @@ changeAccountStatus :: ( Member (Concurrency 'Unsafe) r, Member UserSubsystem r, Member Events r, - Member AuthenticationSubsystem r, Member UserStore r ) => NonEmpty UserId -> AccountStatus -> ExceptT AccountStatusError (AppT r) () changeAccountStatus usrs status = do - ev <- mkUserEvent usrs status + -- It is safe to not revoke any cookies here; if no valid access + -- token is available, cookies are only validated when calling `POST + -- /access`, and access token refresh only works on unsuspended + -- users. + -- + -- Evidence: `git grep -Hn --color=never 'UserToken\b' | grep libs/wire-api/src/Wire/API/Routes/Public/`. + ev <- mkUserEvent status lift $ liftSem $ unsafePooledMapConcurrentlyN_ 16 (update ev) usrs where update :: @@ -649,8 +653,6 @@ changeAccountStatus usrs status = do changeSingleAccountStatus :: ( Member UserSubsystem r, Member Events r, - Member (Concurrency Unsafe) r, - Member AuthenticationSubsystem r, Member UserStore r ) => UserId -> @@ -658,26 +660,20 @@ changeSingleAccountStatus :: ExceptT AccountStatusError (AppT r) () changeSingleAccountStatus uid status = do unlessM (lift . liftSem $ UserStore.doesUserExist uid) $ throwE AccountNotFound - ev <- mkUserEvent (NonEmpty.singleton uid) status + ev <- mkUserEvent status lift . liftSem $ do UserStore.updateAccountStatus uid status User.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (ev uid) mkUserEvent :: - ( Traversable t, - Member (Concurrency Unsafe) r, - Member AuthenticationSubsystem r - ) => - t UserId -> + (Monad m) => AccountStatus -> - ExceptT AccountStatusError (AppT r) (UserId -> UserEvent) -mkUserEvent usrs status = + ExceptT AccountStatusError m (UserId -> UserEvent) +mkUserEvent status = case status of Active -> pure UserResumed - Suspended -> do - lift $ liftSem (unsafePooledMapConcurrentlyN_ 16 Auth.revokeAllCookies usrs) - pure UserSuspended + Suspended -> pure UserSuspended Deleted -> throwE InvalidAccountStatus Ephemeral -> throwE InvalidAccountStatus PendingInvitation -> throwE InvalidAccountStatus diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index fdde7fdf4d9..a0e2f4116cf 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -69,7 +69,6 @@ import Wire.API.Team.Member qualified as Teams import Wire.API.Team.Permission (Perm (AddTeamMember)) import Wire.API.Team.Size import Wire.API.User hiding (fromEmail) -import Wire.AuthenticationSubsystem import Wire.BlockListStore import Wire.EmailSubsystem.Interpreter (renderInvitationUrl) import Wire.Error @@ -373,7 +372,6 @@ suspendTeam :: Member Events r, Member TinyLog r, Member InvitationStore r, - Member AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -394,7 +392,6 @@ unsuspendTeam :: Member UserSubsystem r, Member TeamSubsystem r, Member Events r, - Member AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -413,7 +410,6 @@ changeTeamAccountStatuses :: Member TeamSubsystem r, Member UserSubsystem r, Member Events r, - Member AuthenticationSubsystem r, Member UserStore r ) => TeamId -> From 5fcf1a668d0cd8f28ea3ac5a4c0adee03128d335 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 27 Apr 2026 11:41:42 +0200 Subject: [PATCH 05/24] Allow unsuspended users to login even if inactive. There is a way to auto-suspend inactive users after a configurable time span. This change makes sure that expired cookies that would trigger auto-suspend immediately are removed on re-activation. --- .../src/Wire/AuthenticationSubsystem.hs | 1 + .../Wire/AuthenticationSubsystem/Config.hs | 4 +- .../Wire/AuthenticationSubsystem/Cookie.hs | 29 +++++++++ .../AuthenticationSubsystem/Interpreter.hs | 1 + .../test/unit/Wire/MiniBackend.hs | 3 +- services/brig/src/Brig/API/Internal.hs | 4 +- services/brig/src/Brig/API/User.hs | 60 ++++++++++++------- .../brig/src/Brig/CanonicalInterpreter.hs | 5 +- services/brig/src/Brig/Team/API.hs | 4 ++ services/brig/src/Brig/User/Auth.hs | 3 +- services/brig/src/Brig/User/Auth/Cookie.hs | 3 + services/brig/test/integration/API/User.hs | 3 +- 12 files changed, 91 insertions(+), 29 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs index b4eff9d78da..64df3184e48 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs @@ -78,6 +78,7 @@ data AuthenticationSubsystem m a where SameLabelPolicy -> AuthenticationSubsystem m (Either RetryAfter (Cookie (ZAuth.Token t))) RevokeCookies :: UserId -> [CookieId] -> [CookieLabel] -> AuthenticationSubsystem m () + RevokeAllExpiredCookies :: UserId -> AuthenticationSubsystem m () -- Verification Codes EnforceVerificationCodeEither :: Local UserId -> Maybe Code.Value -> VerificationAction -> AuthenticationSubsystem m (Either VerificationCodeError ()) -- For testing diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs index 0c205f8ce06..dca16f0bc37 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs @@ -26,6 +26,7 @@ import Data.Vector qualified as Vector import Data.ZAuth.Creation qualified as ZC import Imports import Sodium.Crypto.Sign +import Util.Timeout import Wire.API.Allowlists (AllowlistEmailDomains) import Wire.AuthenticationSubsystem.Cookie.Limit @@ -35,7 +36,8 @@ data AuthenticationSubsystemConfig = AuthenticationSubsystemConfig zauthEnv :: ZAuthEnv, userCookieRenewAge :: Integer, userCookieLimit :: Int, - userCookieThrottle :: CookieThrottle + userCookieThrottle :: CookieThrottle, + suspendInactiveUsers :: Maybe Timeout } data ZAuthSettings = ZAuthSettings diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs index 01543d47073..ec73fbf3b6a 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs @@ -19,12 +19,14 @@ module Wire.AuthenticationSubsystem.Cookie where import Data.Id import Data.RetryAfter +import Data.Time import Data.ZAuth.CryptoSign (CryptoSign) import Data.ZAuth.Token import Imports import Polysemy import Polysemy.Error import Polysemy.Input +import Util.Timeout import Wire.API.User.Auth import Wire.API.UserEvent (UserEvent (UserSessionRefreshSuggested)) import Wire.AuthenticationSubsystem @@ -142,3 +144,30 @@ revokeCookiesMatchingExcept u mself ids labels = do && ( c.cookieId `elem` ids || maybe False (`elem` labels) c.cookieLabel ) + +-- Remove stale cookies. Stale means either (1) cookie is expired, or +-- (2) cookie creation time is further in the past than +-- `env.suspendInactiveUsers` allows. +revokeAllExpiredCookiesImpl :: + ( Member SessionStore r, + Member (Input AuthenticationSubsystemConfig) r, + Member Now r + ) => + UserId -> + Sem r () +revokeAllExpiredCookiesImpl uid = do + now :: UTCTime <- Now.get + mbSuspendAge <- (.suspendInactiveUsers) <$> input + + let dead :: Cookie () -> Bool + dead c = cookieExpired && userInactive + where + cookieExpired = c.cookieExpires < now + userInactive = + maybe + False + (\suspendAge -> c.cookieCreated < addUTCTime (-(timeoutDiff suspendAge)) now) + mbSuspendAge + + cc <- filter dead <$> SessionStore.listCookies uid + SessionStore.deleteCookies uid cc diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs index 0bf6b57f0cf..2f59082bc5a 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs @@ -107,6 +107,7 @@ interpretAuthenticationSubsystem userSubsystemInterpreter = NewCookie uid mcid typ mLabel policy -> newCookieImpl uid mcid typ mLabel policy NewCookieLimited uid mcid typ mLabel policy -> runError $ newCookieLimitedImpl uid mcid typ mLabel policy RevokeCookies uid ids labels -> revokeCookiesImpl uid ids labels + RevokeAllExpiredCookies uid -> revokeAllExpiredCookiesImpl uid -- Verification Codes EnforceVerificationCodeEither luid mCode action -> runError $ enforceVerificationCodeImpl luid mCode action -- Testing diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 88624c7019f..9cadf92eb22 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -461,7 +461,8 @@ defaultAuthenticationSubsystemConfig = local = defaultLocalDomain, userCookieRenewAge = 2, userCookieLimit = 5, - userCookieThrottle = StdDevThrottle 5 3 + userCookieThrottle = StdDevThrottle 5 3, + suspendInactiveUsers = Nothing } defaultLocalDomain :: Local () diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index b9d29498367..1dc1a21cfbc 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -315,6 +315,7 @@ teamsAPI :: Member (Polysemy.Error UserSubsystemError) r, Member Events r, Member (Input (Local ())) r, + Member AuthenticationSubsystem r, Member IndexedUserStore r ) => ServerT BrigIRoutes.TeamsAPI (Handler r) @@ -782,7 +783,8 @@ getPasswordResetCode email = changeAccountStatusH :: ( Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => UserId -> AccountStatusUpdate -> diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 13556f68f07..39b5b6d91d7 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -120,6 +120,7 @@ import Wire.API.UserEvent import Wire.ActivationCodeStore import Wire.ActivationCodeStore qualified as ActivationCode import Wire.AuthenticationSubsystem (AuthenticationSubsystem, internalLookupPasswordResetCode) +import Wire.AuthenticationSubsystem qualified as Auth import Wire.BackendNotificationQueueAccess import Wire.BlockListStore as BlockListStore import Wire.ClientStore (ClientStore) @@ -626,34 +627,21 @@ changeAccountStatus :: ( Member (Concurrency 'Unsafe) r, Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => NonEmpty UserId -> AccountStatus -> ExceptT AccountStatusError (AppT r) () changeAccountStatus usrs status = do - -- It is safe to not revoke any cookies here; if no valid access - -- token is available, cookies are only validated when calling `POST - -- /access`, and access token refresh only works on unsuspended - -- users. - -- - -- Evidence: `git grep -Hn --color=never 'UserToken\b' | grep libs/wire-api/src/Wire/API/Routes/Public/`. ev <- mkUserEvent status - lift $ liftSem $ unsafePooledMapConcurrentlyN_ 16 (update ev) usrs - where - update :: - (UserId -> UserEvent) -> - UserId -> - Sem r () - update ev u = do - UserStore.updateAccountStatus u status - User.internalUpdateSearchIndex u - Events.generateUserEvent u Nothing (ev u) + lift $ liftSem $ unsafePooledMapConcurrentlyN_ 16 (changeSingleAccountStatusInternal status ev) usrs changeSingleAccountStatus :: ( Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => UserId -> AccountStatus -> @@ -661,10 +649,38 @@ changeSingleAccountStatus :: changeSingleAccountStatus uid status = do unlessM (lift . liftSem $ UserStore.doesUserExist uid) $ throwE AccountNotFound ev <- mkUserEvent status - lift . liftSem $ do - UserStore.updateAccountStatus uid status - User.internalUpdateSearchIndex uid - Events.generateUserEvent uid Nothing (ev uid) + lift . liftSem $ changeSingleAccountStatusInternal status ev uid + +changeSingleAccountStatusInternal :: + ( Member UserSubsystem r, + Member Events r, + Member UserStore r, + Member AuthenticationSubsystem r + ) => + AccountStatus -> + (UserId -> UserEvent) -> + UserId -> + Sem r () +changeSingleAccountStatusInternal status ev u = do + -- It is safe to *not* revoke any cookies here; if no valid access + -- token is available, cookies are only validated when calling `POST + -- /access`, and access token refresh only works on unsuspended + -- users. + -- + -- Evidence: `git grep -Hn --color=never 'UserToken\b' | grep libs/wire-api/src/Wire/API/Routes/Public/`. + -- + -- Having that said, we need to remove all *expired* cookies here, + -- otherwise /login considers the user inactive, see + -- 'mustSuspendInactiveUser'. + -- + -- The intuition is that every change of account status can be + -- considered an account activity, so users that have their status + -- changed recently should not be considered inactive, even if they + -- haven't taken any action themselves. + Auth.revokeAllExpiredCookies u + UserStore.updateAccountStatus u status + User.internalUpdateSearchIndex u + Events.generateUserEvent u Nothing (ev u) mkUserEvent :: (Monad m) => diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 06eb36f10ff..3018028335a 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -28,7 +28,7 @@ import Brig.Effects.SFT (SFT, interpretSFT) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Effects.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra) import Brig.IO.Intra (runEvents) -import Brig.Options (Settings (consumableNotifications), federationDomainConfigs, federationStrategy) +import Brig.Options (Settings (consumableNotifications), SuspendInactiveUsers (..), federationDomainConfigs, federationStrategy) import Brig.Options qualified as Opt import Brig.Template (InvitationUrlTemplates) import Brig.User.Search.Index (IndexEnv (..)) @@ -352,7 +352,8 @@ runBrigToIO e (AppT ma) = do local = localUnit, userCookieRenewAge = e.settings.userCookieRenewAge, userCookieLimit = e.settings.userCookieLimit, - userCookieThrottle = e.settings.userCookieThrottle + userCookieThrottle = e.settings.userCookieThrottle, + suspendInactiveUsers = suspendTimeout <$> e.settings.suspendInactiveUsers } mainESEnv = e.indexEnv ^. to idxElastic indexedUserStoreConfig = diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index a0e2f4116cf..c66abd7c3ab 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -69,6 +69,7 @@ import Wire.API.Team.Member qualified as Teams import Wire.API.Team.Permission (Perm (AddTeamMember)) import Wire.API.Team.Size import Wire.API.User hiding (fromEmail) +import Wire.AuthenticationSubsystem qualified as Auth import Wire.BlockListStore import Wire.EmailSubsystem.Interpreter (renderInvitationUrl) import Wire.Error @@ -372,6 +373,7 @@ suspendTeam :: Member Events r, Member TinyLog r, Member InvitationStore r, + Member Auth.AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -392,6 +394,7 @@ unsuspendTeam :: Member UserSubsystem r, Member TeamSubsystem r, Member Events r, + Member Auth.AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -410,6 +413,7 @@ changeTeamAccountStatuses :: Member TeamSubsystem r, Member UserSubsystem r, Member Events r, + Member Auth.AuthenticationSubsystem r, Member UserStore r ) => TeamId -> diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index 1dff35ceb2e..4130ab8a484 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -235,7 +235,8 @@ catchSuspendInactiveUser :: ( Member TinyLog r, Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => UserId -> e -> diff --git a/services/brig/src/Brig/User/Auth/Cookie.hs b/services/brig/src/Brig/User/Auth/Cookie.hs index 6288cf652e0..99f5a0b77d6 100644 --- a/services/brig/src/Brig/User/Auth/Cookie.hs +++ b/services/brig/src/Brig/User/Auth/Cookie.hs @@ -153,10 +153,13 @@ mustSuspendInactiveUser uid = Nothing -> pure False Just (SuspendInactiveUsers (Timeout suspendAge)) -> do now <- liftIO =<< asks (.currentTime) + let suspendHere :: UTCTime suspendHere = addUTCTime (-suspendAge) now + youngEnough :: Cookie () -> Bool youngEnough = (>= suspendHere) . cookieCreated + ckies <- listCookies uid [] let mustSuspend | null ckies = False diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index 7c88c057abf..80281eeb93f 100644 --- a/services/brig/test/integration/API/User.hs +++ b/services/brig/test/integration/API/User.hs @@ -66,7 +66,8 @@ tests conf fbc p b c ch g n aws db userJournalWatcher = do local = localUnit, userCookieRenewAge = conf.settings.userCookieRenewAge, userCookieLimit = conf.settings.userCookieLimit, - userCookieThrottle = conf.settings.userCookieThrottle + userCookieThrottle = conf.settings.userCookieThrottle, + suspendInactiveUsers = Nothing } pure $ testGroup From 68212d51ac618845b828bc4d431e2bc45d9da33e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 13 May 2026 11:47:08 +0200 Subject: [PATCH 06/24] More precise haddocks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- services/brig/src/Brig/API/User.hs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 39b5b6d91d7..1afbefb7113 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -669,7 +669,9 @@ changeSingleAccountStatusInternal status ev u = do -- -- Evidence: `git grep -Hn --color=never 'UserToken\b' | grep libs/wire-api/src/Wire/API/Routes/Public/`. -- - -- Having that said, we need to remove all *expired* cookies here, + -- Having that said, we need to remove cookies here that are no + -- longer valid for login/inactivity handling (including both + -- expired cookies and cookies older than the inactivity threshold), -- otherwise /login considers the user inactive, see -- 'mustSuspendInactiveUser'. -- From 45ccb2d668b18e2464386a57ad6bb256dc3230da Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 13 May 2026 11:47:52 +0200 Subject: [PATCH 07/24] Fix boolean logic bug. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs index ec73fbf3b6a..96b1bb67247 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs @@ -160,7 +160,7 @@ revokeAllExpiredCookiesImpl uid = do mbSuspendAge <- (.suspendInactiveUsers) <$> input let dead :: Cookie () -> Bool - dead c = cookieExpired && userInactive + dead c = cookieExpired || userInactive where cookieExpired = c.cookieExpires < now userInactive = From 0f95018659adff0b1b022ffb02221ed1a2c2d996 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 13 May 2026 11:48:26 +0200 Subject: [PATCH 08/24] Better effect action name. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs | 2 +- .../src/Wire/AuthenticationSubsystem/Cookie.hs | 4 ++-- .../src/Wire/AuthenticationSubsystem/Interpreter.hs | 2 +- services/brig/src/Brig/API/User.hs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs index 64df3184e48..49d778e4e12 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs @@ -78,7 +78,7 @@ data AuthenticationSubsystem m a where SameLabelPolicy -> AuthenticationSubsystem m (Either RetryAfter (Cookie (ZAuth.Token t))) RevokeCookies :: UserId -> [CookieId] -> [CookieLabel] -> AuthenticationSubsystem m () - RevokeAllExpiredCookies :: UserId -> AuthenticationSubsystem m () + RevokeAllStaleCookies :: UserId -> AuthenticationSubsystem m () -- Verification Codes EnforceVerificationCodeEither :: Local UserId -> Maybe Code.Value -> VerificationAction -> AuthenticationSubsystem m (Either VerificationCodeError ()) -- For testing diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs index 96b1bb67247..921137c5ee6 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs @@ -148,14 +148,14 @@ revokeCookiesMatchingExcept u mself ids labels = do -- Remove stale cookies. Stale means either (1) cookie is expired, or -- (2) cookie creation time is further in the past than -- `env.suspendInactiveUsers` allows. -revokeAllExpiredCookiesImpl :: +revokeAllStaleCookiesImpl :: ( Member SessionStore r, Member (Input AuthenticationSubsystemConfig) r, Member Now r ) => UserId -> Sem r () -revokeAllExpiredCookiesImpl uid = do +revokeAllStaleCookiesImpl uid = do now :: UTCTime <- Now.get mbSuspendAge <- (.suspendInactiveUsers) <$> input diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs index 2f59082bc5a..93abd3faa08 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs @@ -107,7 +107,7 @@ interpretAuthenticationSubsystem userSubsystemInterpreter = NewCookie uid mcid typ mLabel policy -> newCookieImpl uid mcid typ mLabel policy NewCookieLimited uid mcid typ mLabel policy -> runError $ newCookieLimitedImpl uid mcid typ mLabel policy RevokeCookies uid ids labels -> revokeCookiesImpl uid ids labels - RevokeAllExpiredCookies uid -> revokeAllExpiredCookiesImpl uid + RevokeAllStaleCookies uid -> revokeAllStaleCookiesImpl uid -- Verification Codes EnforceVerificationCodeEither luid mCode action -> runError $ enforceVerificationCodeImpl luid mCode action -- Testing diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 1afbefb7113..6f5066a5d1e 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -679,7 +679,7 @@ changeSingleAccountStatusInternal status ev u = do -- considered an account activity, so users that have their status -- changed recently should not be considered inactive, even if they -- haven't taken any action themselves. - Auth.revokeAllExpiredCookies u + Auth.revokeAllStaleCookies u UserStore.updateAccountStatus u status User.internalUpdateSearchIndex u Events.generateUserEvent u Nothing (ev u) From 2f38f6337aab9930ef9a8e597509d71ce8f8191a Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 13 May 2026 14:52:48 +0200 Subject: [PATCH 09/24] Extend changelog entry. --- .../WPB-24977-allow-suspended-apps-to-keep-their-cookies | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies b/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies index 65637c79325..0104225d0cc 100644 --- a/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies +++ b/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies @@ -1 +1 @@ -Allow suspended users to keep their cookies. +Allow suspended users to keep their cookies. (This has become necessary to allow apps to keep their cookies during (sufficiently brief) suspend periods. It works because instead, there is a new guard now preventing session token refresh for suspended users with valid cookies.) From 8c40151612cb64c1ec2509ad7d5525adc11b15d4 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 15:03:58 +0200 Subject: [PATCH 10/24] Better changelog entry. Co-authored-by: Akshay Mankar --- .../WPB-24977-allow-suspended-apps-to-keep-their-cookies | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies b/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies index 0104225d0cc..df0fa53db84 100644 --- a/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies +++ b/changelog.d/2-features/WPB-24977-allow-suspended-apps-to-keep-their-cookies @@ -1 +1 @@ -Allow suspended users to keep their cookies. (This has become necessary to allow apps to keep their cookies during (sufficiently brief) suspend periods. It works because instead, there is a new guard now preventing session token refresh for suspended users with valid cookies.) +Allow suspended users to keep their cookies, but disallow them to create/refresh access tokens. From 23ba81ca67064c674c9fe4690945b8c4e007bb3e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 15:14:08 +0200 Subject: [PATCH 11/24] Better haddocks. --- .../wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs index 921137c5ee6..5ca5cfe4754 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs @@ -147,7 +147,8 @@ revokeCookiesMatchingExcept u mself ids labels = do -- Remove stale cookies. Stale means either (1) cookie is expired, or -- (2) cookie creation time is further in the past than --- `env.suspendInactiveUsers` allows. +-- `optSettings.setSuspendInactiveUsers.suspendTimeout` in the brig +-- config allows. revokeAllStaleCookiesImpl :: ( Member SessionStore r, Member (Input AuthenticationSubsystemConfig) r, From aab85c01f3b4c992fdc1855091e06b0050c605fd Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 15:33:07 +0200 Subject: [PATCH 12/24] Better guard logic: reduce responsibilities of caller. --- services/brig/src/Brig/User/Auth.hs | 33 +++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index 4130ab8a484..3b02b41b9be 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -201,8 +201,7 @@ renewAccess uts at mcid = do (uid, ck) <- validateTokens uts at traverse_ (checkClientId uid) mcid lift . liftSem . Log.debug $ field "user" (toByteString uid) . field "action" (val "User.renewAccess") - catchSuspendInactiveUser uid ZAuth.Expired - catchSuspendedUsers uid + guardSuspendedOrInactive uid ZAuth.Expired mapExceptT liftSem $ do ck' <- nextCookie ck mcid at' <- lift $ newAccessToken (fromMaybe ck ck') at @@ -231,6 +230,20 @@ revokeAccess luid@(tUnqualified -> u) pw cc ll = do -------------------------------------------------------------------------------- -- Internal +guardSuspendedOrInactive :: + ( Member TinyLog r, + Member UserSubsystem r, + Member Events r, + Member UserStore r, + Member AuthenticationSubsystem r + ) => + UserId -> + e -> + ExceptT e (AppT r) () +guardSuspendedOrInactive u e = do + catchSuspendInactiveUser u e + catchSuspendedUsers u e + catchSuspendInactiveUser :: ( Member TinyLog r, Member UserSubsystem r, @@ -268,14 +281,15 @@ catchSuspendInactiveUser uid errval = do catchSuspendedUsers :: (Member UserStore r) => UserId -> - ExceptT ZAuth.Failure (AppT r) () -catchSuspendedUsers uid = do + e -> + ExceptT e (AppT r) () +catchSuspendedUsers uid e = do mb <- lift $ liftSem $ lookupStatus uid case mb of - Nothing -> throwE ZAuth.Invalid + Nothing -> throwE e Just Active -> pure () - Just Suspended -> throwE ZAuth.Invalid - Just Deleted -> throwE ZAuth.Invalid -- (does not happen, but if it did, this is what we'd want to do) + Just Suspended -> throwE e + Just Deleted -> throwE e -- (does not happen, but if it did, this is what we'd want to do) Just Ephemeral -> pure () Just PendingInvitation -> pure () @@ -300,10 +314,7 @@ newAccess :: Maybe CookieLabel -> ExceptT LoginError (AppT r) (Access u) newAccess uid cid ct cl = do - catchSuspendInactiveUser uid LoginSuspended - -- NB: no need to call `catchSuspendedUsers` here. `newAccess` is - -- called in 3 places (login, ssoLogin, legalHoldLogin), and all of - -- them reject suspended users before calling it. + guardSuspendedOrInactive uid LoginSuspended r <- lift $ liftSem $ newCookieLimited uid cid ct cl RevokeSameLabel case r of Left delay -> throwE $ LoginThrottled delay From c854b3d8919c7340daa8ac2db3a48cef9d154e0f Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 15:33:11 +0200 Subject: [PATCH 13/24] Integration tests: always test labels when testing error status. --- integration/test/Test/Apps.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index bc312e26246..9cc27a80dd3 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -643,3 +643,4 @@ testZauthAndApps = do refreshFails app cookie = renewToken app cookie `bindResponse` \resp -> do resp.status `shouldMatchInt` 403 + (resp.json %. "label") `shouldMatch` "invalid-credentials" From 414ce3ae7051cd5f833661458c611d9a5e71d1fc Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 15:38:02 +0200 Subject: [PATCH 14/24] Rewrite integration test: inline functions. --- integration/test/Test/Apps.hs | 52 ++++++++++++----------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 9cc27a80dd3..582c5248371 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -603,44 +603,26 @@ testTeamSizeWithApps (TaggedBool testInternalApi) = do testZauthAndApps :: (HasCallStack) => App () testZauthAndApps = do (owner, tid, []) <- createTeam OwnDomain 1 - (app, cookie) <- createIt owner tid - - refreshSucceeds app cookie - suspendApp app >> refreshFails app cookie - unsuspendApp app >> refreshSucceeds app cookie - where - createIt :: (HasCallStack, MakesValue owner) => owner -> String -> App (Value, String) - createIt owner tid = - createApp owner tid new `bindResponse` \resp -> do - resp.status `shouldMatchInt` 200 - app <- resp.json %. "user" - cookie <- resp.json %. "cookie" & asString - pure (app, cookie) - where - new :: NewApp = + (app, cookie) <- do + let new :: NewApp = def { name = "chappie", description = "some description of this app", category = "ai" } - suspendApp :: (HasCallStack, MakesValue app) => app -> App () - suspendApp app = - BrigI.setAccountStatus app "suspended" `bindResponse` \resp -> do - resp.status `shouldMatchInt` 200 - - unsuspendApp :: (HasCallStack, MakesValue app) => app -> App () - unsuspendApp app = - BrigI.setAccountStatus app "active" `bindResponse` \resp -> do - resp.status `shouldMatchInt` 200 - - refreshSucceeds :: (HasCallStack, MakesValue app) => app -> String -> App () - refreshSucceeds app cookie = - renewToken app cookie `bindResponse` \resp -> do - resp.status `shouldMatchInt` 200 - - refreshFails :: (HasCallStack, MakesValue app) => app -> String -> App () - refreshFails app cookie = - renewToken app cookie `bindResponse` \resp -> do - resp.status `shouldMatchInt` 403 - (resp.json %. "label") `shouldMatch` "invalid-credentials" + createApp owner tid new `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + app <- resp.json %. "user" + cookie <- resp.json %. "cookie" & asString + pure (app, cookie) + + renewToken app cookie >>= assertSuccess + + BrigI.setAccountStatus app "suspended" >>= assertSuccess + renewToken app cookie `bindResponse` \resp -> do + resp.status `shouldMatchInt` 403 + (resp.json %. "label") `shouldMatch` "invalid-credentials" + + BrigI.setAccountStatus app "active" >>= assertSuccess + renewToken app cookie >>= assertSuccess From 2e6f067a38df32f2656b11048494b79bb43bbd2e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 15:59:18 +0200 Subject: [PATCH 15/24] Undo change to original cookie revokation semantics. The code removed here was confused: we *don't need* to revoke cookies that have expired because they will not be considered valid credentials; and we *must not* change the way suspension on inactivity works. Now the PR should apply the same rules as before when deciding suspension due to inactivity. --- .../src/Wire/AuthenticationSubsystem.hs | 1 - .../Wire/AuthenticationSubsystem/Cookie.hs | 30 ------------------- .../AuthenticationSubsystem/Interpreter.hs | 1 - services/brig/src/Brig/API/User.hs | 16 +--------- 4 files changed, 1 insertion(+), 47 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs index 49d778e4e12..b4eff9d78da 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs @@ -78,7 +78,6 @@ data AuthenticationSubsystem m a where SameLabelPolicy -> AuthenticationSubsystem m (Either RetryAfter (Cookie (ZAuth.Token t))) RevokeCookies :: UserId -> [CookieId] -> [CookieLabel] -> AuthenticationSubsystem m () - RevokeAllStaleCookies :: UserId -> AuthenticationSubsystem m () -- Verification Codes EnforceVerificationCodeEither :: Local UserId -> Maybe Code.Value -> VerificationAction -> AuthenticationSubsystem m (Either VerificationCodeError ()) -- For testing diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs index 5ca5cfe4754..01543d47073 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Cookie.hs @@ -19,14 +19,12 @@ module Wire.AuthenticationSubsystem.Cookie where import Data.Id import Data.RetryAfter -import Data.Time import Data.ZAuth.CryptoSign (CryptoSign) import Data.ZAuth.Token import Imports import Polysemy import Polysemy.Error import Polysemy.Input -import Util.Timeout import Wire.API.User.Auth import Wire.API.UserEvent (UserEvent (UserSessionRefreshSuggested)) import Wire.AuthenticationSubsystem @@ -144,31 +142,3 @@ revokeCookiesMatchingExcept u mself ids labels = do && ( c.cookieId `elem` ids || maybe False (`elem` labels) c.cookieLabel ) - --- Remove stale cookies. Stale means either (1) cookie is expired, or --- (2) cookie creation time is further in the past than --- `optSettings.setSuspendInactiveUsers.suspendTimeout` in the brig --- config allows. -revokeAllStaleCookiesImpl :: - ( Member SessionStore r, - Member (Input AuthenticationSubsystemConfig) r, - Member Now r - ) => - UserId -> - Sem r () -revokeAllStaleCookiesImpl uid = do - now :: UTCTime <- Now.get - mbSuspendAge <- (.suspendInactiveUsers) <$> input - - let dead :: Cookie () -> Bool - dead c = cookieExpired || userInactive - where - cookieExpired = c.cookieExpires < now - userInactive = - maybe - False - (\suspendAge -> c.cookieCreated < addUTCTime (-(timeoutDiff suspendAge)) now) - mbSuspendAge - - cc <- filter dead <$> SessionStore.listCookies uid - SessionStore.deleteCookies uid cc diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs index 93abd3faa08..0bf6b57f0cf 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs @@ -107,7 +107,6 @@ interpretAuthenticationSubsystem userSubsystemInterpreter = NewCookie uid mcid typ mLabel policy -> newCookieImpl uid mcid typ mLabel policy NewCookieLimited uid mcid typ mLabel policy -> runError $ newCookieLimitedImpl uid mcid typ mLabel policy RevokeCookies uid ids labels -> revokeCookiesImpl uid ids labels - RevokeAllStaleCookies uid -> revokeAllStaleCookiesImpl uid -- Verification Codes EnforceVerificationCodeEither luid mCode action -> runError $ enforceVerificationCodeImpl luid mCode action -- Testing diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 6f5066a5d1e..e27c603ec14 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -120,7 +120,6 @@ import Wire.API.UserEvent import Wire.ActivationCodeStore import Wire.ActivationCodeStore qualified as ActivationCode import Wire.AuthenticationSubsystem (AuthenticationSubsystem, internalLookupPasswordResetCode) -import Wire.AuthenticationSubsystem qualified as Auth import Wire.BackendNotificationQueueAccess import Wire.BlockListStore as BlockListStore import Wire.ClientStore (ClientStore) @@ -654,8 +653,7 @@ changeSingleAccountStatus uid status = do changeSingleAccountStatusInternal :: ( Member UserSubsystem r, Member Events r, - Member UserStore r, - Member AuthenticationSubsystem r + Member UserStore r ) => AccountStatus -> (UserId -> UserEvent) -> @@ -668,18 +666,6 @@ changeSingleAccountStatusInternal status ev u = do -- users. -- -- Evidence: `git grep -Hn --color=never 'UserToken\b' | grep libs/wire-api/src/Wire/API/Routes/Public/`. - -- - -- Having that said, we need to remove cookies here that are no - -- longer valid for login/inactivity handling (including both - -- expired cookies and cookies older than the inactivity threshold), - -- otherwise /login considers the user inactive, see - -- 'mustSuspendInactiveUser'. - -- - -- The intuition is that every change of account status can be - -- considered an account activity, so users that have their status - -- changed recently should not be considered inactive, even if they - -- haven't taken any action themselves. - Auth.revokeAllStaleCookies u UserStore.updateAccountStatus u status User.internalUpdateSearchIndex u Events.generateUserEvent u Nothing (ev u) From 267353ef535b28677c7d14eca18e714257f0c4e0 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 15 May 2026 16:33:03 +0200 Subject: [PATCH 16/24] Remove redundant constraints. --- services/brig/src/Brig/API/Internal.hs | 4 +--- services/brig/src/Brig/API/User.hs | 6 ++---- services/brig/src/Brig/Team/API.hs | 4 ---- services/brig/src/Brig/User/Auth.hs | 6 ++---- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 1dc1a21cfbc..b9d29498367 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -315,7 +315,6 @@ teamsAPI :: Member (Polysemy.Error UserSubsystemError) r, Member Events r, Member (Input (Local ())) r, - Member AuthenticationSubsystem r, Member IndexedUserStore r ) => ServerT BrigIRoutes.TeamsAPI (Handler r) @@ -783,8 +782,7 @@ getPasswordResetCode email = changeAccountStatusH :: ( Member UserSubsystem r, Member Events r, - Member UserStore r, - Member AuthenticationSubsystem r + Member UserStore r ) => UserId -> AccountStatusUpdate -> diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index e27c603ec14..5bdbb19b159 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -626,8 +626,7 @@ changeAccountStatus :: ( Member (Concurrency 'Unsafe) r, Member UserSubsystem r, Member Events r, - Member UserStore r, - Member AuthenticationSubsystem r + Member UserStore r ) => NonEmpty UserId -> AccountStatus -> @@ -639,8 +638,7 @@ changeAccountStatus usrs status = do changeSingleAccountStatus :: ( Member UserSubsystem r, Member Events r, - Member UserStore r, - Member AuthenticationSubsystem r + Member UserStore r ) => UserId -> AccountStatus -> diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index c66abd7c3ab..a0e2f4116cf 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -69,7 +69,6 @@ import Wire.API.Team.Member qualified as Teams import Wire.API.Team.Permission (Perm (AddTeamMember)) import Wire.API.Team.Size import Wire.API.User hiding (fromEmail) -import Wire.AuthenticationSubsystem qualified as Auth import Wire.BlockListStore import Wire.EmailSubsystem.Interpreter (renderInvitationUrl) import Wire.Error @@ -373,7 +372,6 @@ suspendTeam :: Member Events r, Member TinyLog r, Member InvitationStore r, - Member Auth.AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -394,7 +392,6 @@ unsuspendTeam :: Member UserSubsystem r, Member TeamSubsystem r, Member Events r, - Member Auth.AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -413,7 +410,6 @@ changeTeamAccountStatuses :: Member TeamSubsystem r, Member UserSubsystem r, Member Events r, - Member Auth.AuthenticationSubsystem r, Member UserStore r ) => TeamId -> diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index 3b02b41b9be..a58d34044fc 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -234,8 +234,7 @@ guardSuspendedOrInactive :: ( Member TinyLog r, Member UserSubsystem r, Member Events r, - Member UserStore r, - Member AuthenticationSubsystem r + Member UserStore r ) => UserId -> e -> @@ -248,8 +247,7 @@ catchSuspendInactiveUser :: ( Member TinyLog r, Member UserSubsystem r, Member Events r, - Member UserStore r, - Member AuthenticationSubsystem r + Member UserStore r ) => UserId -> e -> From a4459078521289e667b45fbc5ba104b0b55f55d2 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 18 May 2026 09:01:39 +0200 Subject: [PATCH 17/24] Fix: catchSuspendedUsers should do nothing on non-existing users. --- services/brig/src/Brig/User/Auth.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index a58d34044fc..a73fe6b226b 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -284,7 +284,7 @@ catchSuspendedUsers :: catchSuspendedUsers uid e = do mb <- lift $ liftSem $ lookupStatus uid case mb of - Nothing -> throwE e + Nothing -> pure () Just Active -> pure () Just Suspended -> throwE e Just Deleted -> throwE e -- (does not happen, but if it did, this is what we'd want to do) From 98a20a0f9e344274ddd623435edaa335614f479c Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 18 May 2026 09:02:20 +0200 Subject: [PATCH 18/24] Haddocks. --- services/brig/src/Brig/User/Auth.hs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index a73fe6b226b..589dfbda199 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -271,11 +271,13 @@ catchSuspendInactiveUser uid errval = do -- | Suspended users are not allowed to pick up new session tokens, -- even if they have a valid cookie. -- --- This does *not* change behavior for existing apps, because their --- observations are the same: before, refreshing access tokens failed --- because the cookie was invalid, now it fails with the same status --- code if the user is suspended, whether there are valid cookies or --- not. +-- This does not throw if the user is not found; that case must be +-- handled by the caller. +-- +-- This does *not* change observable behavior for existing users: +-- before, refreshing access tokens failed because the cookie was +-- invalid, now it fails with the same status code if the user is +-- suspended, whether there are valid cookies or not. catchSuspendedUsers :: (Member UserStore r) => UserId -> From 0cef09dbaf68656d3d7f4c80e25b4084b57710f4 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 18 May 2026 09:07:56 +0200 Subject: [PATCH 19/24] Rm dead code. --- .../src/Wire/AuthenticationSubsystem/Config.hs | 4 +--- libs/wire-subsystems/test/unit/Wire/MiniBackend.hs | 3 +-- services/brig/src/Brig/CanonicalInterpreter.hs | 5 ++--- services/brig/test/integration/API/User.hs | 3 +-- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs index dca16f0bc37..0c205f8ce06 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs @@ -26,7 +26,6 @@ import Data.Vector qualified as Vector import Data.ZAuth.Creation qualified as ZC import Imports import Sodium.Crypto.Sign -import Util.Timeout import Wire.API.Allowlists (AllowlistEmailDomains) import Wire.AuthenticationSubsystem.Cookie.Limit @@ -36,8 +35,7 @@ data AuthenticationSubsystemConfig = AuthenticationSubsystemConfig zauthEnv :: ZAuthEnv, userCookieRenewAge :: Integer, userCookieLimit :: Int, - userCookieThrottle :: CookieThrottle, - suspendInactiveUsers :: Maybe Timeout + userCookieThrottle :: CookieThrottle } data ZAuthSettings = ZAuthSettings diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 9cadf92eb22..88624c7019f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -461,8 +461,7 @@ defaultAuthenticationSubsystemConfig = local = defaultLocalDomain, userCookieRenewAge = 2, userCookieLimit = 5, - userCookieThrottle = StdDevThrottle 5 3, - suspendInactiveUsers = Nothing + userCookieThrottle = StdDevThrottle 5 3 } defaultLocalDomain :: Local () diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 3018028335a..06eb36f10ff 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -28,7 +28,7 @@ import Brig.Effects.SFT (SFT, interpretSFT) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Effects.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra) import Brig.IO.Intra (runEvents) -import Brig.Options (Settings (consumableNotifications), SuspendInactiveUsers (..), federationDomainConfigs, federationStrategy) +import Brig.Options (Settings (consumableNotifications), federationDomainConfigs, federationStrategy) import Brig.Options qualified as Opt import Brig.Template (InvitationUrlTemplates) import Brig.User.Search.Index (IndexEnv (..)) @@ -352,8 +352,7 @@ runBrigToIO e (AppT ma) = do local = localUnit, userCookieRenewAge = e.settings.userCookieRenewAge, userCookieLimit = e.settings.userCookieLimit, - userCookieThrottle = e.settings.userCookieThrottle, - suspendInactiveUsers = suspendTimeout <$> e.settings.suspendInactiveUsers + userCookieThrottle = e.settings.userCookieThrottle } mainESEnv = e.indexEnv ^. to idxElastic indexedUserStoreConfig = diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index 80281eeb93f..7c88c057abf 100644 --- a/services/brig/test/integration/API/User.hs +++ b/services/brig/test/integration/API/User.hs @@ -66,8 +66,7 @@ tests conf fbc p b c ch g n aws db userJournalWatcher = do local = localUnit, userCookieRenewAge = conf.settings.userCookieRenewAge, userCookieLimit = conf.settings.userCookieLimit, - userCookieThrottle = conf.settings.userCookieThrottle, - suspendInactiveUsers = Nothing + userCookieThrottle = conf.settings.userCookieThrottle } pure $ testGroup From e6372bf823ab11c847a24d8eac547f16ec5cf972 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 19 May 2026 11:06:36 +0200 Subject: [PATCH 20/24] Fix ancient (and previously harmless) bug in brig integration tests. --- services/brig/test/integration/API/User/Auth.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/brig/test/integration/API/User/Auth.hs b/services/brig/test/integration/API/User/Auth.hs index 9cd8047a67e..b10e814ca9c 100644 --- a/services/brig/test/integration/API/User/Auth.hs +++ b/services/brig/test/integration/API/User/Auth.hs @@ -1003,7 +1003,7 @@ testSuspendInactiveUsers config brig cookieType endPoint = do have <- retrying (exponentialBackoff 200000 <> limitRetries 6) - (\_ have -> pure $ have == Suspended) + (\_ have -> pure $ have /= want) (\_ -> getStatus brig (userId user)) let errmsg = "testSuspendInactiveUsers: " <> show (want, cookieType, endPoint, waitTime, suspendAge) liftIO $ HUnit.assertEqual errmsg want have From 704b67728830543bce9c1b3417fd93b11ab9ca3c Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 19 May 2026 11:10:02 +0200 Subject: [PATCH 21/24] Adjust brig integration test to new behavior. --- services/brig/test/integration/API/User/Auth.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/brig/test/integration/API/User/Auth.hs b/services/brig/test/integration/API/User/Auth.hs index b10e814ca9c..f837984842d 100644 --- a/services/brig/test/integration/API/User/Auth.hs +++ b/services/brig/test/integration/API/User/Auth.hs @@ -1010,8 +1010,11 @@ testSuspendInactiveUsers config brig cookieType endPoint = do assertStatus Suspended setStatus brig (userId user) Active assertStatus Active + + -- if the user has been inactive for too long due to suspended + -- state, so it gets re-suspended on the next login attempt. login brig (emailLogin email defPassword Nothing) cookieType - !!! const 200 === statusCode + !!! const 403 === statusCode ------------------------------------------------------------------------------- -- Cookie Management From 2da2222031bb8eaf674eb93904c9bcea2228aaff Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 19 May 2026 16:35:11 +0200 Subject: [PATCH 22/24] Keep track of user inactivity in postgres and without cookies. --- ...260519120000-create-last-user-activity.sql | 4 + .../src/Wire/AuthenticationSubsystem.hs | 3 + .../Wire/AuthenticationSubsystem/Config.hs | 4 +- .../AuthenticationSubsystem/Interpreter.hs | 49 +++++++++++ .../src/Wire/UserActivityStore.hs | 32 ++++++++ .../src/Wire/UserActivityStore/Postgres.hs | 64 +++++++++++++++ .../InterpreterSpec.hs | 82 +++++++++++++++++-- .../test/unit/Wire/MiniBackend.hs | 6 +- .../test/unit/Wire/MockInterpreters.hs | 1 + .../MockInterpreters/UserActivityStore.hs | 40 +++++++++ .../Wire/MockInterpreters/UserSubsystem.hs | 2 +- libs/wire-subsystems/wire-subsystems.cabal | 3 + services/brig/src/Brig/API/Auth.hs | 2 - services/brig/src/Brig/API/Internal.hs | 10 ++- services/brig/src/Brig/API/Public.hs | 4 + services/brig/src/Brig/API/User.hs | 18 +++- .../brig/src/Brig/CanonicalInterpreter.hs | 8 +- .../brig/src/Brig/InternalEvent/Process.hs | 2 + services/brig/src/Brig/Team/API.hs | 4 + services/brig/src/Brig/User/Auth.hs | 69 ++-------------- services/brig/src/Brig/User/Auth/Cookie.hs | 25 ------ services/brig/test/integration/API/User.hs | 3 +- .../brig/test/integration/API/User/Auth.hs | 5 +- 23 files changed, 330 insertions(+), 110 deletions(-) create mode 100644 libs/wire-subsystems/postgres-migrations/20260519120000-create-last-user-activity.sql create mode 100644 libs/wire-subsystems/src/Wire/UserActivityStore.hs create mode 100644 libs/wire-subsystems/src/Wire/UserActivityStore/Postgres.hs create mode 100644 libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserActivityStore.hs diff --git a/libs/wire-subsystems/postgres-migrations/20260519120000-create-last-user-activity.sql b/libs/wire-subsystems/postgres-migrations/20260519120000-create-last-user-activity.sql new file mode 100644 index 00000000000..862b33d53ab --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260519120000-create-last-user-activity.sql @@ -0,0 +1,4 @@ +CREATE TABLE last_user_activity ( + user_id uuid PRIMARY KEY, + active_at timestamptz NOT NULL +); diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs index b4eff9d78da..40d54dc0fb1 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs @@ -78,6 +78,9 @@ data AuthenticationSubsystem m a where SameLabelPolicy -> AuthenticationSubsystem m (Either RetryAfter (Cookie (ZAuth.Token t))) RevokeCookies :: UserId -> [CookieId] -> [CookieLabel] -> AuthenticationSubsystem m () + -- Inactivity tracking + RecordUserActivity :: UserId -> AuthenticationSubsystem m () + CheckAndSuspendInactiveUser :: UserId -> e -> AuthenticationSubsystem m (Either e ()) -- Verification Codes EnforceVerificationCodeEither :: Local UserId -> Maybe Code.Value -> VerificationAction -> AuthenticationSubsystem m (Either VerificationCodeError ()) -- For testing diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs index 0c205f8ce06..d7c8ae03b5c 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs @@ -21,6 +21,7 @@ import Data.Aeson import Data.List.NonEmpty (NonEmpty, nonEmpty) import Data.List.NonEmpty qualified as NonEmpty import Data.Qualified +import Data.Time.Clock (NominalDiffTime) import Data.Vector (Vector) import Data.Vector qualified as Vector import Data.ZAuth.Creation qualified as ZC @@ -35,7 +36,8 @@ data AuthenticationSubsystemConfig = AuthenticationSubsystemConfig zauthEnv :: ZAuthEnv, userCookieRenewAge :: Integer, userCookieLimit :: Int, - userCookieThrottle :: CookieThrottle + userCookieThrottle :: CookieThrottle, + suspendInactiveUsersTimeout :: Maybe NominalDiffTime } data ZAuthSettings = ZAuthSettings diff --git a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs index 0bf6b57f0cf..c992ce04f2f 100644 --- a/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs @@ -42,6 +42,7 @@ import Wire.API.Allowlists qualified as AllowLists import Wire.API.Team.Feature import Wire.API.User import Wire.API.User.Password +import Wire.API.UserEvent (UserEvent (UserSuspended)) import Wire.AuthenticationSubsystem import Wire.AuthenticationSubsystem.Config import Wire.AuthenticationSubsystem.Cookie @@ -59,6 +60,8 @@ import Wire.Sem.Now import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) import Wire.SessionStore +import Wire.UserActivityStore (UserActivityStore) +import Wire.UserActivityStore qualified as UserActivityStore import Wire.UserKeyStore import Wire.UserStore (UserStore) import Wire.UserStore qualified as UserStore @@ -81,6 +84,7 @@ interpretAuthenticationSubsystem :: Member PasswordStore r, Member EmailSubsystem r, Member UserStore r, + Member UserActivityStore r, Member RateLimit r, Member CryptoSign r, Member Random r, @@ -107,6 +111,9 @@ interpretAuthenticationSubsystem userSubsystemInterpreter = NewCookie uid mcid typ mLabel policy -> newCookieImpl uid mcid typ mLabel policy NewCookieLimited uid mcid typ mLabel policy -> runError $ newCookieLimitedImpl uid mcid typ mLabel policy RevokeCookies uid ids labels -> revokeCookiesImpl uid ids labels + -- Inactivity tracking + RecordUserActivity uid -> recordUserActivityImpl uid + CheckAndSuspendInactiveUser uid er -> checkAndSuspendInactiveUserImpl uid er -- Verification Codes EnforceVerificationCodeEither luid mCode action -> runError $ enforceVerificationCodeImpl luid mCode action -- Testing @@ -415,6 +422,48 @@ verifyUserPasswordErrorImpl (tUnqualified -> uid) password = do unlessM (fst <$> verifyUserPasswordImpl uid password) do throw AuthenticationSubsystemBadCredentials +recordUserActivityImpl :: + ( Member Now r, + Member UserActivityStore r + ) => + UserId -> + Sem r () +recordUserActivityImpl uid = do + now <- Now.get + UserActivityStore.updateLastActivity uid now + +checkAndSuspendInactiveUserImpl :: + ( Member (Input AuthenticationSubsystemConfig) r, + Member UserActivityStore r, + Member Now r, + Member UserStore r, + Member UserSubsystem r, + Member Events r, + Member TinyLog r + ) => + UserId -> + e -> + Sem r (Either e ()) +checkAndSuspendInactiveUserImpl uid er = + inputs (.suspendInactiveUsersTimeout) >>= \case + Nothing -> pure (Right ()) + Just timeout -> do + UserActivityStore.getLastActivity uid >>= \case + Nothing -> pure (Right ()) + Just lastActivity -> do + now <- Now.get + if diffUTCTime now lastActivity > timeout + then do + Log.warn $ + msg (val "Suspending user due to inactivity") + . field "user" (toByteString uid) + . field "action" ("user.suspend" :: String) + UserStore.updateAccountStatus uid Suspended + User.internalUpdateSearchIndex uid + generateUserEvent uid Nothing (UserSuspended uid) + pure (Left er) + else pure (Right ()) + enforceVerificationCodeImpl :: forall r. ( Member GalleyAPIAccess r, diff --git a/libs/wire-subsystems/src/Wire/UserActivityStore.hs b/libs/wire-subsystems/src/Wire/UserActivityStore.hs new file mode 100644 index 00000000000..93e5d95af46 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/UserActivityStore.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.UserActivityStore where + +import Data.Id +import Data.Time.Clock +import Imports +import Polysemy + +data UserActivityStore m a where + GetLastActivity :: UserId -> UserActivityStore m (Maybe UTCTime) + UpdateLastActivity :: UserId -> UTCTime -> UserActivityStore m () + DeleteLastActivity :: UserId -> UserActivityStore m () + +makeSem ''UserActivityStore diff --git a/libs/wire-subsystems/src/Wire/UserActivityStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserActivityStore/Postgres.hs new file mode 100644 index 00000000000..198a19221b7 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/UserActivityStore/Postgres.hs @@ -0,0 +1,64 @@ +{-# LANGUAGE QuasiQuotes #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.UserActivityStore.Postgres + ( interpretUserActivityStoreToPostgres, + ) +where + +import Data.Id +import Data.Time.Clock +import Hasql.TH +import Imports +import Polysemy +import Wire.Postgres +import Wire.UserActivityStore + +interpretUserActivityStoreToPostgres :: + (PGConstraints r) => + InterpreterFor UserActivityStore r +interpretUserActivityStoreToPostgres = interpret $ \case + GetLastActivity uid -> getLastActivityImpl uid + UpdateLastActivity uid t -> updateLastActivityImpl uid t + DeleteLastActivity uid -> deleteLastActivityImpl uid + +getLastActivityImpl :: (PGConstraints r) => UserId -> Sem r (Maybe UTCTime) +getLastActivityImpl uid = + runStatement (toUUID uid) $ + [maybeStatement| + SELECT active_at :: timestamptz + FROM last_user_activity + WHERE user_id = $1 :: uuid + |] + +updateLastActivityImpl :: (PGConstraints r) => UserId -> UTCTime -> Sem r () +updateLastActivityImpl uid t = + runStatement (toUUID uid, t) $ + [resultlessStatement| + INSERT INTO last_user_activity (user_id, active_at) + VALUES ($1 :: uuid, $2 :: timestamptz) + ON CONFLICT (user_id) DO UPDATE SET active_at = EXCLUDED.active_at + |] + +deleteLastActivityImpl :: (PGConstraints r) => UserId -> Sem r () +deleteLastActivityImpl uid = + runStatement (toUUID uid) $ + [resultlessStatement| + DELETE FROM last_user_activity WHERE user_id = $1 :: uuid + |] diff --git a/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs index ac5139937c9..8a7215469fc 100644 --- a/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs @@ -1,4 +1,4 @@ -{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns -Wno-ambiguous-fields #-} -- This file is part of the Wire Server implementation. -- @@ -67,8 +67,10 @@ import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.SessionStore import Wire.StoredUser +import Wire.UserActivityStore (UserActivityStore) import Wire.UserKeyStore import Wire.UserStore +import Wire.UserStore qualified as UserStore import Wire.VerificationCode import Wire.VerificationCodeGen import Wire.VerificationCodeStore @@ -99,6 +101,8 @@ type AllEffects = TinyLog, EmailSubsystem, UserStore, + UserActivityStore, + State (Map UserId UTCTime), UserKeyStore, State [MiniEvent], State (Map EmailAddress [SentMail]), @@ -106,11 +110,11 @@ type AllEffects = ] runAllEffects :: Domain -> [StoredUser] -> Map UserId Password -> Maybe [Text] -> Sem AllEffects a -> Either AuthenticationSubsystemError a -runAllEffects domain users passwords emailDomains action = snd $ runAllEffectsWithEventStateAndFeatures domain users passwords emailDomains def action +runAllEffects domain users passwords emailDomains action = snd $ runAllEffectsWithEventStateAndFeatures domain users passwords emailDomains def Nothing action runAllEffectsWithEventState :: Domain -> [StoredUser] -> Map UserId Password -> Maybe [Text] -> Sem AllEffects a -> ([MiniEvent], Either AuthenticationSubsystemError a) runAllEffectsWithEventState localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains = - runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains def + runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains def Nothing runAllEffectsWithEventStateAndFeatures :: Domain -> @@ -118,19 +122,23 @@ runAllEffectsWithEventStateAndFeatures :: Map UserId Password -> Maybe [Text] -> AllTeamFeatures -> + Maybe NominalDiffTime -> Sem AllEffects a -> ([MiniEvent], Either AuthenticationSubsystemError a) -runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains galleyFeatures = +runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains galleyFeatures mSuspendTimeout = let cfg = defaultAuthenticationSubsystemConfig { allowlistEmailDomains = AllowlistEmailDomains <$> mAllowedEmailDomains, - local = toLocalUnsafe localDomain () + local = toLocalUnsafe localDomain (), + suspendInactiveUsersTimeout = mSuspendTimeout } in run . evalState mempty . evalState mempty . runState mempty . runInMemoryUserKeyStoreIntepreterWithStoredUsers preexistingUsers + . evalState (mempty :: Map UserId UTCTime) + . inMemoryUserActivityStoreInterpreter . runInMemoryUserStoreInterpreter preexistingUsers preexistingPasswords . inMemoryEmailSubsystemInterpreter . discardTinyLogs @@ -566,7 +574,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do luid = toLocalUnsafe testDomain user.id features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def (_, Right result) = - runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do + runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do code <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing enforceVerificationCodeEither luid (Just code.codeValue) action in result === Right () @@ -577,7 +585,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do luid = toLocalUnsafe testDomain user.id features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def (_, Right result) = - runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do + runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do _ <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing enforceVerificationCodeEither luid (Just wrongCode) action in if status == FeatureStatusEnabled @@ -590,7 +598,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do luid = toLocalUnsafe testDomain user.id features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def (_, Right result) = - runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do + runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do _ <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing enforceVerificationCodeEither luid Nothing action in if status == FeatureStatusEnabled @@ -603,13 +611,69 @@ spec = describe "AuthenticationSubsystem.Interpreter" do luid = toLocalUnsafe testDomain user.id features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def (_, Right result) = - runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do + runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do code <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing enforceVerificationCodeEither luid (Just code.codeValue) action in if status == FeatureStatusEnabled then result === Left VerificationCodeNoEmail else result === Right () + describe "checkAndSuspendInactiveUser" do + let timeout = 3600 :: NominalDiffTime + + prop "suspends user after timeout expires" $ \userNoEmail -> + let user = (userNoEmail :: StoredUser) {status = Just Active} + uid = user.id + Right finalStatus = + snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do + recordUserActivity uid + passTime (timeout + 1) + _ <- checkAndSuspendInactiveUser uid False + UserStore.lookupStatus uid + in finalStatus === Just Suspended + + prop "returns Left when inactive" $ \userNoEmail -> + let user = (userNoEmail :: StoredUser) {status = Just Active} + uid = user.id + Right result = + snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do + recordUserActivity uid + passTime (timeout + 1) + checkAndSuspendInactiveUser uid False + in result === Left False + + prop "does not suspend user within timeout" $ \userNoEmail -> + let user = (userNoEmail :: StoredUser) {status = Just Active} + uid = user.id + Right finalStatus = + snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do + recordUserActivity uid + passTime (timeout - 1) + _ <- checkAndSuspendInactiveUser uid False + UserStore.lookupStatus uid + in finalStatus === Just Active + + prop "does not suspend if feature is disabled" $ \userNoEmail -> + let user = (userNoEmail :: StoredUser) {status = Just Active} + uid = user.id + Right finalStatus = + snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def Nothing $ do + recordUserActivity uid + passTime (timeout + 1) + _ <- checkAndSuspendInactiveUser uid False + UserStore.lookupStatus uid + in finalStatus === Just Active + + prop "does not suspend if no activity record exists" $ \userNoEmail -> + let user = (userNoEmail :: StoredUser) {status = Just Active} + uid = user.id + Right finalStatus = + snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do + passTime (timeout + 1) + _ <- checkAndSuspendInactiveUser uid False + UserStore.lookupStatus uid + in finalStatus === Just Active + describe "randomConnId" $ do it "generates different connection ids" $ do let connIds = run . runRandomPure $ replicateM 100 randomConnId diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 88624c7019f..db55cbae03b 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -139,6 +139,7 @@ import Wire.TeamCollaboratorsSubsystem import Wire.TeamCollaboratorsSubsystem.Interpreter import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem.GalleyAPI +import Wire.UserActivityStore (UserActivityStore) import Wire.UserClientIndexStore (UserClientIndexStore) import Wire.UserGroupStore (UserGroupStore) import Wire.UserKeyStore @@ -280,6 +281,7 @@ type MiniBackendLowerEffects = ActivationCodeStore, BlockListStore, UserStore, + UserActivityStore, AppStore, TeamCollaboratorsStore, UserKeyStore, @@ -343,6 +345,7 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . inMemoryUserKeyStoreInterpreter . inMemoryTeamCollaboratorsStoreInterpreter . inMemoryAppStoreInterpreter + . noOpUserActivityStoreInterpreter . inMemoryUserStoreInterpreter . inMemoryBlockListStoreInterpreter . inMemoryActivationCodeStoreInterpreter @@ -461,7 +464,8 @@ defaultAuthenticationSubsystemConfig = local = defaultLocalDomain, userCookieRenewAge = 2, userCookieLimit = 5, - userCookieThrottle = StdDevThrottle 5 3 + userCookieThrottle = StdDevThrottle 5 3, + suspendInactiveUsersTimeout = Nothing } defaultLocalDomain :: Local () diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index c1bdcdb2628..dfe70ad9f0a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -52,6 +52,7 @@ import Wire.MockInterpreters.SessionStore as MockInterpreters import Wire.MockInterpreters.SparAPIAccess as MockInterpreters import Wire.MockInterpreters.TeamCollaboratorsStore as MockInterpreters import Wire.MockInterpreters.TinyLog as MockInterpreters +import Wire.MockInterpreters.UserActivityStore as MockInterpreters import Wire.MockInterpreters.UserGroupStore as MockInterpreters import Wire.MockInterpreters.UserKeyStore as MockInterpreters import Wire.MockInterpreters.UserStore as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserActivityStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserActivityStore.hs new file mode 100644 index 00000000000..48cee0cbfb0 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserActivityStore.hs @@ -0,0 +1,40 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MockInterpreters.UserActivityStore where + +import Data.Id +import Data.Map.Strict qualified as Map +import Data.Time.Clock +import Imports +import Polysemy +import Polysemy.State +import Wire.UserActivityStore + +inMemoryUserActivityStoreInterpreter :: + (Member (State (Map UserId UTCTime)) r) => + InterpreterFor UserActivityStore r +inMemoryUserActivityStoreInterpreter = interpret $ \case + GetLastActivity uid -> gets (Map.lookup uid) + UpdateLastActivity uid t -> modify (Map.insert uid t) + DeleteLastActivity uid -> modify (Map.delete uid) + +noOpUserActivityStoreInterpreter :: InterpreterFor UserActivityStore r +noOpUserActivityStoreInterpreter = interpret $ \case + GetLastActivity _ -> pure Nothing + UpdateLastActivity _ _ -> pure () + DeleteLastActivity _ -> pure () diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index c5feaeae032..783eaedbbf9 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -86,7 +86,7 @@ inMemoryUserSubsystemInterpreter = BlockListInsert _ -> error "BlockListInsert: implement on demand (userSubsystemInterpreter)" UpdateTeamSearchVisibilityInbound _ -> error "UpdateTeamSearchVisibilityInbound: implement on demand (userSubsystemInterpreter)" AcceptTeamInvitation {} -> error "AcceptTeamInvitation: implement on demand (userSubsystemInterpreter)" - InternalUpdateSearchIndex _ -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" + InternalUpdateSearchIndex _ -> pure () InternalFindTeamInvitation {} -> error "InternalFindTeamInvitation: implement on demand (userSubsystemInterpreter)" GetUserExportData _ -> error "GetUserExportData: implement on demand (userSubsystemInterpreter)" RemoveEmailEither _ -> error "RemoveEmailEither: implement on demand (userSubsystemInterpreter)" diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 0f4c739c004..cd363eb228d 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -439,6 +439,8 @@ library Wire.TeamSubsystem.GalleyAPI Wire.TeamSubsystem.Interpreter Wire.TeamSubsystem.Util + Wire.UserActivityStore + Wire.UserActivityStore.Postgres Wire.UserClientIndexStore Wire.UserClientIndexStore.Cassandra Wire.UserGroupStore @@ -621,6 +623,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.SparAPIAccess Wire.MockInterpreters.TeamCollaboratorsStore Wire.MockInterpreters.TinyLog + Wire.MockInterpreters.UserActivityStore Wire.MockInterpreters.UserGroupStore Wire.MockInterpreters.UserKeyStore Wire.MockInterpreters.UserStore diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index 0614ae647f5..e8fca530830 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -100,8 +100,6 @@ accessH mcid ut' mat' = do access :: ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, UserTokenLike u, AccessTokenLike a, AccessTokenType u ~ a, diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index b9d29498367..de306072da6 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -130,6 +130,7 @@ import Wire.Sem.Random (Random) import Wire.SparAPIAccess (SparAPIAccess) import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) +import Wire.UserActivityStore import Wire.UserGroupSubsystem import Wire.UserKeyStore import Wire.UserStore as UserStore @@ -156,6 +157,7 @@ servantSitemap :: Member TeamSubsystem r, Member TeamInvitationSubsystem r, Member UserStore r, + Member UserActivityStore r, Member InvitationStore r, Member UserKeyStore r, Member Rpc r, @@ -239,6 +241,7 @@ accountAPI :: Member UserKeyStore r, Member (Input (Local ())) r, Member UserStore r, + Member UserActivityStore r, Member TinyLog r, Member EmailSubsystem r, Member PropertySubsystem r, @@ -315,7 +318,8 @@ teamsAPI :: Member (Polysemy.Error UserSubsystemError) r, Member Events r, Member (Input (Local ())) r, - Member IndexedUserStore r + Member IndexedUserStore r, + Member AuthenticationSubsystem r ) => ServerT BrigIRoutes.TeamsAPI (Handler r) teamsAPI = @@ -623,6 +627,7 @@ deleteUserNoAuthH :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, Member UserStore r, + Member UserActivityStore r, Member TinyLog r, Member UserKeyStore r, Member Events r, @@ -782,7 +787,8 @@ getPasswordResetCode email = changeAccountStatusH :: ( Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => UserId -> AccountStatusUpdate -> diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index b22f0ff6df6..0aec3222000 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -196,6 +196,7 @@ import Wire.TeamCollaboratorsSubsystem import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem +import Wire.UserActivityStore import Wire.UserGroupSubsystem (UserGroupSubsystem) import Wire.UserGroupSubsystem qualified as UserGroup import Wire.UserKeyStore @@ -395,6 +396,7 @@ servantSitemap :: Member UserKeyStore r, Member ActivationCodeStore r, Member UserStore r, + Member UserActivityStore r, Member (Input InvitationUrlTemplates) r, Member UserSubsystem r, Member TeamInvitationSubsystem r, @@ -1424,6 +1426,7 @@ deleteSelfUser :: Member UserKeyStore r, Member NotificationSubsystem r, Member UserStore r, + Member UserActivityStore r, Member EmailSubsystem r, Member UserSubsystem r, Member VerificationCodeSubsystem r, @@ -1445,6 +1448,7 @@ verifyDeleteUser :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, Member UserStore r, + Member UserActivityStore r, Member TinyLog r, Member UserKeyStore r, Member VerificationCodeSubsystem r, diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 5bdbb19b159..158828097f6 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -119,7 +119,7 @@ import Wire.API.User.RichInfo import Wire.API.UserEvent import Wire.ActivationCodeStore import Wire.ActivationCodeStore qualified as ActivationCode -import Wire.AuthenticationSubsystem (AuthenticationSubsystem, internalLookupPasswordResetCode) +import Wire.AuthenticationSubsystem (AuthenticationSubsystem, internalLookupPasswordResetCode, recordUserActivity) import Wire.BackendNotificationQueueAccess import Wire.BlockListStore as BlockListStore import Wire.ClientStore (ClientStore) @@ -145,6 +145,7 @@ import Wire.Sem.Paging.Cassandra import Wire.StoredUser import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem +import Wire.UserActivityStore as UserActivityStore import Wire.UserGroupSubsystem import Wire.UserKeyStore import Wire.UserStore (UserStore) @@ -626,6 +627,7 @@ changeAccountStatus :: ( Member (Concurrency 'Unsafe) r, Member UserSubsystem r, Member Events r, + Member AuthenticationSubsystem r, Member UserStore r ) => NonEmpty UserId -> @@ -638,7 +640,8 @@ changeAccountStatus usrs status = do changeSingleAccountStatus :: ( Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => UserId -> AccountStatus -> @@ -651,7 +654,8 @@ changeSingleAccountStatus uid status = do changeSingleAccountStatusInternal :: ( Member UserSubsystem r, Member Events r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => AccountStatus -> (UserId -> UserEvent) -> @@ -667,6 +671,9 @@ changeSingleAccountStatusInternal status ev u = do UserStore.updateAccountStatus u status User.internalUpdateSearchIndex u Events.generateUserEvent u Nothing (ev u) + -- Reactivation resets the inactivity clock so that the user has the + -- full window before being considered inactive again. + when (status == Active) $ recordUserActivity u mkUserEvent :: (Monad m) => @@ -937,6 +944,7 @@ deleteSelfUser :: Member UserKeyStore r, Member NotificationSubsystem r, Member UserStore r, + Member UserActivityStore r, Member EmailSubsystem r, Member VerificationCodeSubsystem r, Member Events r, @@ -1014,6 +1022,7 @@ verifyDeleteUser :: Member UserKeyStore r, Member TinyLog r, Member UserStore r, + Member UserActivityStore r, Member VerificationCodeSubsystem r, Member Events r, Member UserSubsystem r, @@ -1045,6 +1054,7 @@ ensureAccountDeleted :: Member TinyLog r, Member UserKeyStore r, Member UserStore r, + Member UserActivityStore r, Member Events r, Member UserSubsystem r, Member PropertySubsystem r, @@ -1096,6 +1106,7 @@ deleteAccount :: Member UserKeyStore r, Member TinyLog r, Member UserStore r, + Member UserActivityStore r, Member PropertySubsystem r, Member UserSubsystem r, Member Events r, @@ -1114,6 +1125,7 @@ deleteAccount user = do PropertySubsystem.onUserDeleted uid UserStore.deleteUser user + UserActivityStore.deleteLastActivity uid traverse_ (removeUserFromAllGroups uid) user.userTeam diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 06eb36f10ff..9096be06725 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -51,6 +51,7 @@ import Polysemy.Error (Error, errorToIOFinal, mapError, runError) import Polysemy.Input (Input, runInputConst) import Polysemy.Internal.Kind import Polysemy.TinyLog (TinyLog) +import Util.Timeout (timeoutDiff) import Wire.API.Error (ErrorS, errorToWai) import Wire.API.Error.Galley import Wire.API.Federation.Client qualified @@ -154,6 +155,8 @@ import Wire.TeamInvitationSubsystem.Error import Wire.TeamInvitationSubsystem.Interpreter import Wire.TeamSubsystem import Wire.TeamSubsystem.GalleyAPI +import Wire.UserActivityStore +import Wire.UserActivityStore.Postgres import Wire.UserGroupStore import Wire.UserGroupStore.Postgres (interpretUserGroupStoreToPostgres) import Wire.UserGroupSubsystem @@ -195,6 +198,7 @@ type BrigLowerLevelEffects = '[ SAMLEmailSubsystem, TeamSubsystem, TeamCollaboratorsStore, + UserActivityStore, AppStore, EmailSubsystem, VerificationCodeSubsystem, @@ -352,7 +356,8 @@ runBrigToIO e (AppT ma) = do local = localUnit, userCookieRenewAge = e.settings.userCookieRenewAge, userCookieLimit = e.settings.userCookieLimit, - userCookieThrottle = e.settings.userCookieThrottle + userCookieThrottle = e.settings.userCookieThrottle, + suspendInactiveUsersTimeout = fmap (timeoutDiff . Opt.suspendTimeout) e.settings.suspendInactiveUsers } mainESEnv = e.indexEnv ^. to idxElastic indexedUserStoreConfig = @@ -488,6 +493,7 @@ runBrigToIO e (AppT ma) = do . interpretVerificationCodeSubsystem . emailSubsystemInterpreter e.userTemplates e.teamTemplates e.templateBrandingAsMap . interpretAppStoreToPostgres + . interpretUserActivityStoreToPostgres . interpretTeamCollaboratorsStoreToPostgres . interpretTeamSubsystemToGalleyAPI . samlEmailSubsystemInterpreter diff --git a/services/brig/src/Brig/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs index af018889184..9a19280ebf4 100644 --- a/services/brig/src/Brig/InternalEvent/Process.hs +++ b/services/brig/src/Brig/InternalEvent/Process.hs @@ -42,6 +42,7 @@ import Wire.NotificationSubsystem import Wire.PropertySubsystem import Wire.Sem.Concurrency import Wire.Sem.Delay +import Wire.UserActivityStore import Wire.UserGroupSubsystem import Wire.UserKeyStore import Wire.UserStore (UserStore) @@ -59,6 +60,7 @@ onEvent :: Member (Input (Local ())) r, Member UserKeyStore r, Member UserStore r, + Member UserActivityStore r, Member PropertySubsystem r, Member UserSubsystem r, Member Events r, diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index a0e2f4116cf..fdde7fdf4d9 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -69,6 +69,7 @@ import Wire.API.Team.Member qualified as Teams import Wire.API.Team.Permission (Perm (AddTeamMember)) import Wire.API.Team.Size import Wire.API.User hiding (fromEmail) +import Wire.AuthenticationSubsystem import Wire.BlockListStore import Wire.EmailSubsystem.Interpreter (renderInvitationUrl) import Wire.Error @@ -372,6 +373,7 @@ suspendTeam :: Member Events r, Member TinyLog r, Member InvitationStore r, + Member AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -392,6 +394,7 @@ unsuspendTeam :: Member UserSubsystem r, Member TeamSubsystem r, Member Events r, + Member AuthenticationSubsystem r, Member UserStore r ) => TeamId -> @@ -410,6 +413,7 @@ changeTeamAccountStatuses :: Member TeamSubsystem r, Member UserSubsystem r, Member Events r, + Member AuthenticationSubsystem r, Member UserStore r ) => TeamId -> diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index 589dfbda199..4d62f4ceafc 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -34,7 +34,6 @@ module Brig.User.Auth where import Brig.API.Types -import Brig.API.User (changeSingleAccountStatus) import Brig.App import Brig.Budget import Brig.Options qualified as Opt @@ -57,7 +56,7 @@ import Polysemy import Polysemy.Input import Polysemy.TinyLog (TinyLog) import Polysemy.TinyLog qualified as Log -import System.Logger (field, msg, val, (~~)) +import System.Logger import Util.Timeout import Wire.API.Team.Feature import Wire.API.User @@ -69,11 +68,10 @@ import Wire.ActivationCodeStore qualified as ActivationCode import Wire.AuthenticationSubsystem import Wire.AuthenticationSubsystem qualified as Authentication import Wire.AuthenticationSubsystem.Config -import Wire.AuthenticationSubsystem.Error (VerificationCodeError (..)) +import Wire.AuthenticationSubsystem.Error import Wire.AuthenticationSubsystem.ZAuth qualified as ZAuth import Wire.ClientStore (ClientStore) import Wire.ClientStore qualified as ClientStore -import Wire.Events (Events) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess import Wire.Sem.Metrics (Metrics) @@ -89,7 +87,6 @@ login :: forall r. ( Member (Input (Local ())) r, Member ActivationCodeStore r, - Member Events r, Member TinyLog r, Member UserKeyStore r, Member UserStore r, @@ -177,8 +174,6 @@ logout uts at = do renewAccess :: forall r u a. ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, ZAuth.UserTokenLike u, ZAuth.AccessTokenLike a, ZAuth.AccessTokenType u ~ a, @@ -201,7 +196,9 @@ renewAccess uts at mcid = do (uid, ck) <- validateTokens uts at traverse_ (checkClientId uid) mcid lift . liftSem . Log.debug $ field "user" (toByteString uid) . field "action" (val "User.renewAccess") - guardSuspendedOrInactive uid ZAuth.Expired + either throwE pure =<< (lift . liftSem $ Authentication.checkAndSuspendInactiveUser uid ZAuth.Expired) + catchSuspendedUsers uid ZAuth.Expired + lift . liftSem $ Authentication.recordUserActivity uid mapExceptT liftSem $ do ck' <- nextCookie ck mcid at' <- lift $ newAccessToken (fromMaybe ck ck') at @@ -230,54 +227,11 @@ revokeAccess luid@(tUnqualified -> u) pw cc ll = do -------------------------------------------------------------------------------- -- Internal -guardSuspendedOrInactive :: - ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, - Member UserStore r - ) => - UserId -> - e -> - ExceptT e (AppT r) () -guardSuspendedOrInactive u e = do - catchSuspendInactiveUser u e - catchSuspendedUsers u e - -catchSuspendInactiveUser :: - ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, - Member UserStore r - ) => - UserId -> - e -> - ExceptT e (AppT r) () -catchSuspendInactiveUser uid errval = do - mustsuspend <- lift $ wrapHttpClient $ mustSuspendInactiveUser uid - when mustsuspend $ do - lift . liftSem . Log.warn $ - msg (val "Suspending user due to inactivity") - ~~ field "user" (toByteString uid) - ~~ field "action" ("user.suspend" :: String) - lift $ runExceptT (changeSingleAccountStatus uid Suspended) >>= explicitlyIgnoreErrors - throwE errval - where - explicitlyIgnoreErrors :: (Monad m) => Either AccountStatusError () -> m () - explicitlyIgnoreErrors = \case - Left InvalidAccountStatus -> pure () - Left AccountNotFound -> pure () - Right () -> pure () - -- | Suspended users are not allowed to pick up new session tokens, -- even if they have a valid cookie. -- -- This does not throw if the user is not found; that case must be -- handled by the caller. --- --- This does *not* change observable behavior for existing users: --- before, refreshing access tokens failed because the cookie was --- invalid, now it fails with the same status code if the user is --- suspended, whether there are valid cookies or not. catchSuspendedUsers :: (Member UserStore r) => UserId -> @@ -295,10 +249,7 @@ catchSuspendedUsers uid e = do newAccess :: forall u a r. - ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, - ZAuth.UserTokenLike u, + ( ZAuth.UserTokenLike u, ZAuth.AccessTokenLike a, ZAuth.AccessTokenType u ~ a, Member (Input AuthenticationSubsystemConfig) r, @@ -314,7 +265,9 @@ newAccess :: Maybe CookieLabel -> ExceptT LoginError (AppT r) (Access u) newAccess uid cid ct cl = do - guardSuspendedOrInactive uid LoginSuspended + either throwE pure =<< (lift . liftSem $ Authentication.checkAndSuspendInactiveUser uid LoginSuspended) + catchSuspendedUsers uid LoginSuspended + lift . liftSem $ Authentication.recordUserActivity uid r <- lift $ liftSem $ newCookieLimited uid cid ct cl RevokeSameLabel case r of Left delay -> throwE $ LoginThrottled delay @@ -426,8 +379,6 @@ validateToken ut at = do -- | Allow to login as any user without having the credentials. ssoLogin :: ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, @@ -464,9 +415,7 @@ ssoLogin (SsoLogin uid label) typ = do legalHoldLogin :: ( Member GalleyAPIAccess r, Member TinyLog r, - Member UserSubsystem r, Member AuthenticationSubsystem r, - Member Events r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, Member CryptoSign r, diff --git a/services/brig/src/Brig/User/Auth/Cookie.hs b/services/brig/src/Brig/User/Auth/Cookie.hs index 99f5a0b77d6..063b16e2d1e 100644 --- a/services/brig/src/Brig/User/Auth/Cookie.hs +++ b/services/brig/src/Brig/User/Auth/Cookie.hs @@ -23,7 +23,6 @@ module Brig.User.Auth.Cookie revokeCookies, revokeAllCookies, listCookies, - mustSuspendInactiveUser, -- * Limited Cookies RetryAfter (..), @@ -143,30 +142,6 @@ renewCookie old mcid = do Store.insertCookie uid (toUnitCookie old') (Just (Store.TTL (fromIntegral ttl))) pure new --- | Whether a user has not renewed any of her cookies for longer than --- 'suspendCookiesOlderThanSecs'. Call this always before 'newCookie', 'nextCookie', --- 'newCookieLimited' if there is a chance that the user should be suspended (we don't do it --- implicitly because of cyclical dependencies). -mustSuspendInactiveUser :: (MonadReader Env m, MonadClient m) => UserId -> m Bool -mustSuspendInactiveUser uid = - asks (.settings.suspendInactiveUsers) >>= \case - Nothing -> pure False - Just (SuspendInactiveUsers (Timeout suspendAge)) -> do - now <- liftIO =<< asks (.currentTime) - - let suspendHere :: UTCTime - suspendHere = addUTCTime (-suspendAge) now - - youngEnough :: Cookie () -> Bool - youngEnough = (>= suspendHere) . cookieCreated - - ckies <- listCookies uid [] - let mustSuspend - | null ckies = False - | any youngEnough ckies = False - | otherwise = True - pure mustSuspend - newAccessToken :: forall u a r. ( ZAuth.UserTokenLike u, diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index 7c88c057abf..abc1a3c6ffa 100644 --- a/services/brig/test/integration/API/User.hs +++ b/services/brig/test/integration/API/User.hs @@ -66,7 +66,8 @@ tests conf fbc p b c ch g n aws db userJournalWatcher = do local = localUnit, userCookieRenewAge = conf.settings.userCookieRenewAge, userCookieLimit = conf.settings.userCookieLimit, - userCookieThrottle = conf.settings.userCookieThrottle + userCookieThrottle = conf.settings.userCookieThrottle, + suspendInactiveUsersTimeout = Nothing } pure $ testGroup diff --git a/services/brig/test/integration/API/User/Auth.hs b/services/brig/test/integration/API/User/Auth.hs index f837984842d..b10e814ca9c 100644 --- a/services/brig/test/integration/API/User/Auth.hs +++ b/services/brig/test/integration/API/User/Auth.hs @@ -1010,11 +1010,8 @@ testSuspendInactiveUsers config brig cookieType endPoint = do assertStatus Suspended setStatus brig (userId user) Active assertStatus Active - - -- if the user has been inactive for too long due to suspended - -- state, so it gets re-suspended on the next login attempt. login brig (emailLogin email defPassword Nothing) cookieType - !!! const 403 === statusCode + !!! const 200 === statusCode ------------------------------------------------------------------------------- -- Cookie Management From 49d2f658206edd843bee347cfee294f434838f60 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 20 May 2026 09:38:51 +0200 Subject: [PATCH 23/24] Update postgres schema dump. --- postgres-schema.sql | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/postgres-schema.sql b/postgres-schema.sql index 36b3260dfcd..8956f178acd 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -213,6 +213,18 @@ CREATE TABLE public.domain_registration_challenge ( ALTER TABLE public.domain_registration_challenge OWNER TO "wire-server"; +-- +-- Name: last_user_activity; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.last_user_activity ( + user_id uuid NOT NULL, + active_at timestamp with time zone NOT NULL +); + + +ALTER TABLE public.last_user_activity OWNER TO "wire-server"; + -- -- Name: local_conversation_remote_member; Type: TABLE; Schema: public; Owner: wire-server -- @@ -444,6 +456,14 @@ ALTER TABLE ONLY public.domain_registration ADD CONSTRAINT domain_registration_pkey PRIMARY KEY (domain); +-- +-- Name: last_user_activity last_user_activity_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.last_user_activity + ADD CONSTRAINT last_user_activity_pkey PRIMARY KEY (user_id); + + -- -- Name: local_conversation_remote_member local_conversation_remote_member_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -559,6 +579,13 @@ CREATE INDEX conversation_codes_key_expires_at_idx ON public.conversation_codes CREATE INDEX conversation_member_user_idx ON public.conversation_member USING btree ("user"); +-- +-- Name: conversation_parent_conv_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX conversation_parent_conv_idx ON public.conversation USING btree (parent_conv); + + -- -- Name: conversation_team_group_type_lower_name_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- From 3abd1ae0290b3e000cf549fdb51850cef83c4b48 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 20 May 2026 14:05:00 +0200 Subject: [PATCH 24/24] Remove redundant polysemy constraints. --- services/brig/src/Brig/API/Auth.hs | 12 +----------- services/brig/src/Brig/API/Internal.hs | 3 --- services/brig/src/Brig/User/Auth.hs | 4 +--- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index e8fca530830..220254b4cbf 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -59,7 +59,6 @@ import Wire.ClientStore (ClientStore) import Wire.DomainRegistrationStore (DomainRegistrationStore) import Wire.EmailSubsystem (EmailSubsystem) import Wire.Error (HttpError (..)) -import Wire.Events (Events) import Wire.GalleyAPIAccess import Wire.Sem.Metrics (Metrics) import Wire.Sem.Now (Now) @@ -75,8 +74,6 @@ import Wire.UserSubsystem.UserSubsystemConfig accessH :: ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, Member (Input AuthenticationSubsystemConfig) r, Member (Embed IO) r, Member Metrics r, @@ -131,7 +128,6 @@ login :: ( Member TinyLog r, Member UserKeyStore r, Member UserStore r, - Member Events r, Member (Input (Local ())) r, Member UserSubsystem r, Member ActivationCodeStore r, @@ -235,9 +231,6 @@ removeCookies lusr (RemoveCookies pw lls ids) = legalHoldLogin :: ( Member GalleyAPIAccess r, - Member TinyLog r, - Member UserSubsystem r, - Member Events r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, @@ -253,10 +246,7 @@ legalHoldLogin lhl = do traverse mkUserTokenCookie c ssoLogin :: - ( Member TinyLog r, - Member AuthenticationSubsystem r, - Member UserSubsystem r, - Member Events r, + ( Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, Member CryptoSign r, diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index de306072da6..0b317f01074 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -344,9 +344,6 @@ clientAPI = Named @"update-client-last-active" updateClientLastActive authAPI :: ( Member GalleyAPIAccess r, - Member TinyLog r, - Member Events r, - Member UserSubsystem r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index 4d62f4ceafc..8dc52dc14be 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -378,8 +378,7 @@ validateToken ut at = do -- | Allow to login as any user without having the credentials. ssoLogin :: - ( Member TinyLog r, - Member AuthenticationSubsystem r, + ( Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, Member CryptoSign r, @@ -414,7 +413,6 @@ ssoLogin (SsoLogin uid label) typ = do -- | Log in as a LegalHold service, getting LegalHoldUser/Access Tokens. legalHoldLogin :: ( Member GalleyAPIAccess r, - Member TinyLog r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, Member Now r,