diff --git a/integration/default.nix b/integration/default.nix index 5e5d07bf0b4..c9d46b82ab4 100644 --- a/integration/default.nix +++ b/integration/default.nix @@ -15,6 +15,7 @@ , Cabal , case-insensitive , containers +, cookie , cql , cql-io , crypton @@ -103,6 +104,7 @@ mkDerivation { bytestring-conversion case-insensitive containers + cookie cql cql-io crypton diff --git a/integration/integration.cabal b/integration/integration.cabal index e3e7baf7251..dbf1ae6e0ea 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -136,6 +136,7 @@ library Test.MLS.SubConversation Test.MLS.Unreachable Test.Notifications + Test.PasswordReset Test.Presence Test.Roles Test.Search @@ -179,6 +180,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 10956be294c..7445737576c 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -636,3 +636,30 @@ renewToken :: (HasCallStack, MakesValue uid) => uid -> String -> App Response renewToken caller cookie = do req <- baseRequest caller Brig Versioned "access" submit "POST" (addHeader "Cookie" ("zuid=" <> cookie) req) + +activate :: (HasCallStack, MakesValue domain) => domain -> String -> String -> App Response +activate domain key code = do + req <- rawBaseRequest domain Brig Versioned $ joinHttpPath ["activate"] + submit "GET" $ + req + & addQueryParams [("key", key), ("code", code)] + +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 24b653c88ba..ac561d2e9c5 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -271,3 +271,13 @@ deleteFeatureForUser user featureName = do uid <- objId user req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "users", uid, "features", featureName] submit "DELETE" req + +getActivationCode :: (HasCallStack, MakesValue domain) => domain -> String -> App Response +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..833467fdeae --- /dev/null +++ b/integration/test/Test/PasswordReset.hs @@ -0,0 +1,100 @@ +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: +-- - 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 + + (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 687e9e0cb6f..5a2ce14881d 100644 --- a/integration/test/Testlib/HTTP.hs +++ b/integration/test/Testlib/HTTP.hs @@ -27,6 +27,7 @@ import Testlib.Assertions import Testlib.Env import Testlib.JSON import Testlib.Types +import Web.Cookie import Prelude splitHttpPath :: String -> [String] @@ -74,6 +75,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 2af53458908..2ab243378b0 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -421,7 +421,6 @@ executable brig-integration API.User.Client API.User.Connection API.User.Handles - API.User.PasswordReset API.User.Property API.User.RichInfo API.User.Util diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index 59e79905156..47758d2700c 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.Property qualified import API.User.RichInfo qualified import API.User.Util @@ -68,7 +67,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.Property.tests 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 26f1b5c41e4..00000000000 --- a/services/brig/test/integration/API/User/PasswordReset.hs +++ /dev/null @@ -1,121 +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 hiding (cs) -import Test.Tasty hiding (Timeout) -import Util -import Wire.API.User -import Wire.API.User.Auth - -tests :: - DB.ClientState -> - ConnectionLimit -> - Opt.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 cs, - test p "post /password-reset after put /access/self/email - 400" $ testPasswordResetAfterEmailUpdate b cs, - test p "post /password-reset/complete - password too short - 400" $ testPasswordResetInvalidPasswordLength b cs - ] - -testPasswordReset :: Brig -> DB.ClientState -> Http () -testPasswordReset brig cs = 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 - passwordResetData <- preparePasswordReset brig cs email uid newpw - completePasswordReset brig passwordResetData !!! const 200 === statusCode - -- try login - login brig (defEmailLogin email) PersistentCookie - !!! const 403 === statusCode - login - brig - (PasswordLogin (PasswordLoginData (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 cs email uid newpw - completePasswordReset brig passwordResetData !!! const 409 === statusCode - -testPasswordResetAfterEmailUpdate :: Brig -> DB.ClientState -> Http () -testPasswordResetAfterEmailUpdate brig cs = 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 cs 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 -> DB.ClientState -> Http () -testPasswordResetInvalidPasswordLength brig cs = 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 cs 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)) - )