Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions _routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
94 changes: 94 additions & 0 deletions lib/GaletteOAuth2/Authorization/UserHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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
*/
Expand Down
84 changes: 83 additions & 1 deletion lib/GaletteOAuth2/Controllers/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
42 changes: 42 additions & 0 deletions templates/default/oauth2_two_factor.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}
<form action="{{ url_for(prefix ~ '_doTwoFactor') }}" method="post" class="ui form">
<div class="ui segments">
<div class="ui segment">
<p>{{ _T("Enter the code shown by your authentication application.") }}</p>
<div class="field">
<div class="ui left icon input">
<i class="mobile alternate icon" aria-hidden="true"></i>
<label for="code" class="visually-hidden">{{ _T("Code:") }}</label>
<input
type="text"
name="code"
id="code"
autofocus
autocomplete="one-time-code"
spellcheck="false"
placeholder="{{ _T("Code:") }}"
/>
</div>
</div>
<input type="submit" class="ui fluid large primary submit button" value="{{ _T("Validate") }}"/>
</div>
<div class="ui center aligned secondary segment">
<p class="ui small text">{{ _T("Lost your device? Enter one of your recovery codes above instead.") }}</p>
<a href="{{ url_for(prefix ~ '_logout') }}">
<i class="icon sign out alt" aria-hidden="true"></i>
{{ _T("Cancel and log off") }}
</a>
</div>
</div>
</form>
{% endblock %}
Loading
Loading