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..df0fa53db84 --- /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, but disallow them to create/refresh access tokens. diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 4428f5e732f..582c5248371 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -599,3 +599,30 @@ 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) <- do + let new :: NewApp = + def + { name = "chappie", + description = "some description of this app", + category = "ai" + } + + 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 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/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 -- diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index b47366621fc..220254b4cbf 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -59,9 +59,7 @@ 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.Concurrency import Wire.Sem.Metrics (Metrics) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -76,13 +74,10 @@ 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, Member SessionStore r, - Member (Concurrency Unsafe) r, Member CryptoSign r, Member Now r, Member AuthenticationSubsystem r, @@ -102,8 +97,6 @@ accessH mcid ut' mat' = do access :: ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, UserTokenLike u, AccessTokenLike a, AccessTokenType u ~ a, @@ -111,7 +104,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, @@ -136,13 +128,11 @@ login :: ( Member TinyLog r, Member UserKeyStore r, Member UserStore r, - Member Events r, Member (Input (Local ())) r, Member UserSubsystem r, Member ActivationCodeStore r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r @@ -241,12 +231,8 @@ 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 (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, @@ -260,12 +246,8 @@ 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 (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..0b317f01074 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, @@ -254,7 +257,6 @@ accountAPI :: Member RateLimit r, Member SparAPIAccess r, Member EnterpriseLoginSubsystem r, - Member (Concurrency Unsafe) r, Member ClientStore r, Member ClientSubsystem r ) => @@ -342,12 +344,8 @@ 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 (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, @@ -626,6 +624,7 @@ deleteUserNoAuthH :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, Member UserStore r, + Member UserActivityStore r, Member TinyLog r, Member UserKeyStore r, Member Events r, @@ -785,9 +784,8 @@ getPasswordResetCode email = changeAccountStatusH :: ( Member UserSubsystem r, Member Events r, - Member (Concurrency Unsafe) r, - Member AuthenticationSubsystem 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 4b6f1a4877c..158828097f6 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 @@ -120,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) @@ -146,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) @@ -634,50 +634,55 @@ changeAccountStatus :: AccountStatus -> ExceptT AccountStatusError (AppT r) () changeAccountStatus usrs status = do - ev <- mkUserEvent usrs 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) + ev <- mkUserEvent status + lift $ liftSem $ unsafePooledMapConcurrentlyN_ 16 (changeSingleAccountStatusInternal status ev) usrs changeSingleAccountStatus :: ( Member UserSubsystem r, Member Events r, - Member (Concurrency Unsafe) r, - Member AuthenticationSubsystem r, - Member UserStore r + Member UserStore r, + Member AuthenticationSubsystem r ) => UserId -> AccountStatus -> ExceptT AccountStatusError (AppT r) () changeSingleAccountStatus uid status = do unlessM (lift . liftSem $ UserStore.doesUserExist uid) $ throwE AccountNotFound - ev <- mkUserEvent (NonEmpty.singleton uid) status - lift . liftSem $ do - UserStore.updateAccountStatus uid status - User.internalUpdateSearchIndex uid - Events.generateUserEvent uid Nothing (ev uid) + ev <- mkUserEvent status + lift . liftSem $ changeSingleAccountStatusInternal status ev uid -mkUserEvent :: - ( Traversable t, - Member (Concurrency Unsafe) r, +changeSingleAccountStatusInternal :: + ( Member UserSubsystem r, + Member Events r, + Member UserStore r, Member AuthenticationSubsystem r ) => - t UserId -> AccountStatus -> - ExceptT AccountStatusError (AppT r) (UserId -> UserEvent) -mkUserEvent usrs status = + (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/`. + 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) => + AccountStatus -> + 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 @@ -939,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, @@ -1016,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, @@ -1047,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, @@ -1098,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, @@ -1116,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/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index d00b6340196..8dc52dc14be 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,14 +68,12 @@ 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.Concurrency import Wire.Sem.Metrics (Metrics) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -90,14 +87,12 @@ login :: forall r. ( Member (Input (Local ())) r, Member ActivationCodeStore r, - Member Events r, Member TinyLog r, Member UserKeyStore r, Member UserStore r, Member UserSubsystem r, Member AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r @@ -179,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, @@ -188,7 +181,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, @@ -204,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") - catchSuspendInactiveUser 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 @@ -233,42 +227,31 @@ revokeAccess luid@(tUnqualified -> u) pw cc ll = do -------------------------------------------------------------------------------- -- Internal -catchSuspendInactiveUser :: - ( Member TinyLog r, - Member UserSubsystem r, - Member Events r, - Member (Concurrency 'Unsafe) r, - Member AuthenticationSubsystem r, - Member UserStore r - ) => +-- | 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. +catchSuspendedUsers :: + (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 () +catchSuspendedUsers uid e = do + mb <- lift $ liftSem $ lookupStatus uid + case mb of + 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) + Just Ephemeral -> pure () + Just PendingInvitation -> pure () 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 (Concurrency Unsafe) r, Member (Input AuthenticationSubsystemConfig) r, Member Now r, Member AuthenticationSubsystem r, @@ -282,7 +265,9 @@ newAccess :: Maybe CookieLabel -> ExceptT LoginError (AppT r) (Access u) newAccess uid cid ct cl = do - catchSuspendInactiveUser 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 @@ -393,12 +378,8 @@ 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 AuthenticationSubsystem r, Member (Input AuthenticationSubsystemConfig) r, - Member (Concurrency Unsafe) r, Member Now r, Member CryptoSign r, Member Random r, @@ -432,12 +413,8 @@ ssoLogin (SsoLogin uid label) typ = do -- | Log in as a LegalHold service, getting LegalHoldUser/Access Tokens. legalHoldLogin :: ( Member GalleyAPIAccess r, - Member TinyLog r, - Member UserSubsystem r, Member AuthenticationSubsystem 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/User/Auth/Cookie.hs b/services/brig/src/Brig/User/Auth/Cookie.hs index 6288cf652e0..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,27 +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 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