2021-08-02 22:04:43 +08:00
< ? php
namespace Tests\Auth ;
2023-05-18 00:56:55 +08:00
use BookStack\Access\LoginService ;
use BookStack\Access\Mfa\MfaValue ;
use BookStack\Access\Mfa\TotpService ;
2021-08-03 05:02:25 +08:00
use BookStack\Exceptions\StoppedAuthenticationException ;
2023-05-18 00:56:55 +08:00
use BookStack\Users\Models\Role ;
use BookStack\Users\Models\User ;
2021-08-02 22:04:43 +08:00
use Illuminate\Support\Facades\Hash ;
use PragmaRX\Google2FA\Google2FA ;
use Tests\TestCase ;
use Tests\TestResponse ;
class MfaVerificationTest extends TestCase
{
public function test_totp_verification ()
{
[ $user , $secret , $loginResp ] = $this -> startTotpLogin ();
$loginResp -> assertRedirect ( '/mfa/verify' );
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSee ( 'Verify Access' );
$resp -> assertSee ( 'Enter the code, generated using your mobile app, below:' );
2022-07-23 22:10:18 +08:00
$this -> withHtml ( $resp ) -> assertElementExists ( 'form[action$="/mfa/totp/verify"] input[name="code"][autofocus]' );
2021-08-02 22:04:43 +08:00
$google2fa = new Google2FA ();
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/totp/verify' , [
2021-08-02 22:04:43 +08:00
'code' => $google2fa -> getCurrentOtp ( $secret ),
]);
$resp -> assertRedirect ( '/' );
$this -> assertEquals ( $user -> id , auth () -> user () -> id );
}
public function test_totp_verification_fails_on_missing_invalid_code ()
{
[ $user , $secret , $loginResp ] = $this -> startTotpLogin ();
$resp = $this -> get ( '/mfa/verify' );
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/totp/verify' , [
2021-08-02 22:04:43 +08:00
'code' => '' ,
]);
$resp -> assertRedirect ( '/mfa/verify' );
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSeeText ( 'The code field is required.' );
$this -> assertNull ( auth () -> user ());
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/totp/verify' , [
2021-08-02 22:04:43 +08:00
'code' => '123321' ,
]);
$resp -> assertRedirect ( '/mfa/verify' );
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSeeText ( 'The provided code is not valid or has expired.' );
$this -> assertNull ( auth () -> user ());
}
2024-03-11 02:31:01 +08:00
public function test_totp_form_has_autofill_configured ()
{
[ $user , $secret , $loginResp ] = $this -> startTotpLogin ();
$html = $this -> withHtml ( $this -> get ( '/mfa/verify' ));
$html -> assertElementExists ( 'form[autocomplete="off"][action$="/verify"]' );
$html -> assertElementExists ( 'input[autocomplete="one-time-code"][name="code"]' );
}
2021-08-02 23:35:37 +08:00
public function test_backup_code_verification ()
{
[ $user , $codes , $loginResp ] = $this -> startBackupCodeLogin ();
$loginResp -> assertRedirect ( '/mfa/verify' );
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSee ( 'Verify Access' );
$resp -> assertSee ( 'Backup Code' );
$resp -> assertSee ( 'Enter one of your remaining backup codes below:' );
2022-07-23 22:10:18 +08:00
$this -> withHtml ( $resp ) -> assertElementExists ( 'form[action$="/mfa/backup_codes/verify"] input[name="code"]' );
2021-08-02 23:35:37 +08:00
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/backup_codes/verify' , [
2021-08-02 23:35:37 +08:00
'code' => $codes [ 1 ],
]);
$resp -> assertRedirect ( '/' );
$this -> assertEquals ( $user -> id , auth () -> user () -> id );
// Ensure code no longer exists in available set
$userCodes = MfaValue :: getValueForUser ( $user , MfaValue :: METHOD_BACKUP_CODES );
$this -> assertStringNotContainsString ( $codes [ 1 ], $userCodes );
$this -> assertStringContainsString ( $codes [ 0 ], $userCodes );
}
public function test_backup_code_verification_fails_on_missing_or_invalid_code ()
{
[ $user , $codes , $loginResp ] = $this -> startBackupCodeLogin ();
$resp = $this -> get ( '/mfa/verify' );
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/backup_codes/verify' , [
2021-08-02 23:35:37 +08:00
'code' => '' ,
]);
$resp -> assertRedirect ( '/mfa/verify' );
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSeeText ( 'The code field is required.' );
$this -> assertNull ( auth () -> user ());
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/backup_codes/verify' , [
2021-08-02 23:35:37 +08:00
'code' => 'ab123-ab456' ,
]);
$resp -> assertRedirect ( '/mfa/verify' );
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSeeText ( 'The provided code is not valid or has already been used.' );
$this -> assertNull ( auth () -> user ());
}
public function test_backup_code_verification_fails_on_attempted_code_reuse ()
{
[ $user , $codes , $loginResp ] = $this -> startBackupCodeLogin ();
2021-08-03 05:02:25 +08:00
$this -> post ( '/mfa/backup_codes/verify' , [
2021-08-02 23:35:37 +08:00
'code' => $codes [ 0 ],
]);
$this -> assertNotNull ( auth () -> user ());
auth () -> logout ();
session () -> flush ();
$this -> post ( '/login' , [ 'email' => $user -> email , 'password' => 'password' ]);
$this -> get ( '/mfa/verify' );
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/backup_codes/verify' , [
2021-08-02 23:35:37 +08:00
'code' => $codes [ 0 ],
]);
$resp -> assertRedirect ( '/mfa/verify' );
$this -> assertNull ( auth () -> user ());
$resp = $this -> get ( '/mfa/verify' );
$resp -> assertSeeText ( 'The provided code is not valid or has already been used.' );
}
public function test_backup_code_verification_shows_warning_when_limited_codes_remain ()
{
[ $user , $codes , $loginResp ] = $this -> startBackupCodeLogin ([ 'abc12-def45' , 'abc12-def46' ]);
2021-08-03 05:02:25 +08:00
$resp = $this -> post ( '/mfa/backup_codes/verify' , [
2021-08-02 23:35:37 +08:00
'code' => $codes [ 0 ],
]);
$resp = $this -> followRedirects ( $resp );
$resp -> assertSeeText ( 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.' );
}
2024-03-11 02:31:01 +08:00
public function test_backup_code_form_has_autofill_configured ()
{
[ $user , $codes , $loginResp ] = $this -> startBackupCodeLogin ();
$html = $this -> withHtml ( $this -> get ( '/mfa/verify' ));
$html -> assertElementExists ( 'form[autocomplete="off"][action$="/verify"]' );
$html -> assertElementExists ( 'input[autocomplete="one-time-code"][name="code"]' );
}
2021-08-02 23:35:37 +08:00
public function test_both_mfa_options_available_if_set_on_profile ()
{
2023-01-21 19:08:34 +08:00
$user = $this -> users -> editor ();
2021-08-02 23:35:37 +08:00
$user -> password = Hash :: make ( 'password' );
$user -> save ();
MfaValue :: upsertWithValue ( $user , MfaValue :: METHOD_TOTP , 'abc123' );
MfaValue :: upsertWithValue ( $user , MfaValue :: METHOD_BACKUP_CODES , '["abc12-def456"]' );
/** @var TestResponse $mfaView */
$mfaView = $this -> followingRedirects () -> post ( '/login' , [
2021-08-21 22:49:40 +08:00
'email' => $user -> email ,
2021-08-02 23:35:37 +08:00
'password' => 'password' ,
]);
// Totp shown by default
2022-07-23 22:10:18 +08:00
$this -> withHtml ( $mfaView ) -> assertElementExists ( 'form[action$="/mfa/totp/verify"] input[name="code"]' );
$this -> withHtml ( $mfaView ) -> assertElementContains ( 'a[href$="/mfa/verify?method=backup_codes"]' , 'Verify using a backup code' );
2021-08-02 23:35:37 +08:00
// Ensure can view backup_codes view
$resp = $this -> get ( '/mfa/verify?method=backup_codes' );
2022-07-23 22:10:18 +08:00
$this -> withHtml ( $resp ) -> assertElementExists ( 'form[action$="/mfa/backup_codes/verify"] input[name="code"]' );
$this -> withHtml ( $resp ) -> assertElementContains ( 'a[href$="/mfa/verify?method=totp"]' , 'Verify using a mobile app' );
2021-08-02 23:35:37 +08:00
}
2021-08-03 05:02:25 +08:00
public function test_mfa_required_with_no_methods_leads_to_setup ()
{
2023-01-21 19:08:34 +08:00
$user = $this -> users -> editor ();
2021-08-03 05:02:25 +08:00
$user -> password = Hash :: make ( 'password' );
$user -> save ();
/** @var Role $role */
$role = $user -> roles -> first ();
$role -> mfa_enforced = true ;
$role -> save ();
$this -> assertDatabaseMissing ( 'mfa_values' , [
'user_id' => $user -> id ,
]);
/** @var TestResponse $resp */
$resp = $this -> followingRedirects () -> post ( '/login' , [
2021-08-21 22:49:40 +08:00
'email' => $user -> email ,
2021-08-03 05:02:25 +08:00
'password' => 'password' ,
]);
$resp -> assertSeeText ( 'No Methods Configured' );
2022-07-23 22:10:18 +08:00
$this -> withHtml ( $resp ) -> assertElementContains ( 'a[href$="/mfa/setup"]' , 'Configure' );
2021-08-03 05:02:25 +08:00
$this -> get ( '/mfa/backup_codes/generate' );
2021-08-21 22:14:24 +08:00
$resp = $this -> post ( '/mfa/backup_codes/confirm' );
$resp -> assertRedirect ( '/login' );
2021-08-03 05:02:25 +08:00
$this -> assertDatabaseHas ( 'mfa_values' , [
'user_id' => $user -> id ,
]);
2021-08-21 22:14:24 +08:00
$resp = $this -> get ( '/login' );
$resp -> assertSeeText ( 'Multi-factor method configured, Please now login again using the configured method.' );
2021-08-03 05:02:25 +08:00
$resp = $this -> followingRedirects () -> post ( '/login' , [
2021-08-21 22:49:40 +08:00
'email' => $user -> email ,
2021-08-03 05:02:25 +08:00
'password' => 'password' ,
]);
$resp -> assertSeeText ( 'Enter one of your remaining backup codes below:' );
}
public function test_mfa_setup_route_access ()
{
$routes = [
[ 'get' , '/mfa/setup' ],
[ 'get' , '/mfa/totp/generate' ],
[ 'post' , '/mfa/totp/confirm' ],
[ 'get' , '/mfa/backup_codes/generate' ],
[ 'post' , '/mfa/backup_codes/confirm' ],
];
// Non-auth access
foreach ( $routes as [ $method , $path ]) {
$resp = $this -> call ( $method , $path );
$resp -> assertRedirect ( '/login' );
}
// Attempted login user, who has configured mfa, access
// Sets up user that has MFA required after attempted login.
$loginService = $this -> app -> make ( LoginService :: class );
2023-01-21 19:08:34 +08:00
$user = $this -> users -> editor ();
2021-08-03 05:02:25 +08:00
/** @var Role $role */
$role = $user -> roles -> first ();
$role -> mfa_enforced = true ;
$role -> save ();
2021-08-21 22:49:40 +08:00
2021-08-03 05:02:25 +08:00
try {
$loginService -> login ( $user , 'testing' );
} catch ( StoppedAuthenticationException $e ) {
}
$this -> assertNotNull ( $loginService -> getLastLoginAttemptUser ());
MfaValue :: upsertWithValue ( $user , MfaValue :: METHOD_BACKUP_CODES , '[]' );
foreach ( $routes as [ $method , $path ]) {
$resp = $this -> call ( $method , $path );
$resp -> assertRedirect ( '/login' );
}
}
2021-08-02 23:35:37 +08:00
2022-05-31 01:31:08 +08:00
public function test_login_mfa_interception_does_not_log_error ()
{
$logHandler = $this -> withTestLogger ();
[ $user , $secret , $loginResp ] = $this -> startTotpLogin ();
$loginResp -> assertRedirect ( '/mfa/verify' );
$this -> assertFalse ( $logHandler -> hasErrorRecords ());
}
2021-08-02 22:04:43 +08:00
/**
2021-11-01 21:26:02 +08:00
* @ return array < User , string , TestResponse >
2021-08-02 22:04:43 +08:00
*/
protected function startTotpLogin () : array
{
$secret = $this -> app -> make ( TotpService :: class ) -> generateSecret ();
2023-01-21 19:08:34 +08:00
$user = $this -> users -> editor ();
2021-08-02 22:04:43 +08:00
$user -> password = Hash :: make ( 'password' );
$user -> save ();
MfaValue :: upsertWithValue ( $user , MfaValue :: METHOD_TOTP , $secret );
$loginResp = $this -> post ( '/login' , [
2021-08-21 22:49:40 +08:00
'email' => $user -> email ,
2021-08-02 22:04:43 +08:00
'password' => 'password' ,
]);
return [ $user , $secret , $loginResp ];
}
2021-08-02 23:35:37 +08:00
/**
2021-11-01 21:26:02 +08:00
* @ return array < User , string , TestResponse >
2021-08-02 23:35:37 +08:00
*/
2021-08-21 22:49:40 +08:00
protected function startBackupCodeLogin ( $codes = [ 'kzzu6-1pgll' , 'bzxnf-plygd' , 'bwdsp-ysl51' , '1vo93-ioy7n' , 'lf7nw-wdyka' , 'xmtrd-oplac' ]) : array
2021-08-02 23:35:37 +08:00
{
2023-01-21 19:08:34 +08:00
$user = $this -> users -> editor ();
2021-08-02 23:35:37 +08:00
$user -> password = Hash :: make ( 'password' );
$user -> save ();
MfaValue :: upsertWithValue ( $user , MfaValue :: METHOD_BACKUP_CODES , json_encode ( $codes ));
$loginResp = $this -> post ( '/login' , [
2021-08-21 22:49:40 +08:00
'email' => $user -> email ,
2021-08-02 23:35:37 +08:00
'password' => 'password' ,
]);
return [ $user , $codes , $loginResp ];
}
2021-08-21 22:49:40 +08:00
}