diff --git a/_routes.php b/_routes.php index 9f1eb11..66c5b21 100644 --- a/_routes.php +++ b/_routes.php @@ -44,6 +44,17 @@ [LoginController::class, 'doLogin'] )->setName(OAUTH2_PREFIX . '_doLogin'); +//second factor, when the member has one +$app->get( + '/two-factor', + [LoginController::class, 'twoFactor'] +)->setName(OAUTH2_PREFIX . '_twoFactor'); + +$app->post( + '/two-factor', + [LoginController::class, 'doTwoFactor'] +)->setName(OAUTH2_PREFIX . '_doTwoFactor'); + $app->map( ['GET', 'POST'], '/logout', diff --git a/lib/GaletteOAuth2/Authorization/UserHelper.php b/lib/GaletteOAuth2/Authorization/UserHelper.php index cbb1af4..e9740bf 100644 --- a/lib/GaletteOAuth2/Authorization/UserHelper.php +++ b/lib/GaletteOAuth2/Authorization/UserHelper.php @@ -18,6 +18,8 @@ use Galette\Core\I18n; use Galette\Core\Login; use Galette\Core\Preferences; +use Galette\Core\TwoFactorAuth; +use Galette\Core\TwoFactorSecret; use Galette\Entity\Adherent; use Galette\Entity\Social; use Galette\Entity\Status; @@ -61,6 +63,9 @@ public function __construct( */ public function login(string $nick, string $password, AuthThrottle $throttle): int|false { + //a second factor owed by a previous attempt must not outlive it + unset($this->session->login); + if (trim($nick) === '' || trim($password) === '') { return false; } @@ -110,6 +115,14 @@ public function login(string $nick, string $password, AuthThrottle $throttle): i $this->login->logIn($nick, $password); } + if ($this->login->isTwoFactorPending()) { + //credentials are accepted, but isLogged() stays false until the + //second factor is produced. The session is kept for the next step, + //so that the password is not carried over. + $this->session->login = $this->login; + return false; + } + if ($this->login->isLogged()) { $throttle->recordSuccess($nick); $this->session->login = $this->login; @@ -128,6 +141,87 @@ public function login(string $nick, string $password, AuthThrottle $throttle): i return false; } + /** + * Are credentials accepted, but a second factor still owed? + */ + public function isTwoFactorPending(): bool + { + $login = $this->session->login ?? null; + return $login instanceof Login && $login->isTwoFactorPending(); + } + + /** + * Check the second factor of a pending login + * + * Same checks as the core challenge: throttled, and a recovery code is + * accepted as well. When the second factor cannot be checked any more, the + * pending login is dropped and isTwoFactorPending() turns false. + * + * @param string $code Submitted code + * @param TwoFactorAuth $tfa Second factor service + * @param AuthThrottle $throttle Authentication throttle + * + * @return int|false Member ID, false on failure + */ + public function verifySecondFactor(string $code, TwoFactorAuth $tfa, AuthThrottle $throttle): int|false + { + if (!$this->isTwoFactorPending()) { + return false; + } + + /** @var Login $login */ + $login = $this->session->login; + $nick = (string)$login->login; + + //evaluated before any verification: reaching this stage means holding + //the password, so an attacker gets no free tries at the code + $delay = $throttle->getSecondFactorDelay($nick); + if ($delay > 0) { + Analog::log( + 'Second factor throttled for `' . $nick . '`, ' . $delay . ' seconds left.', + Analog::INFO + ); + $this->flash->addMessage( + 'error_detected', + _T("Too many failed attempts. Please try again later.") + ); + return false; + } + + $secret = $tfa->storeFor($login); + if (!$secret->isEnabled()) { + //removed while this session was mid-challenge + Analog::log( + 'No enabled second factor for ' . $secret->getOwner() . ' during challenge', + Analog::WARNING + ); + $throttle->recordSecondFactorFailure($nick); + $login->logOut(); + unset($this->session->login); + $this->flash->addMessage('error_detected', _T("Two-factor authentication failed.")); + return false; + } + + $accepted = $tfa->verify($secret, $code) + || ($secret instanceof TwoFactorSecret && $secret->consumeRecoveryCode($code)); + + if (!$accepted) { + $throttle->recordSecondFactorFailure($nick); + $this->history->add(_T("Second factor failed"), $nick); + $this->flash->addMessage('error_detected', _T("Two-factor authentication failed.")); + return false; + } + + //the authentication is only complete now, so this is where the counters + //of both stages are cleared + $throttle->recordSuccess($nick); + $login->validateTwoFactor(); + $this->session->login = $login; + $this->history->add(_T('Login')); + + return $login->id; + } + /** * Log out current member */ diff --git a/lib/GaletteOAuth2/Controllers/LoginController.php b/lib/GaletteOAuth2/Controllers/LoginController.php index 8c665dd..ff5a744 100755 --- a/lib/GaletteOAuth2/Controllers/LoginController.php +++ b/lib/GaletteOAuth2/Controllers/LoginController.php @@ -14,6 +14,7 @@ use DI\Attribute\Inject; use Galette\Controllers\AbstractPluginController; use Galette\Core\AuthThrottle; +use Galette\Core\TwoFactorAuth; use GaletteOAuth2\Authorization\UserAuthorizationException; use GaletteOAuth2\Authorization\UserHelper; use GaletteOAuth2\Repositories\ClientRepository; @@ -128,10 +129,91 @@ public function doLogin(Request $request, Response $response, AuthThrottle $thro ->withStatus(302) ->withHeader( 'Location', - $this->routeparser->urlFor(OAUTH2_PREFIX . '_login') + $this->routeparser->urlFor( + $this->userHelper->isTwoFactorPending() + ? OAUTH2_PREFIX . '_twoFactor' + : OAUTH2_PREFIX . '_login' + ) + ); + } + + return $this->completeLogin($response, $uid); + } + + /** + * Display second factor form + * + * @param Request $request Received request + * @param Response $response Response instance + */ + public function twoFactor(Request $request, Response $response): Response + { + Debug::logRequest('twoFactor()', $request); + + if (!$this->userHelper->isTwoFactorPending()) { + return $response + ->withStatus(302) + ->withHeader('Location', $this->routeparser->urlFor(OAUTH2_PREFIX . '_login')); + } + + $this->view->render( + $response, + $this->getTemplate(OAUTH2_PREFIX . '_two_factor'), + [ + 'page_title' => _T("Two-factor authentication"), + 'prefix' => OAUTH2_PREFIX + ] + ); + return $response; + } + + /** + * Check second factor + * + * @param Request $request Received request + * @param Response $response Response instance + * @param TwoFactorAuth $tfa Second factor service + * @param AuthThrottle $throttle Authentication throttle + */ + public function doTwoFactor( + Request $request, + Response $response, + TwoFactorAuth $tfa, + AuthThrottle $throttle + ): Response { + $params = $request->getParsedBody(); + $code = is_array($params) ? ($params['code'] ?? '') : ''; + $code = is_scalar($code) ? trim((string)$code) : ''; + + $this->session->isLoggedIn = 'no'; + unset($this->session->client_id); + $this->session->user_id = $uid = $this->userHelper->verifySecondFactor($code, $tfa, $throttle); + Debug::log("UserHelper::verifySecondFactor() return '{$uid}'"); + + if (false === $uid) { + return $response + ->withStatus(302) + ->withHeader( + 'Location', + $this->routeparser->urlFor( + $this->userHelper->isTwoFactorPending() + ? OAUTH2_PREFIX . '_twoFactor' + : OAUTH2_PREFIX . '_login' + ) ); } + return $this->completeLogin($response, $uid); + } + + /** + * Check rights of an authenticated member, then go on with authorization + * + * @param Response $response Response instance + * @param int $uid Member ID + */ + private function completeLogin(Response $response, int $uid): Response + { try { $client_id = $this->session->request_args['client_id']; $this->userHelper->getUserData( diff --git a/templates/default/oauth2_two_factor.html.twig b/templates/default/oauth2_two_factor.html.twig new file mode 100644 index 0000000..32a93b4 --- /dev/null +++ b/templates/default/oauth2_two_factor.html.twig @@ -0,0 +1,42 @@ +{# + # This file is part of Galette OAuth2 plugin (https://galette-plugins.github.io/plugin-oauth2/). + # SPDX-FileCopyrightText: Copyright © 2021-2026 The Galette Team + # SPDX-License-Identifier: GPL-3.0-or-later + #} +{% extends 'public_page.html.twig' %} + +{% set ext_auth = true %} +{% set body_class = "front_page" %} + +{% block content %} +
+
+
+

{{ _T("Enter the code shown by your authentication application.") }}

+
+
+ + + +
+
+ +
+
+

{{ _T("Lost your device? Enter one of your recovery codes above instead.") }}

+ + + {{ _T("Cancel and log off") }} + +
+
+
+{% endblock %} diff --git a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php index 37a69bc..2b3ed56 100644 --- a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php +++ b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php @@ -425,6 +425,171 @@ public function testDoLoginSuccessClearsThrottle(): void $this->assertSame(0, $this->zdb->execute($select)->count()); } + /** + * A member with a second factor is asked for it before the flow goes on + * + * @return void + */ + public function testDoLoginWithSecondFactor(): void + { + //clients are team only by default + $member = $this->getAdminMember($this->getMemberOne()); + $data = $this->dataAdherentOne(); + $secret = $this->enrolSecondFactor($member->id); + $this->session->request_args = [ + 'response_type' => 'code', + 'client_id' => 'galette_cli', + 'redirect_uri' => 'http://localhost:8888', + 'scope' => 'member', + 'state' => 'login-state', + ]; + + $test_response = $this->app->handle($this->doLoginRequest($data['login_adh'], $data['mdp_adh'])); + + $this->assertSame(302, $test_response->getStatusCode()); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_twoFactor')]], + $test_response->getHeaders() + ); + //credentials alone do not open the authorization + $this->assertSame('no', $this->session->isLoggedIn); + $this->assertFalse($this->session->user_id); + + //the second factor page is reachable + $test_response = $this->app->handle( + $this->createRequest(route_name: OAUTH2_PREFIX . '_twoFactor') + ); + $this->assertSame(200, $test_response->getStatusCode()); + $this->assertStringContainsString( + 'action="' . $this->routeparser->urlFor(OAUTH2_PREFIX . '_doTwoFactor') . '"', + (string)$test_response->getBody() + ); + + //a wrong code keeps the door shut + $test_response = $this->app->handle($this->doTwoFactorRequest('000000')); + $this->assertSame(302, $test_response->getStatusCode()); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_twoFactor')]], + $test_response->getHeaders() + ); + $this->assertSame('no', $this->session->isLoggedIn); + + //the right one goes on with the authorization + $tfa = $this->container->get(\Galette\Core\TwoFactorAuth::class); + $sid = session_id(); + $test_response = $this->app->handle($this->doTwoFactorRequest($tfa->getCodeAt($secret->getSecret()))); + + $this->assertSame(302, $test_response->getStatusCode()); + $this->assertStringStartsWith( + $this->routeparser->urlFor(OAUTH2_PREFIX . '_authorize') . '?', + $test_response->getHeaderLine('Location') + ); + $this->assertSame('yes', $this->session->isLoggedIn); + $this->assertSame($member->id, $this->session->user_id); + $this->assertSame('galette_cli', $this->session->client_id); + $this->assertNotSame($sid, session_id()); + } + + /** + * A recovery code gets through the second factor too + * + * @return void + */ + public function testDoTwoFactorWithRecoveryCode(): void + { + //clients are team only by default + $member = $this->getAdminMember($this->getMemberOne()); + $data = $this->dataAdherentOne(); + $secret = $this->enrolSecondFactor($member->id); + $codes = $secret->generateRecoveryCodes(); + $this->session->request_args = [ + 'response_type' => 'code', + 'client_id' => 'galette_cli', + 'redirect_uri' => 'http://localhost:8888', + 'scope' => 'member', + ]; + + $this->app->handle($this->doLoginRequest($data['login_adh'], $data['mdp_adh'])); + $test_response = $this->app->handle($this->doTwoFactorRequest($codes[0])); + + $this->assertStringStartsWith( + $this->routeparser->urlFor(OAUTH2_PREFIX . '_authorize') . '?', + $test_response->getHeaderLine('Location') + ); + $this->assertSame($member->id, $this->session->user_id); + + //and that code is spent + $reloaded = new \Galette\Core\TwoFactorSecret($this->zdb); + $reloaded->load($member->id); + $this->assertSame(count($codes) - 1, $reloaded->countRemainingCodes()); + } + + /** + * Failed codes are throttled, even the right one is refused afterwards + * + * @return void + */ + public function testDoTwoFactorIsThrottled(): void + { + $member = $this->getMemberOne(); + $data = $this->dataAdherentOne(); + $secret = $this->enrolSecondFactor($member->id); + $this->session->request_args = [ + 'response_type' => 'code', + 'client_id' => 'galette_cli', + 'redirect_uri' => 'http://localhost:8888', + ]; + + $this->app->handle($this->doLoginRequest($data['login_adh'], $data['mdp_adh'])); + for ($i = 0; $i < 10; ++$i) { + $this->app->handle($this->doTwoFactorRequest('000000')); + } + + $tfa = $this->container->get(\Galette\Core\TwoFactorAuth::class); + $test_response = $this->app->handle($this->doTwoFactorRequest($tfa->getCodeAt($secret->getSecret()))); + + $this->assertSame( + ['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_twoFactor')]], + $test_response->getHeaders() + ); + $this->assertSame('no', $this->session->isLoggedIn); + $this->expectLogEntry(Analog::INFO, 'Second factor throttled for `' . $data['login_adh'] . '`'); + } + + /** + * With nothing pending, the second factor is not a page anyone can sit on + * + * @return void + */ + public function testTwoFactorNeedsAPendingLogin(): void + { + $login_url = $this->routeparser->urlFor(OAUTH2_PREFIX . '_login'); + + $test_response = $this->app->handle( + $this->createRequest(route_name: OAUTH2_PREFIX . '_twoFactor') + ); + $this->assertSame(['Location' => [$login_url]], $test_response->getHeaders()); + + $test_response = $this->app->handle($this->doTwoFactorRequest('000000')); + $this->assertSame(['Location' => [$login_url]], $test_response->getHeaders()); + $this->assertSame('no', $this->session->isLoggedIn); + } + + /** + * Enrol a member with a known secret, second factor being optional + * + * @param int $id_adh Member identifier + */ + private function enrolSecondFactor(int $id_adh): \Galette\Core\TwoFactorSecret + { + $this->preferences->pref_2fa_mode = \Galette\Core\TwoFactorAuth::MODE_OPTIONAL; + + $secret = new \Galette\Core\TwoFactorSecret($this->zdb); + $secret->create($id_adh, 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); + $secret->enable(); + return $secret; + } + /** * Build a login request * @@ -436,4 +601,15 @@ private function doLoginRequest(string $login, string $password): \Slim\Psr7\Req return $this->createRequest(route_name: OAUTH2_PREFIX . '_doLogin', method: 'POST') ->withParsedBody(['login' => $login, 'password' => $password]); } + + /** + * Build a second factor request + * + * @param string $code Code to submit + */ + private function doTwoFactorRequest(string $code): \Slim\Psr7\Request + { + return $this->createRequest(route_name: OAUTH2_PREFIX . '_doTwoFactor', method: 'POST') + ->withParsedBody(['code' => $code]); + } }