Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1f2e9b6
feat: Add Login UI MFA Flow
matiasperrone-exo Jun 24, 2026
cc97f0f
fix: rename HTMLRender.jsx to .js so webpack can resolve it
smarcet Jul 23, 2026
0eca371
fix: revert password submit to native form POST
smarcet Jul 23, 2026
7a8dc5d
fix: add missing React import in HTMLRender to prevent ReferenceError…
smarcet Jul 23, 2026
e39b715
fix: cancel login now invalidates the pending MFA challenge server-side
smarcet Jul 23, 2026
f67f5b1
fix: stop the 2FA verify XHR from following cross-origin OAuth2 redir…
smarcet Jul 23, 2026
a062848
fix: cancel and session-expiry now correctly return to the password s…
smarcet Jul 23, 2026
f338172
fix: clear identity fields from session on MFA cancel/verify-success
smarcet Jul 23, 2026
ee02cca
fix: invalidate pending MFA challenge when identity chip is cleared
smarcet Jul 23, 2026
8c3e6fe
fix: block submitting an expired MFA code
smarcet Jul 23, 2026
0330c3b
fix: seed the MFA countdown with the remaining OTP lifetime after ref…
smarcet Jul 23, 2026
246c777
feat: add expiry countdown to the passwordless OTP form, dedup shared…
smarcet Jul 23, 2026
e2fc8e8
fix: surface cancelLogin failures instead of swallowing them in console
smarcet Jul 23, 2026
0a96737
feat: add 30s resend cooldown to the passwordless OTP screen
smarcet Jul 24, 2026
2bcadb3
feat: rate-limit the passwordless OTP issuance endpoint server-side
smarcet Jul 24, 2026
5970171
fix: mock EmailOTPMFAChallengeStrategy's new getCreatedAt() call in u…
smarcet Jul 24, 2026
5b352a1
feat: passwordless OTP screen survives browser refresh
smarcet Jul 24, 2026
7597db9
fix: show success snackbar when passwordless OTP code is (re)sent
smarcet Jul 24, 2026
3fcd5fa
fix: set Retry-After/X-RateLimit-* headers on 2FA rate-limit 429s
smarcet Jul 24, 2026
29a84db
fix: persist identity chip fallback for new passwordless-OTP users
smarcet Jul 24, 2026
28b5bc1
Refactor 2FA rate limiting to use RateLimiter::for() named limiters
smarcet Jul 25, 2026
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
22 changes: 14 additions & 8 deletions app/Http/Controllers/Traits/JsonResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Response;
use Exception;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
/**
* Trait JsonResponses
* @package App\Http\Controllers\Traits
Expand All @@ -23,11 +24,11 @@ trait JsonResponses
{
protected function error500(Exception $ex){
Log::error($ex);
return Response::json(array( 'error' => 'server error'), 500);
return Response::json(array( 'error' => 'server error'), HttpResponse::HTTP_INTERNAL_SERVER_ERROR);
}

protected function created($data='ok'){
$res = Response::json($data, 201);
$res = Response::json($data, HttpResponse::HTTP_CREATED );
//jsonp
if(Request::has('callback'))
$res->setCallback(Request::input('callback'));
Expand All @@ -36,7 +37,7 @@ protected function created($data='ok'){

protected function updated($data = 'ok', $has_content = true)
{
$res = Response::json($data, $has_content ? 201 : 204);
$res = Response::json($data, $has_content ? HttpResponse::HTTP_CREATED : HttpResponse::HTTP_NO_CONTENT);
//jsonp
if (Request::has('callback')) {
$res->setCallback(Request::input('callback'));
Expand All @@ -45,7 +46,7 @@ protected function updated($data = 'ok', $has_content = true)
}

protected function deleted($data='ok'){
$res = Response::json($data, 204);
$res = Response::json($data, HttpResponse::HTTP_NO_CONTENT);
//jsonp
if(Request::has('callback'))
$res->setCallback(Request::input('callback'));
Expand All @@ -61,19 +62,24 @@ protected function ok($data = 'ok'){
}

protected function error400($data = ['message' => 'Bad Request']){
return Response::json($data, 400);
return Response::json($data, HttpResponse::HTTP_BAD_REQUEST);
}

protected function error404($data = array('message' => 'Entity Not Found')){
if(!is_array($data)){
$data = ['message' => $data];
}
return Response::json($data, 404);
return Response::json($data, HttpResponse::HTTP_NOT_FOUND);
}

protected function error403($data = array('message' => 'Forbidden'))
{
return Response::json($data, 403);
return Response::json($data, HttpResponse::HTTP_FORBIDDEN);
}

protected function unauthorized($data = array('message' => 'UnAuthorized'))
{
return Response::json($data, HttpResponse::HTTP_UNAUTHORIZED);
}

/**
Expand All @@ -94,6 +100,6 @@ protected function error412($messages){
if(!is_array($messages)){
$messages = [$messages];
}
return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), 412);
return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), HttpResponse::HTTP_PRECONDITION_FAILED);
}
}
105 changes: 87 additions & 18 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,60 +13,57 @@
**/

use App\Http\Controllers\OpenId\DiscoveryController;
use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile;
use App\Jobs\RevokeUserGrantsOnExplicitLogout;
use App\Http\Controllers\OpenId\OpenIdController;
use App\Http\Controllers\Traits\JsonResponses;
use App\Http\Controllers\Traits\MFACookieManager;
use App\Http\Utils\CountryList;
use App\libs\Auth\Models\TwoFactorAuditLog;
use App\libs\OAuth2\Strategies\LoginHintProcessStrategy;
use App\ModelSerializers\SerializerRegistry;
use App\Services\Auth\IDeviceTrustService;
use App\Services\Auth\ITwoFactorAuditService;
use App\Services\Auth\ITwoFactorGateService;
use App\Services\Auth\ITwoFactorRateLimitService;
use Auth\User;
use Models\OAuth2\Client;
use Strategies\MFA\MFAChallengeStrategyFactory;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Utils\IPHelper;
use App\libs\OAuth2\Strategies\LoginHintProcessStrategy;
use App\ModelSerializers\SerializerRegistry;
use App\Services\Auth\IUserService as AuthUserService;
use Auth\Exceptions\AuthenticationException;
use Auth\Exceptions\UnverifiedEmailMemberException;
use App\Services\Auth\IUserService as AuthUserService;
use Auth\User;
use Exception;
use Illuminate\Http\Request as LaravelRequest;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\View;
use models\exceptions\EntityNotFoundException;
use models\exceptions\ValidationException;
use Models\OAuth2\Client;
use Models\OAuth2\OAuth2OTP;
use OAuth2\Factories\OAuth2AuthorizationRequestFactory;
use OAuth2\OAuth2Message;
use OAuth2\OAuth2Protocol;
use OAuth2\Repositories\IApiScopeRepository;
use OAuth2\Repositories\IClientRepository;
use OpenId\Services\IUserService;
use OAuth2\Services\IMementoOAuth2SerializerService;
use OAuth2\Services\IResourceServerService;
use OAuth2\Services\ISecurityContextService;
use OAuth2\Services\ITokenService;
use OpenId\Services\IMementoOpenIdSerializerService;
use OpenId\Services\ITrustedSitesService;
use OpenId\Services\IUserService;
use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile;
use Services\IUserActionService;
use Sokil\IsoCodes\IsoCodesFactory;
use Strategies\DefaultLoginStrategy;
use Strategies\IConsentStrategy;
use Strategies\MFA\MFAChallengeStrategyFactory;
use Strategies\OAuth2ConsentStrategy;
use Strategies\OAuth2LoginStrategy;
use Strategies\OpenIdConsentStrategy;
use Strategies\OpenIdLoginStrategy;
use Utils\IPHelper;
use Utils\Services\IAuthService;
use Utils\Services\IServerConfigurationService;
use Utils\Services\IServerConfigurationService as IUtilsServerConfigurationService;
Expand Down Expand Up @@ -395,6 +392,32 @@ public function emitOTP()
OAuth2Protocol::OAuth2PasswordlessPhoneNumber => ($connection == OAuth2Protocol::OAuth2PasswordlessConnectionSMS) ? $username : null
], $client);

// Restore-on-refresh: a subsequent GET /login can rehydrate the OTP
// screen from session instead of dropping back to the email form -
// same mechanism postLogin()'s MFA challengeRequired() branch already
// uses. user_verified is set unconditionally (not inside the
// existing-user lookup below) because loginWithOTP() auto-registers
// brand-new emails at redemption time; gating it on an existing user
// would silently break refresh-restoration for first-time passwordless
// users.
$existing_user = $this->auth_service->getUserByUsername($username);
Session::put('flow', IAuthService::AuthenticationFlowPasswordless);
Session::put('username', $username);
Session::put('user_verified', true);
// Mirrors login.js's emitOtpAction(), which falls back to the
// submitted email as the chip's display name when there's no real
// full name yet - persisting the same fallback here keeps the
// identity chip (visible right after opting into OTP) from
// vanishing on a refresh for a not-yet-registered email.
Session::put('user_fullname', !is_null($existing_user) ? $existing_user->getFullName() : $username);
Session::put('otp_length', $otp->getLength());
Session::put('otp_lifetime', $otp->getLifetime());
Session::put('otp_issued_at', $otp->getCreatedAt()?->getTimestamp() ?? time());
if (!is_null($existing_user)) {
Session::put('user_pic', $existing_user->getPic());
Session::put('user_is_active', $existing_user->isActive() ? 1 : 0);
}

return $this->created([
'otp_length' => $otp->getLength(),
'otp_lifetime' => $otp->getLifetime(),
Expand Down Expand Up @@ -534,6 +557,21 @@ public function postLogin()
Session::put('flow', IAuthService::AuthenticationFlowMFA);
Session::put('mfa_method', $method);

// The password step now submits as a native form POST, so this
// response is a fresh page load, not a client-side transition -
// without these, the React app remounts with no identity state
// at all (no chip, and Cancel/session-expiry can't return to the
// password screen because it looks like the user was never
// verified). Same fields/getters as the AuthenticationException
// errorLogin() branch below.
$payload = array_merge($payload, [
'username' => $username,
'user_fullname' => $user->getFullName(),
'user_pic' => $user->getPic(),
'user_verified' => true,
'user_is_active' => $user->isActive() ? 1 : 0,
]);

return $this->login_strategy->challengeRequired($payload);
}

Expand All @@ -558,6 +596,10 @@ public function postLogin()

$otpClaim = OAuth2OTP::fromParams($username, $connection, $password);
$this->auth_service->loginWithOTP($otpClaim, $client);
// A completed login must not leave the OTP screen restorable
// on a later refresh - same identity-leakage concern already
// fixed for the MFA flow's verify2FA()/verify2FARecovery().
$this->clearMFAUISessionState();
return $this->login_strategy->postLogin();
}
} catch (AuthenticationException $ex) {
Expand Down Expand Up @@ -749,7 +791,7 @@ public function verify2FA()
} catch (\Throwable $auditEx) {
Log::warning($auditEx);
}
return Response::json(['error_code' => 'mfa_verification_failed'], HttpResponse::HTTP_UNAUTHORIZED);
return $this->unauthorized(['error_code' => 'mfa_verification_failed']);
}

// Second factor verified: establish the session.
Expand Down Expand Up @@ -781,7 +823,17 @@ public function verify2FA()
Log::warning($ex);
}

return $this->login_strategy->postLogin();
// Return the same-origin post-login destination as data instead of a raw
// redirect for this XHR to follow: postLogin() can chain into a cross-origin
// hop (authorization code delivery to an already-consented OAuth2 client),
// which no XHR/fetch can read past - and per InteractiveGrantType::handle()'s
// consent-bypass branch, that hop also consumes the OAuth2 memento as a side
// effect, so a silently-failed XHR follow-through burns the authorization
// code with no way to recover it client-side. A real top-level navigation to
// this URL lets the browser complete that chain natively instead - CORS never
// applies to page navigations, only to XHR/fetch.
$redirect = $this->login_strategy->postLogin();
return $this->ok(['redirect_url' => $redirect->getTargetUrl()]);
} catch (ValidationException $ex) {
Log::warning($ex);
return $this->error412($ex->getMessages());
Expand Down Expand Up @@ -843,7 +895,7 @@ public function verify2FARecovery()
} catch (\Throwable $auditEx) {
Log::warning($auditEx);
}
return Response::json(['error_code' => 'mfa_invalid_recovery'], HttpResponse::HTTP_UNAUTHORIZED);
return $this->unauthorized(['error_code' => 'mfa_invalid_recovery']);
}

$this->auth_service->loginUser($user, (bool) $pending['remember']);
Expand All @@ -864,7 +916,10 @@ public function verify2FARecovery()
Log::warning($ex);
}

return $this->login_strategy->postLogin();
// See verify2FA() for rationale: return the destination as data so a real
// top-level navigation (not this XHR) performs any cross-origin hop.
$redirect = $this->login_strategy->postLogin();
return $this->ok(['redirect_url' => $redirect->getTargetUrl()]);
} catch (ValidationException $ex) {
Log::warning($ex);
return $this->error412($ex->getMessages());
Expand Down Expand Up @@ -917,6 +972,9 @@ public function resend2FA()
if (isset($payload['otp_lifetime'])) {
Session::put('otp_lifetime', $payload['otp_lifetime']);
}
if (isset($payload['otp_issued_at'])) {
Session::put('otp_issued_at', $payload['otp_issued_at']);
}

// Best-effort: the challenge was already re-issued and the OTP
// sent, so an audit-logging failure must not 500 the user out of
Expand Down Expand Up @@ -948,7 +1006,7 @@ public function resend2FA()
private function mfaSessionExpired()
{
$this->clearMFAUISessionState();
return Response::json(['error_code' => 'mfa_session_expired'], HttpResponse::HTTP_UNAUTHORIZED);
return $this->unauthorized(['error_code' => 'mfa_session_expired']);
}

/**
Expand All @@ -965,7 +1023,18 @@ private function clearMFAUISessionState(): void
Session::forget('mfa_method');
Session::forget('otp_length');
Session::forget('otp_lifetime');
Session::forget('otp_issued_at');
Session::forget('error_code');
// Identity/display fields written by postLogin()'s challengeRequired()
// payload (needed only to hydrate the React app on the initial
// post-redirect GET /login mount) - must not survive cancel/verify
// success/session-expiry, or a later visitor on the same browser
// session inherits the previous attempt's identity.
Session::forget('username');
Session::forget('user_fullname');
Session::forget('user_pic');
Session::forget('user_verified');
Session::forget('user_is_active');
}

/**
Expand Down
54 changes: 29 additions & 25 deletions app/Http/Middleware/TwoFactorRateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,29 @@

use App\Services\Auth\ITwoFactorRateLimitService;
use Closure;
use Illuminate\Cache\RateLimiting\Unlimited;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Illuminate\Support\Facades\RateLimiter;

/**
* Class TwoFactorRateLimitMiddleware
*
* Throttles the MFA verify / recovery / resend endpoints. Counter state and
* window logic live in ITwoFactorRateLimitService (shared with
* UserController::postLogin(), whose initial challenge issuance counts
* against the same resend window - SDS idp-mfa.md §4.12).
* against the same resend window - SDS idp-mfa.md §4.12). The throttled
* subject and the 429 response shape are resolved via the named
* RateLimiter::for() limiters registered in TwoFactorServiceProvider - this
* middleware only decides *when* a hit counts, since the stock throttle
* pipeline has no hook for that:
*
* - verify / recovery: increment ONLY on a failed response.
* - resend: increment on EVERY request.
* - resend / otp: increment on EVERY request.
*
* @package App\Http\Middleware
*/
final class TwoFactorRateLimitMiddleware
{
private const PENDING_USER_KEY = '2fa_pending_user_id';

/**
* Response error_code values that count as a verification failure.
*/
Expand All @@ -51,38 +52,41 @@ public function __construct(private readonly ITwoFactorRateLimitService $rate_li
/**
* @param \Illuminate\Http\Request $request
* @param Closure $next
* @param string $action one of verify|recovery|resend
* @param string $action one of verify|recovery|resend|otp
* @return mixed
*/
public function handle($request, Closure $next, string $action = ITwoFactorRateLimitService::ActionVerify)
{
$userId = Session::get(self::PENDING_USER_KEY);
$limiter = RateLimiter::limiter(ITwoFactorRateLimitService::RATE_LIMITER_NAME_PREFIX . $action);
$limit = $limiter ? $limiter($request) : null;

// Without a pending user there is nothing to throttle; let the controller
// resolve the (missing) state and return mfa_session_expired.
if (is_null($userId)) {
// No named limiter resolved a subject (no pending session / no otp
// username submitted) - nothing to throttle; let the controller
// resolve the (missing) state itself - mfa_session_expired for the
// session-keyed actions, or its own validator 412 for otp.
if (is_null($limit) || $limit instanceof Unlimited) {
return $next($request);
}

$userId = (int) $userId;
$subject = $limit->key;

if ($this->rate_limit_service->isRateLimited($action, $subject)) {
Log::debug(sprintf("TwoFactorRateLimitMiddleware: action %s subject %s rate limited", $action, $subject));

if ($this->rate_limit_service->isRateLimited($action, $userId)) {
Log::debug(sprintf("TwoFactorRateLimitMiddleware: action %s user %s rate limited", $action, $userId));
return Response::json(
[
'error_code' => ITwoFactorRateLimitService::RATE_LIMIT_ERROR_CODE,
'error_message' => ITwoFactorRateLimitService::RATE_LIMIT_MESSAGE,
],
HttpResponse::HTTP_TOO_MANY_REQUESTS
);
$responseCallback = $limit->responseCallback;
return $responseCallback($request, [
'Retry-After' => $this->rate_limit_service->getRetryAfterSeconds($action, $subject),
'X-RateLimit-Limit' => $this->rate_limit_service->getLimit($action),
'X-RateLimit-Remaining' => 0,
]);
}

$response = $next($request);

if ($action === ITwoFactorRateLimitService::ActionResend) {
$this->rate_limit_service->increment($action, $userId);
if ($action === ITwoFactorRateLimitService::ActionResend || $action === ITwoFactorRateLimitService::ActionOtp) {
$this->rate_limit_service->increment($action, $subject);
} else if ($this->isFailure($response)) {
$this->rate_limit_service->increment($action, $userId);
$this->rate_limit_service->increment($action, $subject);
}

return $response;
Expand Down
Loading
Loading