Skip to content

Commit

Permalink
WPB-5491 Log password reset errors instead of propagating them (#4114)
Browse files Browse the repository at this point in the history
  • Loading branch information
battermann authored Jul 2, 2024
1 parent 16161c6 commit 67a5f68
Show file tree
Hide file tree
Showing 6 changed files with 68 additions and 41 deletions.
1 change: 1 addition & 0 deletions changelog.d/3-bug-fixes/WPB-5491
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Log password reset errors instead of propagating them
2 changes: 0 additions & 2 deletions libs/wire-api/src/Wire/API/Routes/Public/Brig.hs
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,6 @@ type AccountAPI =
:<|> Named
"post-password-reset"
( Summary "Initiate a password reset."
:> CanThrow 'PasswordResetInProgress
:> CanThrow 'InvalidPasswordResetKey
:> "password-reset"
:> ReqBody '[JSON] NewPasswordReset
:> MultiVerb 'POST '[JSON] '[RespondEmpty 201 "Password reset code created and sent by email."] ()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ import Wire.API.Error.Brig qualified as E

data AuthenticationSubsystemError
= AuthenticationSubsystemInvalidPasswordResetKey
| AuthenticationSubsystemPasswordResetInProgress
| AuthenticationSubsystemResetPasswordMustDiffer
| AuthenticationSubsystemInvalidPasswordResetCode
| AuthenticationSubsystemAllowListError
deriving (Eq, Show)

instance Exception AuthenticationSubsystemError
Expand All @@ -39,7 +37,5 @@ authenticationSubsystemErrorToWai :: AuthenticationSubsystemError -> Wai.Error
authenticationSubsystemErrorToWai =
dynErrorToWai . \case
AuthenticationSubsystemInvalidPasswordResetKey -> dynError @(MapError E.InvalidPasswordResetKey)
AuthenticationSubsystemPasswordResetInProgress -> dynError @(MapError E.PasswordResetInProgress)
AuthenticationSubsystemInvalidPasswordResetCode -> dynError @(MapError E.InvalidPasswordResetCode)
AuthenticationSubsystemResetPasswordMustDiffer -> dynError @(MapError E.ResetPasswordMustDiffer)
AuthenticationSubsystemAllowListError -> dynError @(MapError E.AllowlistError)
Original file line number Diff line number Diff line change
Expand Up @@ -78,42 +78,65 @@ maxAttempts = 3
passwordResetCodeTtl :: NominalDiffTime
passwordResetCodeTtl = 3600 -- 60 minutes

-- This type is not exported and used for internal control flow only
data PasswordResetError
= AllowListError
| InvalidResetKey
| InProgress
deriving (Show)

instance Exception PasswordResetError where
displayException AllowListError = "email domain is not allowed for password reset"
displayException InvalidResetKey = "invalid reset key for password reset"
displayException InProgress = "password reset already in progress"

createPasswordResetCodeImpl ::
forall r.
( Member PasswordResetCodeStore r,
Member Now r,
Member (Input (Local ())) r,
Member (Input (Maybe AllowlistEmailDomains)) r,
Member (Input (Maybe AllowlistPhonePrefixes)) r,
Member (Error AuthenticationSubsystemError) r,
Member TinyLog r,
Member UserSubsystem r,
Member EmailSmsSubsystem r
) =>
UserKey ->
Sem r ()
createPasswordResetCodeImpl target = do
allowListOk <- (\e p -> AllowLists.verify e p (toEither target)) <$> input <*> input
unless allowListOk $ throw AuthenticationSubsystemAllowListError
user <- lookupActiveUserByUserKey target >>= maybe (throw AuthenticationSubsystemInvalidPasswordResetKey) pure
let uid = userId user
Log.debug $ field "user" (toByteString uid) . field "action" (val "User.beginPasswordReset")

mExistingCode <- lookupPasswordResetCode uid
when (isJust mExistingCode) $
throw AuthenticationSubsystemPasswordResetInProgress

let key = mkPasswordResetKey uid
now <- Now.get
code <- foldKey (const generateEmailCode) (const generatePhoneCode) target
codeInsert
key
(PRQueryData code uid (Identity maxAttempts) (Identity (passwordResetCodeTtl `addUTCTime` now)))
(round passwordResetCodeTtl)
foldKey
(\email -> sendPasswordResetMail email (key, code) (Just user.userLocale))
(\phone -> sendPasswordResetSms phone (key, code) (Just user.userLocale))
target
pure ()
createPasswordResetCodeImpl target =
logPasswordResetError =<< runError do
allowListOk <- (\e p -> AllowLists.verify e p (toEither target)) <$> input <*> input
unless allowListOk $ throw AllowListError
user <- lookupActiveUserByUserKey target >>= maybe (throw InvalidResetKey) pure
let uid = userId user
Log.debug $ field "user" (toByteString uid) . field "action" (val "User.beginPasswordReset")

mExistingCode <- lookupPasswordResetCode uid
when (isJust mExistingCode) $
throw InProgress

let key = mkPasswordResetKey uid
now <- Now.get
code <- foldKey (const generateEmailCode) (const generatePhoneCode) target
codeInsert
key
(PRQueryData code uid (Identity maxAttempts) (Identity (passwordResetCodeTtl `addUTCTime` now)))
(round passwordResetCodeTtl)
foldKey
(\email -> sendPasswordResetMail email (key, code) (Just user.userLocale))
(\phone -> sendPasswordResetSms phone (key, code) (Just user.userLocale))
target
pure ()
where
-- `PasswordResetError` are errors that we don't want to leak to the caller.
-- Therefore we handle them here and only log without propagating them.
logPasswordResetError :: Either PasswordResetError () -> Sem r ()
logPasswordResetError = \case
Left e ->
Log.err $
field "action" (val "User.beginPasswordReset")
. field "error" (displayException e)
Right v -> pure v

lookupActiveUserIdByUserKey :: (Member UserSubsystem r, Member (Input (Local ())) r) => UserKey -> Sem r (Maybe UserId)
lookupActiveUserIdByUserKey target = userId <$$> lookupActiveUserByUserKey target
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import Wire.MockInterpreters
import Wire.PasswordResetCodeStore
import Wire.PasswordStore
import Wire.Sem.Logger.TinyLog
import Wire.Sem.Now
import Wire.Sem.Now (Now)
import Wire.SessionStore
import Wire.UserKeyStore
import Wire.UserSubsystem
Expand Down Expand Up @@ -130,8 +130,9 @@ spec = describe "AuthenticationSubsystem.Interpreter" do
interpretDependencies localDomain [] mempty (Just ["example.com"])
. interpretAuthenticationSubsystem
$ createPasswordResetCode (userEmailKey email)
in emailDomain email /= "exmaple.com" ==>
createPasswordResetCodeResult === Left AuthenticationSubsystemAllowListError
<* expectNoEmailSent
in emailDomain email /= "example.com" ==>
createPasswordResetCodeResult === Right ()

prop "reset code is generated when email is in allow list" $
\email userNoEmail ->
Expand All @@ -152,16 +153,18 @@ spec = describe "AuthenticationSubsystem.Interpreter" do
interpretDependencies localDomain [UserAccount user status] mempty Nothing
. interpretAuthenticationSubsystem
$ createPasswordResetCode (userEmailKey email)
<* expectNoEmailSent
in status /= Active ==>
createPasswordResetCodeResult === Left AuthenticationSubsystemInvalidPasswordResetKey
createPasswordResetCodeResult === Right ()

prop "reset code is not generated for when there is no user for the email" $
\email localDomain ->
let createPasswordResetCodeResult =
interpretDependencies localDomain [] mempty Nothing
. interpretAuthenticationSubsystem
$ createPasswordResetCode (userEmailKey email)
in createPasswordResetCodeResult === Left AuthenticationSubsystemInvalidPasswordResetKey
<* expectNoEmailSent
in createPasswordResetCodeResult === Right ()

prop "reset code is only generated once" $
\email userNoEmail newPassword ->
Expand All @@ -182,7 +185,7 @@ spec = describe "AuthenticationSubsystem.Interpreter" do

(,mCaughtExc) <$> lookupHashedPassword uid
in (fmap (verifyPassword newPassword) newPasswordHash === Just True)
.&&. (mCaughtException === Just AuthenticationSubsystemPasswordResetInProgress)
.&&. (mCaughtException === Nothing)

prop "reset code is not accepted after expiry" $
\email userNoEmail oldPassword newPassword ->
Expand Down Expand Up @@ -306,3 +309,10 @@ expect1ResetPasswordEmail email =
[] -> error "no emails sent"
[SentMail _ (PasswordResetMail resetPair)] -> resetPair
wrongEmails -> error $ "Wrong emails sent: " <> show wrongEmails

expectNoEmailSent :: (Member (State (Map Email [SentMail])) r) => Sem r ()
expectNoEmailSent = do
emails <- get
if null emails
then pure ()
else error $ "Expected no emails sent, got: " <> show emails
9 changes: 4 additions & 5 deletions services/brig/test/integration/API/User/PasswordReset.hs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import Data.Aeson as A
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Misc
import Imports
import Network.Wai.Utilities (Error (label))
import Test.Tasty hiding (Timeout)
import Util
import Wire.API.User
Expand Down Expand Up @@ -64,10 +63,10 @@ testPasswordReset brig = do
let newpw = plainTextPassword8Unsafe "newsecret"
do
initiatePasswordReset brig email !!! const 201 === statusCode
initiatePasswordReset brig email !!! do
const 409 === statusCode
const (Just "code-exists") === fmap label . responseJsonMaybe
const Nothing {- the "retry-after" header is only added for provider, not user, at the time of writing this test -} === getHeader "Retry-After"
-- even though a password reset is now in progress
-- we expect a successful response from a subsequent request to not leak any information
-- about the requested email
initiatePasswordReset brig email !!! const 201 === statusCode

passwordResetData <- preparePasswordReset brig email uid newpw
completePasswordReset brig passwordResetData !!! const 200 === statusCode
Expand Down

0 comments on commit 67a5f68

Please sign in to comment.