diff --git a/changelog.d/5-internal/WPB-11000 b/changelog.d/5-internal/WPB-11000 new file mode 100644 index 00000000000..d489cc80d7e --- /dev/null +++ b/changelog.d/5-internal/WPB-11000 @@ -0,0 +1 @@ +Additional test for password reset, port tests to new integration test suite diff --git a/integration/default.nix b/integration/default.nix index abff642f97d..37d66c8daf5 100644 --- a/integration/default.nix +++ b/integration/default.nix @@ -19,6 +19,7 @@ , Cabal , case-insensitive , containers +, cookie , cql , cql-io , crypton @@ -115,6 +116,7 @@ mkDerivation { bytestring-conversion case-insensitive containers + cookie cql cql-io crypton diff --git a/integration/integration.cabal b/integration/integration.cabal index faf1a6a4867..a4351796175 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -140,6 +140,7 @@ library Test.MLS.Unreachable Test.Notifications Test.OAuth + Test.PasswordReset Test.Presence Test.Property Test.Provider @@ -193,6 +194,7 @@ library , bytestring-conversion , case-insensitive , containers + , cookie , cql , cql-io , crypton diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index fae907cd2e3..d3e268197ab 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -795,3 +795,23 @@ listInvitations :: (HasCallStack, MakesValue user) => user -> String -> App Resp listInvitations user tid = do req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", tid, "invitations"] submit "GET" req + +passwordReset :: (HasCallStack, MakesValue domain) => domain -> String -> App Response +passwordReset domain email = do + req <- baseRequest domain Brig Versioned "password-reset" + submit "POST" $ req & addJSONObject ["email" .= email] + +completePasswordReset :: (HasCallStack, MakesValue domain) => domain -> String -> String -> String -> App Response +completePasswordReset domain key code pw = do + req <- baseRequest domain Brig Versioned $ joinHttpPath ["password-reset", "complete"] + submit "POST" $ req & addJSONObject ["key" .= key, "code" .= code, "password" .= pw] + +login :: (HasCallStack, MakesValue domain) => domain -> String -> String -> App Response +login domain email password = do + req <- baseRequest domain Brig Versioned "login" + submit "POST" $ req & addJSONObject ["email" .= email, "password" .= password] & addQueryParams [("persist", "true")] + +updateEmail :: (HasCallStack, MakesValue user) => user -> String -> String -> String -> App Response +updateEmail user email cookie token = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["access", "self", "email"] + submit "PUT" $ req & addJSONObject ["email" .= email] & setCookie cookie & addHeader "Authorization" ("Bearer " <> token) diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index 38fe56ac943..7d1ca70230d 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -298,3 +298,8 @@ getActivationCode :: (HasCallStack, MakesValue domain) => domain -> String -> Ap getActivationCode domain email = do req <- baseRequest domain Brig Unversioned "i/users/activation-code" submit "GET" $ req & addQueryParams [("email", email)] + +getPasswordResetCode :: (HasCallStack, MakesValue domain) => domain -> String -> App Response +getPasswordResetCode domain email = do + req <- baseRequest domain Brig Unversioned "i/users/password-reset-code" + submit "GET" $ req & addQueryParams [("email", email)] diff --git a/integration/test/Test/PasswordReset.hs b/integration/test/Test/PasswordReset.hs new file mode 100644 index 00000000000..95a94ea3f27 --- /dev/null +++ b/integration/test/Test/PasswordReset.hs @@ -0,0 +1,105 @@ +module Test.PasswordReset where + +import API.Brig +import API.BrigInternal hiding (activate) +import API.Common +import SetupHelpers +import Testlib.Prelude + +-- @SF.Provisioning @TSFI.RESTfulAPI @S1 +-- +-- This test checks the password reset functionality of the application. +-- Besides a successful password reset the following scenarios are tested: +-- - Subsequent password reset requests should succeed without errors. +-- - Attempting to reset the password with an incorrect key or code should fail. +-- - Attempting to log in with the old password after a successful reset should fail. +-- - Attempting to log in with the new password after a successful reset should succeed. +-- - Attempting to reset the password again to the same new password should fail. +testPasswordResetShouldSucceedButFailOnWrongInputs :: (HasCallStack) => App () +testPasswordResetShouldSucceedButFailOnWrongInputs = do + u <- randomUser OwnDomain def + email <- u %. "email" & asString + passwordReset u email >>= assertSuccess + -- 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. + passwordReset u email >>= assertSuccess + + (key, code) <- getPasswordResetData email + let newPassword = "newpassword" + + -- complete password reset with incorrect key/code should fail + completePasswordReset u "wrong-key" code newPassword >>= assertStatus 400 + login u email newPassword >>= assertStatus 403 + completePasswordReset u key "wrong-code" newPassword >>= assertStatus 400 + login u email newPassword >>= assertStatus 403 + + -- complete password reset with correct key and code should succeed + completePasswordReset u key code newPassword >>= assertSuccess + + -- try login with old password should fail + login u email defPassword >>= assertStatus 403 + -- login with new password should succeed + login u email newPassword >>= assertSuccess + -- reset password again to the same new password should fail + passwordReset u email >>= assertSuccess + (nextKey, nextCode) <- getPasswordResetData email + bindResponse (completePasswordReset u nextKey nextCode newPassword) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "password-must-differ" + +-- @END + +testPasswordResetAfterEmailUpdate :: (HasCallStack) => App () +testPasswordResetAfterEmailUpdate = do + u <- randomUser OwnDomain def + email <- u %. "email" & asString + (cookie, token) <- bindResponse (login u email defPassword) $ \resp -> do + resp.status `shouldMatchInt` 200 + token <- resp.json %. "access_token" & asString + let cookie = fromJust $ getCookie "zuid" resp + pure ("zuid=" <> cookie, token) + + -- initiate email update + newEmail <- randomEmail + updateEmail u newEmail cookie token >>= assertSuccess + + -- initiate password reset + passwordReset u email >>= assertSuccess + (key, code) <- getPasswordResetData email + + -- activate new email + bindResponse (getActivationCode u newEmail) $ \resp -> do + resp.status `shouldMatchInt` 200 + activationKey <- resp.json %. "key" & asString + activationCode <- resp.json %. "code" & asString + activate u activationKey activationCode >>= assertSuccess + + bindResponse (getSelf u) $ \resp -> do + actualEmail <- resp.json %. "email" + actualEmail `shouldMatch` newEmail + + -- attempting to complete password reset should fail + bindResponse (completePasswordReset u key code "newpassword") $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "invalid-code" + +testPasswordResetInvalidPasswordLength :: App () +testPasswordResetInvalidPasswordLength = do + u <- randomUser OwnDomain def + email <- u %. "email" & asString + passwordReset u email >>= assertSuccess + (key, code) <- getPasswordResetData email + + -- complete password reset with a password that is too short should fail + let shortPassword = "123456" + completePasswordReset u key code shortPassword >>= assertStatus 400 + + -- try login with new password should fail + login u email shortPassword >>= assertStatus 403 + +getPasswordResetData :: String -> App (String, String) +getPasswordResetData email = do + bindResponse (getPasswordResetCode OwnDomain email) $ \resp -> do + resp.status `shouldMatchInt` 200 + (,) <$> (resp.json %. "key" & asString) <*> (resp.json %. "code" & asString) diff --git a/integration/test/Testlib/HTTP.hs b/integration/test/Testlib/HTTP.hs index ae15b01adb1..ab7e7d237bf 100644 --- a/integration/test/Testlib/HTTP.hs +++ b/integration/test/Testlib/HTTP.hs @@ -30,6 +30,7 @@ import Testlib.Assertions import Testlib.Env import Testlib.JSON import Testlib.Types +import Web.Cookie import Prelude splitHttpPath :: String -> [String] @@ -89,6 +90,11 @@ setCookie :: String -> HTTP.Request -> HTTP.Request setCookie c r = addHeader "Cookie" (cs c) r +getCookie :: String -> Response -> Maybe String +getCookie name resp = do + cookieHeader <- lookup (CI.mk $ cs "set-cookie") resp.headers + cs <$> lookup (cs name) (parseCookies cookieHeader) + addQueryParams :: [(String, String)] -> HTTP.Request -> HTTP.Request addQueryParams params req = HTTP.setQueryString (map (\(k, v) -> (cs k, Just (cs v))) params) req diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 6d8885cfb71..2e6c169679d 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -391,7 +391,6 @@ executable brig-integration API.User.Client API.User.Connection API.User.Handles - API.User.PasswordReset API.User.RichInfo API.User.Util API.UserPendingActivation diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index d791df93082..35cf4aef598 100644 --- a/services/brig/test/integration/API/User.hs +++ b/services/brig/test/integration/API/User.hs @@ -26,7 +26,6 @@ import API.User.Auth qualified import API.User.Client qualified import API.User.Connection qualified import API.User.Handles qualified -import API.User.PasswordReset qualified import API.User.RichInfo qualified import API.User.Util import Bilge hiding (accept, timeout) @@ -67,7 +66,6 @@ tests conf fbc p b c ch g n aws db userJournalWatcher = do API.User.Auth.tests conf p z db b g n, API.User.Connection.tests cl at p b c g fbc db, API.User.Handles.tests cl at conf p b c g, - API.User.PasswordReset.tests db cl at conf p b c g, API.User.RichInfo.tests cl at conf p b c g ] diff --git a/services/brig/test/integration/API/User/PasswordReset.hs b/services/brig/test/integration/API/User/PasswordReset.hs deleted file mode 100644 index 034c6c40ece..00000000000 --- a/services/brig/test/integration/API/User/PasswordReset.hs +++ /dev/null @@ -1,127 +0,0 @@ -{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 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 API.User.PasswordReset - ( tests, - ) -where - -import API.User.Util -import Bilge hiding (accept, timeout) -import Bilge.Assert -import Brig.Options qualified as Opt -import Cassandra qualified as DB -import Data.Aeson as A -import Data.Aeson.KeyMap qualified as KeyMap -import Data.Misc -import Imports -import Test.Tasty hiding (Timeout) -import Util -import Util.Timeout -import Wire.API.User -import Wire.API.User.Auth - -tests :: - DB.ClientState -> - ConnectionLimit -> - Timeout -> - Opt.Opts -> - Manager -> - Brig -> - Cannon -> - Galley -> - TestTree -tests _cs _cl _at _conf p b _c _g = - testGroup - "password-reset" - [ test p "post /password-reset[/complete] - 201[/200]" $ testPasswordReset b, - test p "post /password-reset after put /access/self/email - 400" $ testPasswordResetAfterEmailUpdate b, - test p "post /password-reset/complete - password too short - 400" $ testPasswordResetInvalidPasswordLength b - ] - -testPasswordReset :: Brig -> Http () -testPasswordReset brig = do - u <- randomUser brig - let Just email = userEmail u - let uid = userId u - -- initiate reset - let newpw = plainTextPassword8Unsafe "newsecret" - do - initiatePasswordReset brig email !!! const 201 === statusCode - -- 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 - -- try login - login brig (defEmailLogin email) PersistentCookie - !!! const 403 === statusCode - login - brig - (MkLogin (LoginByEmail email) (plainTextPassword8To6 newpw) Nothing Nothing) - PersistentCookie - !!! const 200 === statusCode - -- reset password again to the same new password, get 400 "must be different" - do - initiatePasswordReset brig email !!! const 201 === statusCode - passwordResetData <- preparePasswordReset brig email uid newpw - completePasswordReset brig passwordResetData !!! const 409 === statusCode - -testPasswordResetAfterEmailUpdate :: Brig -> Http () -testPasswordResetAfterEmailUpdate brig = do - u <- randomUser brig - let uid = userId u - let Just email = userEmail u - eml <- randomEmail - initiateEmailUpdateLogin brig eml (emailLogin email defPassword Nothing) uid !!! const 202 === statusCode - initiatePasswordReset brig email !!! const 201 === statusCode - passwordResetData <- preparePasswordReset brig email uid (plainTextPassword8Unsafe "newsecret") - -- activate new email - activateEmail brig eml - checkEmail brig uid eml - -- attempting to complete password reset should fail - completePasswordReset brig passwordResetData !!! const 400 === statusCode - -testPasswordResetInvalidPasswordLength :: Brig -> Http () -testPasswordResetInvalidPasswordLength brig = do - u <- randomUser brig - let Just email = userEmail u - let uid = userId u - -- for convenience, we create a valid password first that we replace with an invalid one in the JSON later - let newpw = plainTextPassword8Unsafe "newsecret" - initiatePasswordReset brig email !!! const 201 === statusCode - passwordResetData <- preparePasswordReset brig email uid newpw - let shortPassword = String "123456" - let reqBody = toJSON passwordResetData & addJsonKey "password" shortPassword - postCompletePasswordReset reqBody !!! const 400 === statusCode - where - addJsonKey :: Key -> Value -> Value -> Object - addJsonKey key val (Object xs) = KeyMap.insert key val xs - addJsonKey _ _ _ = error "invalid JSON object" - - postCompletePasswordReset :: Object -> (MonadHttp m) => m ResponseLBS - postCompletePasswordReset bdy = - post - ( brig - . path "/password-reset/complete" - . contentJson - . body (RequestBodyLBS (encode bdy)) - )