Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c4b98f0
Changelog.
fisx Apr 26, 2026
77d3d88
Integration test.
fisx Apr 26, 2026
0d30919
Reject access token refresh requests from suspended users.
fisx Apr 26, 2026
a4cc869
Do not evict cookies on suspend.
fisx Apr 26, 2026
0441978
Allow unsuspended users to login even if inactive.
fisx Apr 27, 2026
27ddb21
More precise haddocks
fisx May 13, 2026
6992909
Fix boolean logic bug.
fisx May 13, 2026
2e17ca0
Better effect action name.
fisx May 13, 2026
9c34bc0
Extend changelog entry.
fisx May 13, 2026
ab78d2a
Better changelog entry.
fisx May 15, 2026
d1a4f32
Better haddocks.
fisx May 15, 2026
adee0cf
Better guard logic: reduce responsibilities of caller.
fisx May 15, 2026
d585b08
Integration tests: always test labels when testing error status.
fisx May 15, 2026
f92101e
Rewrite integration test: inline functions.
fisx May 15, 2026
52aa695
Undo change to original cookie revokation semantics.
fisx May 15, 2026
a4daa7f
Remove redundant constraints.
fisx May 15, 2026
2f57f5c
Fix: catchSuspendedUsers should do nothing on non-existing users.
fisx May 18, 2026
3e7063c
Haddocks.
fisx May 18, 2026
b7cf041
Rm dead code.
fisx May 18, 2026
212c45a
Fix ancient (and previously harmless) bug in brig integration tests.
fisx May 19, 2026
874e503
Adjust brig integration test to new behavior.
fisx May 19, 2026
e8857d1
Keep track of user inactivity in postgres and without cookies.
fisx May 19, 2026
97fdaa8
Update postgres schema dump.
fisx May 20, 2026
02a3f32
Remove redundant polysemy constraints.
fisx May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow suspended users to keep their cookies, but disallow them to create/refresh access tokens.
Comment thread
fisx marked this conversation as resolved.
27 changes: 27 additions & 0 deletions integration/test/Test/Apps.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE last_user_activity (
user_id uuid PRIMARY KEY,
active_at timestamptz NOT NULL
);
3 changes: 3 additions & 0 deletions libs/wire-subsystems/src/Wire/AuthenticationSubsystem.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,7 +36,8 @@ data AuthenticationSubsystemConfig = AuthenticationSubsystemConfig
zauthEnv :: ZAuthEnv,
userCookieRenewAge :: Integer,
userCookieLimit :: Int,
userCookieThrottle :: CookieThrottle
userCookieThrottle :: CookieThrottle,
suspendInactiveUsersTimeout :: Maybe NominalDiffTime
}

data ZAuthSettings = ZAuthSettings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 ())
Comment on lines +451 to +465

enforceVerificationCodeImpl ::
forall r.
( Member GalleyAPIAccess r,
Expand Down
32 changes: 32 additions & 0 deletions libs/wire-subsystems/src/Wire/UserActivityStore.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{-# LANGUAGE TemplateHaskell #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- 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 <https://www.gnu.org/licenses/>.

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
64 changes: 64 additions & 0 deletions libs/wire-subsystems/src/Wire/UserActivityStore/Postgres.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{-# LANGUAGE QuasiQuotes #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- 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 <https://www.gnu.org/licenses/>.

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
|]
Loading