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
f0c1879
Changelog.
fisx Apr 26, 2026
1509a7c
Integration test.
fisx Apr 26, 2026
800379a
Reject access token refresh requests from suspended users.
fisx Apr 26, 2026
fb1e2fd
Do not evict cookies on suspend.
fisx Apr 26, 2026
5fcf1a6
Allow unsuspended users to login even if inactive.
fisx Apr 27, 2026
68212d5
More precise haddocks
fisx May 13, 2026
45ccb2d
Fix boolean logic bug.
fisx May 13, 2026
0f95018
Better effect action name.
fisx May 13, 2026
2f38f63
Extend changelog entry.
fisx May 13, 2026
8c40151
Better changelog entry.
fisx May 15, 2026
23ba81c
Better haddocks.
fisx May 15, 2026
aab85c0
Better guard logic: reduce responsibilities of caller.
fisx May 15, 2026
c854b3d
Integration tests: always test labels when testing error status.
fisx May 15, 2026
414ce3a
Rewrite integration test: inline functions.
fisx May 15, 2026
2e6f067
Undo change to original cookie revokation semantics.
fisx May 15, 2026
267353e
Remove redundant constraints.
fisx May 15, 2026
a445907
Fix: catchSuspendedUsers should do nothing on non-existing users.
fisx May 18, 2026
98a20a0
Haddocks.
fisx May 18, 2026
0cef09d
Rm dead code.
fisx May 18, 2026
e6372bf
Fix ancient (and previously harmless) bug in brig integration tests.
fisx May 19, 2026
704b677
Adjust brig integration test to new behavior.
fisx May 19, 2026
2da2222
Keep track of user inactivity in postgres and without cookies.
fisx May 19, 2026
49d2f65
Update postgres schema dump.
fisx May 20, 2026
3abd1ae
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 ())

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