Skip to content

Commit cd26e25

Browse files
committed
Keep track of user inactivity in postgres and without cookies.
1 parent 704b677 commit cd26e25

23 files changed

Lines changed: 330 additions & 107 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CREATE TABLE last_user_activity (
2+
user_id uuid PRIMARY KEY,
3+
active_at timestamptz NOT NULL
4+
);

libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ data AuthenticationSubsystem m a where
7878
SameLabelPolicy ->
7979
AuthenticationSubsystem m (Either RetryAfter (Cookie (ZAuth.Token t)))
8080
RevokeCookies :: UserId -> [CookieId] -> [CookieLabel] -> AuthenticationSubsystem m ()
81+
-- Inactivity tracking
82+
RecordUserActivity :: UserId -> AuthenticationSubsystem m ()
83+
CheckAndSuspendInactiveUser :: UserId -> e -> AuthenticationSubsystem m (Either e ())
8184
-- Verification Codes
8285
EnforceVerificationCodeEither :: Local UserId -> Maybe Code.Value -> VerificationAction -> AuthenticationSubsystem m (Either VerificationCodeError ())
8386
-- For testing

libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Config.hs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import Data.Aeson
2121
import Data.List.NonEmpty (NonEmpty, nonEmpty)
2222
import Data.List.NonEmpty qualified as NonEmpty
2323
import Data.Qualified
24+
import Data.Time.Clock (NominalDiffTime)
2425
import Data.Vector (Vector)
2526
import Data.Vector qualified as Vector
2627
import Data.ZAuth.Creation qualified as ZC
@@ -35,7 +36,8 @@ data AuthenticationSubsystemConfig = AuthenticationSubsystemConfig
3536
zauthEnv :: ZAuthEnv,
3637
userCookieRenewAge :: Integer,
3738
userCookieLimit :: Int,
38-
userCookieThrottle :: CookieThrottle
39+
userCookieThrottle :: CookieThrottle,
40+
suspendInactiveUsersTimeout :: Maybe NominalDiffTime
3941
}
4042

4143
data ZAuthSettings = ZAuthSettings

libs/wire-subsystems/src/Wire/AuthenticationSubsystem/Interpreter.hs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import Wire.API.Allowlists qualified as AllowLists
4242
import Wire.API.Team.Feature
4343
import Wire.API.User
4444
import Wire.API.User.Password
45+
import Wire.API.UserEvent (UserEvent (UserSuspended))
4546
import Wire.AuthenticationSubsystem
4647
import Wire.AuthenticationSubsystem.Config
4748
import Wire.AuthenticationSubsystem.Cookie
@@ -59,6 +60,8 @@ import Wire.Sem.Now
5960
import Wire.Sem.Now qualified as Now
6061
import Wire.Sem.Random (Random)
6162
import Wire.SessionStore
63+
import Wire.UserActivityStore (UserActivityStore)
64+
import Wire.UserActivityStore qualified as UserActivityStore
6265
import Wire.UserKeyStore
6366
import Wire.UserStore (UserStore)
6467
import Wire.UserStore qualified as UserStore
@@ -81,6 +84,7 @@ interpretAuthenticationSubsystem ::
8184
Member PasswordStore r,
8285
Member EmailSubsystem r,
8386
Member UserStore r,
87+
Member UserActivityStore r,
8488
Member RateLimit r,
8589
Member CryptoSign r,
8690
Member Random r,
@@ -107,6 +111,9 @@ interpretAuthenticationSubsystem userSubsystemInterpreter =
107111
NewCookie uid mcid typ mLabel policy -> newCookieImpl uid mcid typ mLabel policy
108112
NewCookieLimited uid mcid typ mLabel policy -> runError $ newCookieLimitedImpl uid mcid typ mLabel policy
109113
RevokeCookies uid ids labels -> revokeCookiesImpl uid ids labels
114+
-- Inactivity tracking
115+
RecordUserActivity uid -> recordUserActivityImpl uid
116+
CheckAndSuspendInactiveUser uid er -> checkAndSuspendInactiveUserImpl uid er
110117
-- Verification Codes
111118
EnforceVerificationCodeEither luid mCode action -> runError $ enforceVerificationCodeImpl luid mCode action
112119
-- Testing
@@ -415,6 +422,48 @@ verifyUserPasswordErrorImpl (tUnqualified -> uid) password = do
415422
unlessM (fst <$> verifyUserPasswordImpl uid password) do
416423
throw AuthenticationSubsystemBadCredentials
417424

425+
recordUserActivityImpl ::
426+
( Member Now r,
427+
Member UserActivityStore r
428+
) =>
429+
UserId ->
430+
Sem r ()
431+
recordUserActivityImpl uid = do
432+
now <- Now.get
433+
UserActivityStore.updateLastActivity uid now
434+
435+
checkAndSuspendInactiveUserImpl ::
436+
( Member (Input AuthenticationSubsystemConfig) r,
437+
Member UserActivityStore r,
438+
Member Now r,
439+
Member UserStore r,
440+
Member UserSubsystem r,
441+
Member Events r,
442+
Member TinyLog r
443+
) =>
444+
UserId ->
445+
e ->
446+
Sem r (Either e ())
447+
checkAndSuspendInactiveUserImpl uid er =
448+
inputs (.suspendInactiveUsersTimeout) >>= \case
449+
Nothing -> pure (Right ())
450+
Just timeout -> do
451+
UserActivityStore.getLastActivity uid >>= \case
452+
Nothing -> pure (Right ())
453+
Just lastActivity -> do
454+
now <- Now.get
455+
if diffUTCTime now lastActivity > timeout
456+
then do
457+
Log.warn $
458+
msg (val "Suspending user due to inactivity")
459+
. field "user" (toByteString uid)
460+
. field "action" ("user.suspend" :: String)
461+
UserStore.updateAccountStatus uid Suspended
462+
User.internalUpdateSearchIndex uid
463+
generateUserEvent uid Nothing (UserSuspended uid)
464+
pure (Left er)
465+
else pure (Right ())
466+
418467
enforceVerificationCodeImpl ::
419468
forall r.
420469
( Member GalleyAPIAccess r,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{-# LANGUAGE TemplateHaskell #-}
2+
3+
-- This file is part of the Wire Server implementation.
4+
--
5+
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
6+
--
7+
-- This program is free software: you can redistribute it and/or modify it under
8+
-- the terms of the GNU Affero General Public License as published by the Free
9+
-- Software Foundation, either version 3 of the License, or (at your option) any
10+
-- later version.
11+
--
12+
-- This program is distributed in the hope that it will be useful, but WITHOUT
13+
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
14+
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
15+
-- details.
16+
--
17+
-- You should have received a copy of the GNU Affero General Public License along
18+
-- with this program. If not, see <https://www.gnu.org/licenses/>.
19+
20+
module Wire.UserActivityStore where
21+
22+
import Data.Id
23+
import Data.Time.Clock
24+
import Imports
25+
import Polysemy
26+
27+
data UserActivityStore m a where
28+
GetLastActivity :: UserId -> UserActivityStore m (Maybe UTCTime)
29+
UpdateLastActivity :: UserId -> UTCTime -> UserActivityStore m ()
30+
DeleteLastActivity :: UserId -> UserActivityStore m ()
31+
32+
makeSem ''UserActivityStore
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{-# LANGUAGE QuasiQuotes #-}
2+
3+
-- This file is part of the Wire Server implementation.
4+
--
5+
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
6+
--
7+
-- This program is free software: you can redistribute it and/or modify it under
8+
-- the terms of the GNU Affero General Public License as published by the Free
9+
-- Software Foundation, either version 3 of the License, or (at your option) any
10+
-- later version.
11+
--
12+
-- This program is distributed in the hope that it will be useful, but WITHOUT
13+
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
14+
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
15+
-- details.
16+
--
17+
-- You should have received a copy of the GNU Affero General Public License along
18+
-- with this program. If not, see <https://www.gnu.org/licenses/>.
19+
20+
module Wire.UserActivityStore.Postgres
21+
( interpretUserActivityStoreToPostgres,
22+
)
23+
where
24+
25+
import Data.Id
26+
import Data.Time.Clock
27+
import Hasql.TH
28+
import Imports
29+
import Polysemy
30+
import Wire.Postgres
31+
import Wire.UserActivityStore
32+
33+
interpretUserActivityStoreToPostgres ::
34+
(PGConstraints r) =>
35+
InterpreterFor UserActivityStore r
36+
interpretUserActivityStoreToPostgres = interpret $ \case
37+
GetLastActivity uid -> getLastActivityImpl uid
38+
UpdateLastActivity uid t -> updateLastActivityImpl uid t
39+
DeleteLastActivity uid -> deleteLastActivityImpl uid
40+
41+
getLastActivityImpl :: (PGConstraints r) => UserId -> Sem r (Maybe UTCTime)
42+
getLastActivityImpl uid =
43+
runStatement (toUUID uid) $
44+
[maybeStatement|
45+
SELECT active_at :: timestamptz
46+
FROM last_user_activity
47+
WHERE user_id = $1 :: uuid
48+
|]
49+
50+
updateLastActivityImpl :: (PGConstraints r) => UserId -> UTCTime -> Sem r ()
51+
updateLastActivityImpl uid t =
52+
runStatement (toUUID uid, t) $
53+
[resultlessStatement|
54+
INSERT INTO last_user_activity (user_id, active_at)
55+
VALUES ($1 :: uuid, $2 :: timestamptz)
56+
ON CONFLICT (user_id) DO UPDATE SET active_at = EXCLUDED.active_at
57+
|]
58+
59+
deleteLastActivityImpl :: (PGConstraints r) => UserId -> Sem r ()
60+
deleteLastActivityImpl uid =
61+
runStatement (toUUID uid) $
62+
[resultlessStatement|
63+
DELETE FROM last_user_activity WHERE user_id = $1 :: uuid
64+
|]

libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
1+
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns -Wno-ambiguous-fields #-}
22

33
-- This file is part of the Wire Server implementation.
44
--
@@ -67,8 +67,10 @@ import Wire.Sem.Now (Now)
6767
import Wire.Sem.Random (Random)
6868
import Wire.SessionStore
6969
import Wire.StoredUser
70+
import Wire.UserActivityStore (UserActivityStore)
7071
import Wire.UserKeyStore
7172
import Wire.UserStore
73+
import Wire.UserStore qualified as UserStore
7274
import Wire.VerificationCode
7375
import Wire.VerificationCodeGen
7476
import Wire.VerificationCodeStore
@@ -99,38 +101,44 @@ type AllEffects =
99101
TinyLog,
100102
EmailSubsystem,
101103
UserStore,
104+
UserActivityStore,
105+
State (Map UserId UTCTime),
102106
UserKeyStore,
103107
State [MiniEvent],
104108
State (Map EmailAddress [SentMail]),
105109
State [StoredApp]
106110
]
107111

108112
runAllEffects :: Domain -> [StoredUser] -> Map UserId Password -> Maybe [Text] -> Sem AllEffects a -> Either AuthenticationSubsystemError a
109-
runAllEffects domain users passwords emailDomains action = snd $ runAllEffectsWithEventStateAndFeatures domain users passwords emailDomains def action
113+
runAllEffects domain users passwords emailDomains action = snd $ runAllEffectsWithEventStateAndFeatures domain users passwords emailDomains def Nothing action
110114

111115
runAllEffectsWithEventState :: Domain -> [StoredUser] -> Map UserId Password -> Maybe [Text] -> Sem AllEffects a -> ([MiniEvent], Either AuthenticationSubsystemError a)
112116
runAllEffectsWithEventState localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains =
113-
runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains def
117+
runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains def Nothing
114118

115119
runAllEffectsWithEventStateAndFeatures ::
116120
Domain ->
117121
[StoredUser] ->
118122
Map UserId Password ->
119123
Maybe [Text] ->
120124
AllTeamFeatures ->
125+
Maybe NominalDiffTime ->
121126
Sem AllEffects a ->
122127
([MiniEvent], Either AuthenticationSubsystemError a)
123-
runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains galleyFeatures =
128+
runAllEffectsWithEventStateAndFeatures localDomain preexistingUsers preexistingPasswords mAllowedEmailDomains galleyFeatures mSuspendTimeout =
124129
let cfg =
125130
defaultAuthenticationSubsystemConfig
126131
{ allowlistEmailDomains = AllowlistEmailDomains <$> mAllowedEmailDomains,
127-
local = toLocalUnsafe localDomain ()
132+
local = toLocalUnsafe localDomain (),
133+
suspendInactiveUsersTimeout = mSuspendTimeout
128134
}
129135
in run
130136
. evalState mempty
131137
. evalState mempty
132138
. runState mempty
133139
. runInMemoryUserKeyStoreIntepreterWithStoredUsers preexistingUsers
140+
. evalState (mempty :: Map UserId UTCTime)
141+
. inMemoryUserActivityStoreInterpreter
134142
. runInMemoryUserStoreInterpreter preexistingUsers preexistingPasswords
135143
. inMemoryEmailSubsystemInterpreter
136144
. discardTinyLogs
@@ -566,7 +574,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do
566574
luid = toLocalUnsafe testDomain user.id
567575
features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def
568576
(_, Right result) =
569-
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do
577+
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do
570578
code <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing
571579
enforceVerificationCodeEither luid (Just code.codeValue) action
572580
in result === Right ()
@@ -577,7 +585,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do
577585
luid = toLocalUnsafe testDomain user.id
578586
features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def
579587
(_, Right result) =
580-
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do
588+
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do
581589
_ <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing
582590
enforceVerificationCodeEither luid (Just wrongCode) action
583591
in if status == FeatureStatusEnabled
@@ -590,7 +598,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do
590598
luid = toLocalUnsafe testDomain user.id
591599
features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def
592600
(_, Right result) =
593-
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do
601+
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do
594602
_ <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing
595603
enforceVerificationCodeEither luid Nothing action
596604
in if status == FeatureStatusEnabled
@@ -603,13 +611,69 @@ spec = describe "AuthenticationSubsystem.Interpreter" do
603611
luid = toLocalUnsafe testDomain user.id
604612
features = npUpdate @SndFactorPasswordChallengeConfig (LockableFeature status LockStatusUnlocked def) def
605613
(_, Right result) =
606-
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features $ do
614+
runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing features Nothing $ do
607615
code <- createCodeOverwritePrevious (mk6DigitVerificationCodeGen email) (scopeFromAction action) 2 300 Nothing
608616
enforceVerificationCodeEither luid (Just code.codeValue) action
609617
in if status == FeatureStatusEnabled
610618
then result === Left VerificationCodeNoEmail
611619
else result === Right ()
612620

621+
describe "checkAndSuspendInactiveUser" do
622+
let timeout = 3600 :: NominalDiffTime
623+
624+
prop "suspends user after timeout expires" $ \userNoEmail ->
625+
let user = (userNoEmail :: StoredUser) {status = Just Active}
626+
uid = user.id
627+
Right finalStatus =
628+
snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do
629+
recordUserActivity uid
630+
passTime (timeout + 1)
631+
_ <- checkAndSuspendInactiveUser uid False
632+
UserStore.lookupStatus uid
633+
in finalStatus === Just Suspended
634+
635+
prop "returns Left when inactive" $ \userNoEmail ->
636+
let user = (userNoEmail :: StoredUser) {status = Just Active}
637+
uid = user.id
638+
Right result =
639+
snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do
640+
recordUserActivity uid
641+
passTime (timeout + 1)
642+
checkAndSuspendInactiveUser uid False
643+
in result === Left False
644+
645+
prop "does not suspend user within timeout" $ \userNoEmail ->
646+
let user = (userNoEmail :: StoredUser) {status = Just Active}
647+
uid = user.id
648+
Right finalStatus =
649+
snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do
650+
recordUserActivity uid
651+
passTime (timeout - 1)
652+
_ <- checkAndSuspendInactiveUser uid False
653+
UserStore.lookupStatus uid
654+
in finalStatus === Just Active
655+
656+
prop "does not suspend if feature is disabled" $ \userNoEmail ->
657+
let user = (userNoEmail :: StoredUser) {status = Just Active}
658+
uid = user.id
659+
Right finalStatus =
660+
snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def Nothing $ do
661+
recordUserActivity uid
662+
passTime (timeout + 1)
663+
_ <- checkAndSuspendInactiveUser uid False
664+
UserStore.lookupStatus uid
665+
in finalStatus === Just Active
666+
667+
prop "does not suspend if no activity record exists" $ \userNoEmail ->
668+
let user = (userNoEmail :: StoredUser) {status = Just Active}
669+
uid = user.id
670+
Right finalStatus =
671+
snd $ runAllEffectsWithEventStateAndFeatures testDomain [user] mempty Nothing def (Just timeout) $ do
672+
passTime (timeout + 1)
673+
_ <- checkAndSuspendInactiveUser uid False
674+
UserStore.lookupStatus uid
675+
in finalStatus === Just Active
676+
613677
describe "randomConnId" $ do
614678
it "generates different connection ids" $ do
615679
let connIds = run . runRandomPure $ replicateM 100 randomConnId

0 commit comments

Comments
 (0)