diff --git a/app/Http/Controllers/Traits/JsonResponses.php b/app/Http/Controllers/Traits/JsonResponses.php
index e71726d1..b0d9fff7 100644
--- a/app/Http/Controllers/Traits/JsonResponses.php
+++ b/app/Http/Controllers/Traits/JsonResponses.php
@@ -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
@@ -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'));
@@ -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'));
@@ -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'));
@@ -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);
}
/**
@@ -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);
}
}
\ No newline at end of file
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 6f57856d..ef3c49ce 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -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;
@@ -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(),
@@ -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);
}
@@ -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) {
@@ -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.
@@ -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());
@@ -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']);
@@ -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());
@@ -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
@@ -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']);
}
/**
@@ -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');
}
/**
diff --git a/app/Http/Middleware/TwoFactorRateLimitMiddleware.php b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php
index 7a4ec2d4..e197735b 100644
--- a/app/Http/Middleware/TwoFactorRateLimitMiddleware.php
+++ b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php
@@ -14,10 +14,9 @@
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
@@ -25,17 +24,19 @@
* 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.
*/
@@ -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;
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index cc31990c..7d9b56f3 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -57,10 +57,6 @@ protected function configureRateLimiting()
return Limit::perMinute(5)->by(optional($request->user())->id ?: $request->ip());
});
- RateLimiter::for('otp', function (Request $request) {
- return Limit::perMinute(10)->by(optional($request->user())->id ?: $request->ip());
- });
-
RateLimiter::for('oauth2', function (Request $request) {
$maxAttempts = App::environment() == "testing" ? PHP_INT_MAX : 50;
return Limit::perMinute($maxAttempts)->by(optional($request->user())->id ?: $request->ip());
diff --git a/app/Services/Auth/ITwoFactorRateLimitService.php b/app/Services/Auth/ITwoFactorRateLimitService.php
index 46b7a835..47f1c587 100644
--- a/app/Services/Auth/ITwoFactorRateLimitService.php
+++ b/app/Services/Auth/ITwoFactorRateLimitService.php
@@ -24,6 +24,11 @@
* routes) and UserController::postLogin() (initial challenge issuance,
* which shares the resend window per SDS idp-mfa.md §4.12).
*
+ * Also the single source of truth for the pending-user session key, shared
+ * with the named RateLimiter::for() limiters registered in
+ * TwoFactorServiceProvider that resolve the subject (Limit::by()) and the
+ * 429 response shape (Limit::response()) for these actions.
+ *
* @package App\Services\Auth
*/
interface ITwoFactorRateLimitService
@@ -31,21 +36,68 @@ interface ITwoFactorRateLimitService
public const ActionVerify = 'verify';
public const ActionRecovery = 'recovery';
public const ActionResend = 'resend';
+ public const ActionOtp = 'otp';
public const RATE_LIMIT_ERROR_CODE = 'mfa_rate_limit';
public const RATE_LIMIT_MESSAGE = 'Too many attempts. Please try again later.';
/**
- * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend
- * @param int $userId
+ * Session key holding the user id of the pending MFA challenge - the
+ * subject the verify/recovery/resend named limiters throttle by.
+ */
+ public const PENDING_USER_SESSION_KEY = '2fa_pending_user_id';
+
+ /**
+ * Prefix applied to the Action* constants when registering/looking up
+ * the named RateLimiter::for() limiters (TwoFactorServiceProvider /
+ * TwoFactorRateLimitMiddleware). RateLimiter::for() names are a single
+ * global namespace shared with RouteServiceProvider - notably its own
+ * 'otp' limiter - so the bare action string ('otp') can't be reused
+ * directly as the limiter name without silently clobbering it.
+ */
+ public const RATE_LIMITER_NAME_PREFIX = '2fa-rate:';
+
+ /**
+ * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp
+ * @param string|int $subject a user id for session-keyed actions, or a raw
+ * (already-canonicalized) subject string for ActionOtp
* @return bool
*/
- public function isRateLimited(string $action, int $userId): bool;
+ public function isRateLimited(string $action, string|int $subject): bool;
/**
- * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend
- * @param int $userId
+ * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp
+ * @param string|int $subject a user id for session-keyed actions, or a raw
+ * (already-canonicalized) subject string for ActionOtp
* @return void
*/
- public function increment(string $action, int $userId): void;
+ public function increment(string $action, string|int $subject): void;
+
+ /**
+ * The configured max-attempts ceiling for this action, for the
+ * X-RateLimit-Limit response header.
+ * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp
+ * @return int
+ */
+ public function getLimit(string $action): int;
+
+ /**
+ * The configured window length for this action, in seconds - used to
+ * build the matching RateLimiter::for() Limit definition in
+ * TwoFactorServiceProvider so its decaySeconds stays in sync with the
+ * value this service actually enforces.
+ * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp
+ * @return int
+ */
+ public function getWindowSeconds(string $action): int;
+
+ /**
+ * Seconds remaining until this subject's current window resets, for the
+ * Retry-After response header. Returns 0 if there is no active window.
+ * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp
+ * @param string|int $subject a user id for session-keyed actions, or a raw
+ * (already-canonicalized) subject string for ActionOtp
+ * @return int
+ */
+ public function getRetryAfterSeconds(string $action, string|int $subject): int;
}
diff --git a/app/Services/Auth/TwoFactorRateLimitService.php b/app/Services/Auth/TwoFactorRateLimitService.php
index 7bdda6f6..13967462 100644
--- a/app/Services/Auth/TwoFactorRateLimitService.php
+++ b/app/Services/Auth/TwoFactorRateLimitService.php
@@ -15,8 +15,8 @@
* limitations under the License.
**/
-use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Facades\RateLimiter;
/**
* Class TwoFactorRateLimitService
@@ -24,22 +24,40 @@
*/
final class TwoFactorRateLimitService implements ITwoFactorRateLimitService
{
- public function isRateLimited(string $action, int $userId): bool
+ public function isRateLimited(string $action, string|int $subject): bool
{
[$maxAttempts, ] = $this->limitsFor($action);
- return (int) Cache::get($this->cacheKey($action, $userId), 0) >= $maxAttempts;
+ return RateLimiter::tooManyAttempts($this->cacheKey($action, $subject), $maxAttempts);
}
- public function increment(string $action, int $userId): void
+ public function increment(string $action, string|int $subject): void
{
[, $windowSeconds] = $this->limitsFor($action);
- $key = $this->cacheKey($action, $userId);
- // Fixed window: add() sets the TTL once (only if the key is absent),
- // increment() bumps the value while preserving that TTL, so the
- // window starts at the first hit and does not slide.
- Cache::add($key, 0, $windowSeconds);
- Cache::increment($key);
+ // Fixed window: RateLimiter::hit() sets a companion "resets at" timer
+ // key once (only if absent) and bumps the counter while preserving
+ // that timer, so the window starts at the first hit and does not
+ // slide - same semantics the previous hand-rolled Cache::add()+
+ // increment() had, but this also gives us availableIn() for
+ // Retry-After without a driver-specific TTL query.
+ RateLimiter::hit($this->cacheKey($action, $subject), $windowSeconds);
+ }
+
+ public function getLimit(string $action): int
+ {
+ [$maxAttempts, ] = $this->limitsFor($action);
+ return $maxAttempts;
+ }
+
+ public function getWindowSeconds(string $action): int
+ {
+ [, $windowSeconds] = $this->limitsFor($action);
+ return $windowSeconds;
+ }
+
+ public function getRetryAfterSeconds(string $action, string|int $subject): int
+ {
+ return RateLimiter::availableIn($this->cacheKey($action, $subject));
}
/**
@@ -55,14 +73,27 @@ private function limitsFor(string $action): array
];
}
+ if ($action === self::ActionOtp) {
+ return [
+ (int) Config::get('two_factor.rate_limit.max_otp_email_requests', 5),
+ (int) Config::get('two_factor.rate_limit.otp_email_window_minutes', 15) * 60,
+ ];
+ }
+
return [
(int) Config::get('two_factor.rate_limit.max_attempts', 3),
(int) Config::get('two_factor.rate_limit.window_seconds', 900),
];
}
- private function cacheKey(string $action, int $userId): string
+ /**
+ * @param string $action
+ * @param string|int $subject a user id for session-keyed actions, or a raw
+ * (already-canonicalized) subject string for ActionOtp
+ * @return string
+ */
+ private function cacheKey(string $action, string|int $subject): string
{
- return sprintf('2fa_rate:%s:%s', $action, $userId);
+ return sprintf('2fa_rate:%s:%s', $action, $subject);
}
}
diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php
index 76b3750f..0bb18a6f 100644
--- a/app/Services/Auth/TwoFactorServiceProvider.php
+++ b/app/Services/Auth/TwoFactorServiceProvider.php
@@ -15,9 +15,15 @@
* limitations under the License.
**/
+use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Support\DeferrableProvider;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Support\Facades\Response;
+use Illuminate\Support\Facades\Session;
use Illuminate\Support\ServiceProvider;
+use Symfony\Component\HttpFoundation\Response as HttpResponse;
/**
* Class TwoFactorServiceProvider
@@ -27,6 +33,7 @@ final class TwoFactorServiceProvider extends ServiceProvider implements Deferrab
{
public function boot(): void
{
+ $this->registerRateLimiters();
}
public function register(): void
@@ -37,6 +44,65 @@ public function register(): void
$this->app->singleton(ITwoFactorRateLimitService::class, TwoFactorRateLimitService::class);
}
+ /**
+ * Named RateLimiter::for() limiters for the 2FA actions - own the two
+ * things that vanilla ->middleware('throttle:...') can declare: the
+ * throttled subject (Limit::by()) and the 429 response shape
+ * (Limit::response()). TwoFactorRateLimitMiddleware still enforces the
+ * limit itself and decides *when* to count a hit, because the stock
+ * throttle pipeline always increments before the request reaches the
+ * controller and has no hook for "only count on failure" - required for
+ * verify/recovery per SDS idp-mfa.md §4.12. Returning Limit::none()
+ * signals "no resolvable subject yet" so the middleware lets the request
+ * through and the controller resolves the (missing) state itself.
+ */
+ private function registerRateLimiters(): void
+ {
+ $rateLimitService = $this->app->make(ITwoFactorRateLimitService::class);
+
+ $respondRateLimited = fn ($request, array $headers) => Response::json(
+ [
+ 'error_code' => ITwoFactorRateLimitService::RATE_LIMIT_ERROR_CODE,
+ 'error_message' => ITwoFactorRateLimitService::RATE_LIMIT_MESSAGE,
+ ],
+ HttpResponse::HTTP_TOO_MANY_REQUESTS
+ )->withHeaders($headers);
+
+ $bySessionPendingUser = function (string $action) use ($rateLimitService, $respondRateLimited) {
+ $userId = Session::get(ITwoFactorRateLimitService::PENDING_USER_SESSION_KEY);
+
+ if (is_null($userId)) {
+ return Limit::none();
+ }
+
+ return (new Limit(
+ (int) $userId,
+ $rateLimitService->getLimit($action),
+ $rateLimitService->getWindowSeconds($action)
+ ))->response($respondRateLimited);
+ };
+
+ $limiterName = fn (string $action) => ITwoFactorRateLimitService::RATE_LIMITER_NAME_PREFIX . $action;
+
+ RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionVerify), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionVerify));
+ RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionRecovery), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionRecovery));
+ RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionResend), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionResend));
+
+ RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionOtp), function (Request $request) use ($rateLimitService, $respondRateLimited) {
+ $username = strtolower(trim($request->input('username', '')));
+
+ if ($username === '') {
+ return Limit::none();
+ }
+
+ return (new Limit(
+ $username,
+ $rateLimitService->getLimit(ITwoFactorRateLimitService::ActionOtp),
+ $rateLimitService->getWindowSeconds(ITwoFactorRateLimitService::ActionOtp)
+ ))->response($respondRateLimited);
+ });
+ }
+
public function provides(): array
{
return [
diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
index 5a32052f..d5d24f8f 100644
--- a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
+++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
@@ -29,8 +29,11 @@ public function issueChallenge(User $user, ?Client $client, bool $remember): arr
], $client);
return [
- 'otp_length' => $otp->getLength(),
- 'otp_lifetime' => $otp->getLifetime(),
+ 'otp_length' => $otp->getLength(),
+ 'otp_lifetime' => $otp->getLifetime(),
+ // Same source isAlive()/getRemainingLifetime() use server-side, so a
+ // UI countdown seeded from it can never drift from the actual expiry check.
+ 'otp_issued_at' => $otp->getCreatedAt()?->getTimestamp() ?? time(),
];
}
diff --git a/config/two_factor.php b/config/two_factor.php
index 64248f84..48491d80 100644
--- a/config/two_factor.php
+++ b/config/two_factor.php
@@ -68,5 +68,12 @@
'window_seconds' => env('TWO_FACTOR_RATE_WINDOW_SECONDS', 900),
'max_otp_requests' => env('TWO_FACTOR_MAX_OTP_REQUESTS', 5),
'otp_window_minutes' => env('TWO_FACTOR_OTP_WINDOW_MINUTES', 15),
+
+ // Passwordless OTP issuance (POST /auth/login/otp) - anonymous, pre-auth
+ // endpoint, keyed by the submitted email rather than a session user id.
+ // Kept independent from the MFA resend keys above so ops can tune this
+ // budget separately.
+ 'max_otp_email_requests' => env('TWO_FACTOR_MAX_OTP_EMAIL_REQUESTS', 5),
+ 'otp_email_window_minutes' => env('TWO_FACTOR_OTP_EMAIL_WINDOW_MINUTES', 15),
],
];
diff --git a/package.json b/package.json
index ccf4660e..729fca9c 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"clean": "find . -name \"node_modules\" -type d -prune -exec rm -rf '{}' + && yarn",
"build-dev": "./node_modules/.bin/webpack --config webpack.dev.js",
"build": "./node_modules/.bin/webpack --config webpack.prod.js",
- "serve": "webpack-dev-server --open --port=8888 --https --config webpack.dev.js",
+ "serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js",
"test": "jest --watch"
},
"devDependencies": {
@@ -81,6 +81,7 @@
"bootstrap-tagsinput": "^0.7.1",
"chosen-js": "^1.8.7",
"crypto-js": "^3.1.9-1",
+ "dompurify": "^3.4.11",
"easymde": "^2.18.0",
"font-awesome": "^4.7.0",
"formik": "^2.2.9",
@@ -95,6 +96,7 @@
"moment": "^2.29.4",
"moment-timezone": "^0.5.21",
"popper.js": "^1.14.3",
+ "prop-types": "^15.8.1",
"pure": "^2.85.0",
"pwstrength-bootstrap": "^3.0.10",
"react-otp-input": "^3.1.1",
diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js
index d0d20ad9..ebda8a6a 100644
--- a/resources/js/login/actions.js
+++ b/resources/js/login/actions.js
@@ -27,3 +27,33 @@ export const resendVerificationEmail = (email, token) => {
return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
}
+
+export const verify2FA = (otpValue, method, trustDevice, token) => {
+ const params = {
+ otp_value: otpValue,
+ method: method,
+ trust_device: trustDevice ? 1 : 0
+ };
+
+ return postRawRequest(window.VERIFY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
+}
+
+export const resend2FA = (method, token) => {
+ const params = {
+ method: method
+ };
+
+ return postRawRequest(window.RESEND_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
+}
+
+export const verifyRecoveryCode = (recoveryCode, token) => {
+ const params = {
+ recovery_code: recoveryCode
+ };
+
+ return postRawRequest(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
+}
+
+export const cancelLogin = (token) => {
+ return postRawRequest(window.CANCEL_LOGIN_ENDPOINT)({}, {'X-CSRF-TOKEN': token});
+}
diff --git a/resources/js/login/components/email_error_actions.js b/resources/js/login/components/email_error_actions.js
new file mode 100644
index 00000000..4e67ce3b
--- /dev/null
+++ b/resources/js/login/components/email_error_actions.js
@@ -0,0 +1,60 @@
+import React from "react";
+import Grid from "@material-ui/core/Grid";
+import Button from "@material-ui/core/Button";
+import styles from "../login.module.scss";
+
+const EmailErrorActions = ({
+ emitOtpAction,
+ createAccountAction,
+ onValidateEmail,
+ disableInput,
+}) => {
+ return (
+
+ {expired + ? 'Your verification code has expired. Please request a new one.' + : `Code expires in ${formatTime(secondsLeft)}.`} +
+ } + > + ); +}; + +export default OtpCodeInput; diff --git a/resources/js/login/components/otp_help_links.js b/resources/js/login/components/otp_help_links.js new file mode 100644 index 00000000..8d85dea6 --- /dev/null +++ b/resources/js/login/components/otp_help_links.js @@ -0,0 +1,43 @@ +import React, {useState, useEffect} from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; +import {RESEND_COOLDOWN_SECONDS} from "../constants"; + +const OTPHelpLinks = ({ emitOtpAction, disableInput }) => { + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCooldown((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + emitOtpAction(ev); + }; + + return ( + <> +Didn't receive it ?
++ Check your spam folder or{" "} + 0 || disableInput) ? styles.disabled_link : ''} + > + {cooldown > 0 ? `resend email (${cooldown}s)` : 'resend email.'} + +
+ > + ); +}; + +export default OTPHelpLinks; diff --git a/resources/js/login/components/otp_input_form.js b/resources/js/login/components/otp_input_form.js new file mode 100644 index 00000000..1634f815 --- /dev/null +++ b/resources/js/login/components/otp_input_form.js @@ -0,0 +1,115 @@ +import React from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import OtpCodeInput from "./otp_code_input"; +import useOtpCountdown from "./use_otp_countdown"; +import styles from "../login.module.scss"; + +const OTPInputForm = ({ + disableInput, + formAction, + onAuthenticate, + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + onReset, + loginAttempts, +}) => { + const showCaptcha = shouldShowCaptcha(); + const { secondsLeft, expired } = useOtpCountdown(otpLifetime ?? 0, codeVersion); + // The countdown only renders when this page view knows when the code was + // issued (a fresh emitOTP in this session). After a failed-submit page + // reload the issuance time is unknown - showing a fresh full countdown + // would overstate the code's validity, so none is shown. + const countdownActive = otpLifetime != null && otpLifetime > 0; + const blockExpired = countdownActive && expired; + + const handleSubmit = (ev) => { + if (blockExpired || !onAuthenticate(ev.target)) + { + ev.preventDefault(); + } + } + + return ( + + ); +}; + +export default OTPInputForm; diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js new file mode 100644 index 00000000..f89786b9 --- /dev/null +++ b/resources/js/login/components/password_input_form.js @@ -0,0 +1,189 @@ +import React from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Checkbox from "@material-ui/core/Checkbox"; +import Visibility from "@material-ui/icons/Visibility"; +import VisibilityOff from "@material-ui/icons/VisibilityOff"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import IconButton from "@material-ui/core/IconButton"; +import ExistingAccountActions from "./existing_account_actions"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const PasswordInputForm = ({ + formAction, + onAuthenticate, + disableInput, + showPassword, + passwordValue, + passwordError, + onUserPasswordChange, + handleClickShowPassword, + handleMouseDownPassword, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + handleEmitOtpAction, + forgotPasswordAction, + loginAttempts, + maxLoginFailedAttempts, + userIsActive, + helpAction, +}) => { + // Native form submission (same adapter as OTPInputForm): password managers key + // their save/update prompt off the browser's real submit event, and the backend + // login strategies respond with a redirect + flashed session state that only a + // top-level navigation consumes correctly. + const handleSubmit = (ev) => { + if (!onAuthenticate(ev.target)) { + ev.preventDefault(); + } + }; + + const ErrorMessage = () => { + const attempts = parseInt(loginAttempts, 10); + const maxAttempts = parseInt(maxLoginFailedAttempts, 10); + const attemptsLeft = maxAttempts - attempts; + + if (!passwordError) return null; + + if (attempts > 0 && attempts < maxAttempts && userIsActive) { + return ( ++ Incorrect password. You have {attemptsLeft} more attempt + {attemptsLeft !== 1 ? "s" : ""} before your account is locked. +
+ ); + } + + if (attempts > 0 && attempts === maxAttempts && userIsActive) { + return ( ++ Incorrect password. You have reached the maximum ({maxAttempts}) + login attempts. Your account will be locked after another failed + login. +
+ ); + } + + if (attempts > 0 && attempts === maxAttempts && !userIsActive) { + return ( ++ Your account has been locked due to multiple failed login + attempts. Please contact support to + unlock it. +
+ ); + } + + return ( +If you have a login, you may still choose to use a social login with the same email address to + access your account.
+ > + ); +} + +export default ThirdPartyIdentityProviders; diff --git a/resources/js/login/components/two_factor_form.js b/resources/js/login/components/two_factor_form.js new file mode 100644 index 00000000..762f34c5 --- /dev/null +++ b/resources/js/login/components/two_factor_form.js @@ -0,0 +1,125 @@ +import React, {useState, useEffect} from 'react'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; +import OtpCodeInput from './otp_code_input'; +import useOtpCountdown from './use_otp_countdown'; +import styles from '../login.module.scss'; +import {RESEND_COOLDOWN_SECONDS} from '../constants'; + +const TwoFactorForm = ({ + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + onVerify, + trustDevice, + onTrustDeviceChange, + onResend, + onUseRecovery, + onCancel, + disableInput + }) => { + + const {secondsLeft, expired} = useOtpCountdown(otpLifetime, codeVersion); + const [cooldown, setCooldown] = useState(0); + + // 1s ticker for the resend cooldown (the expiry countdown lives in the hook). + useEffect(() => { + const timer = setInterval(() => { + setCooldown(prev => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleSubmit = (ev) => { + ev.preventDefault(); + if (expired) return; + onVerify(); + }; + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + // A successful resend resets the expiry countdown through the parent's + // codeVersion bump; failures are surfaced by the parent as well. + const result = onResend(); + if (result && typeof result.catch === 'function') { + result.catch(() => {}); + } + }; + + const handleRecovery = (ev) => { + ev.preventDefault(); + onUseRecovery(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( + + ); +} + +export default TwoFactorForm; diff --git a/resources/js/login/components/use_otp_countdown.js b/resources/js/login/components/use_otp_countdown.js new file mode 100644 index 00000000..350d04ed --- /dev/null +++ b/resources/js/login/components/use_otp_countdown.js @@ -0,0 +1,25 @@ +import {useState, useEffect} from 'react'; + +/** + * Drives a 1-second expiry countdown for a single-use code. + * Resets whenever a fresh code is issued (otpLifetime change or codeVersion bump). + */ +const useOtpCountdown = (otpLifetime, codeVersion) => { + const [secondsLeft, setSecondsLeft] = useState(otpLifetime || 0); + + useEffect(() => { + setSecondsLeft(otpLifetime || 0); + }, [otpLifetime, codeVersion]); + + useEffect(() => { + const timer = setInterval( + () => setSecondsLeft(prev => (prev > 0 ? prev - 1 : 0)), + 1000 + ); + return () => clearInterval(timer); + }, []); + + return {secondsLeft, expired: secondsLeft <= 0}; +}; + +export default useOtpCountdown; diff --git a/resources/js/login/constants.js b/resources/js/login/constants.js new file mode 100644 index 00000000..183b3784 --- /dev/null +++ b/resources/js/login/constants.js @@ -0,0 +1,40 @@ +export const HTTP_CODES = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + PRECONDITION_FAILED: 412, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +}; + +export const MFA_METHODS = { + EMAIL_OTP: "email_otp", + TOTP: "totp", +}; + +export const FLOW = { + PASSWORD: "password", + MFA: "2fa", + RECOVERY: "recovery", + OTP: "otp", +}; + +export const OTP_LENGTH_DEFAULT = 6; +export const OTP_TTL_DEFAULT = 300; +export const MFA_METHOD_DEFAULT = MFA_METHODS.EMAIL_OTP; +export const CAPTCHA_FIELD = 'cf-turnstile-response'; + +// Cooldown applied to any "resend code" action (MFA and passwordless OTP) to +// avoid hammering the resend endpoint (the backend also rate-limits server-side). +export const RESEND_COOLDOWN_SECONDS = 30; + +// Success confirmation shown after a code is (re)sent - shared by MFA's +// onResend2FA() and passwordless's emitOtpAction() so the two flows can't +// silently diverge in wording. +export const CODE_RESENT_MESSAGE = "A new verification code has been sent to your email."; + +export const MFA_ERROR_CODE = { + MFA_SESSION_EXPIRED: "mfa_session_expired", +}; diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ee061b9a..f04bd4e2 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -1,937 +1,1008 @@ -import React from 'react'; +import React from "react"; import { Turnstile } from "@marsidev/react-turnstile"; -import ReactDOM from 'react-dom'; -import Avatar from '@material-ui/core/Avatar'; -import Button from '@material-ui/core/Button'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import TextField from '@material-ui/core/TextField'; -import Link from '@material-ui/core/Link'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import Container from '@material-ui/core/Container'; -import Chip from '@material-ui/core/Chip'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import {verifyAccount, emitOTP, resendVerificationEmail} from './actions'; -import {MuiThemeProvider, createTheme} from '@material-ui/core/styles'; -import DividerWithText from '../components/divider_with_text'; -import Visibility from '@material-ui/icons/Visibility'; -import VisibilityOff from '@material-ui/icons/VisibilityOff'; -import InputAdornment from '@material-ui/core/InputAdornment'; -import IconButton from '@material-ui/core/IconButton'; -import { emailValidator } from '../validator'; -import Grid from '@material-ui/core/Grid'; +import ReactDOM from "react-dom"; +import Avatar from "@material-ui/core/Avatar"; +import Button from "@material-ui/core/Button"; +import CssBaseline from "@material-ui/core/CssBaseline"; +import Typography from "@material-ui/core/Typography"; +import Container from "@material-ui/core/Container"; +import Chip from "@material-ui/core/Chip"; +import { MuiThemeProvider, createTheme } from "@material-ui/core/styles"; +import { + verifyAccount, + emitOTP, + resendVerificationEmail, + verify2FA, + resend2FA, + verifyRecoveryCode, + cancelLogin, +} from "./actions"; +import { emailValidator } from "../validator"; import CustomSnackbar from "../components/custom_snackbar"; -import Banner from '../components/banner/banner'; -import OtpInput from 'react-otp-input'; -import {handleErrorResponse, handleThirdPartyProvidersVerbiage} from '../utils'; - -import styles from './login.module.scss' +import Banner from "../components/banner/banner"; +import { handleErrorResponse } from "../utils"; + +import EmailInputForm from "./components/email_input_form"; +import PasswordInputForm from "./components/password_input_form"; +import OTPInputForm from "./components/otp_input_form"; +import HelpLinks from "./components/help_links"; +import OTPHelpLinks from "./components/otp_help_links"; +import EmailErrorActions from "./components/email_error_actions"; +import ThirdPartyIdentityProviders from "./components/third_party_identity_providers"; +import TwoFactorForm from "./components/two_factor_form"; +import RecoveryCodeForm from "./components/recovery_code_form"; + +import styles from "./login.module.scss"; import "./third_party_identity_providers.scss"; +import { + FLOW, + HTTP_CODES, + MFA_ERROR_CODE, + OTP_LENGTH_DEFAULT, + OTP_TTL_DEFAULT, + MFA_METHOD_DEFAULT, + CODE_RESENT_MESSAGE, +} from "./constants"; -const EmailInputForm = ({ value, onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { - - return ( - <> -Didn't receive it ?
-Check your spam folder or resend email. -
- > - ); -} - -const EmailErrorActions = ({ emitOtpAction, createAccountAction, onValidateEmail, disableInput }) => { - return ( -If you have a login, you may still choose to use a social login with the same email address to - access your account.
- > + typeof this.props.maxLoginAttempts2ShowCaptcha !== "undefined" && + typeof this.props.loginAttempts !== "undefined" && + this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha ); -} + } -const otp_flow = 'otp'; -const password_flow = 'password'; + handleAuthenticateValidation() { -class LoginPage extends React.Component { - - constructor(props) { - super(props); - this.state = { - user_name: props.userName, - user_password: '', - otpCode: '', - user_pic: props.hasOwnProperty('user_pic') ? props.user_pic : null, - user_fullname: props.hasOwnProperty('user_fullname') ? props.user_fullname : null, - user_verified: props.hasOwnProperty('user_verified') ? props.user_verified : false, - user_active: props.hasOwnProperty('user_active') ? props.user_active : null, - email_verified: props.hasOwnProperty('email_verified') ? props.email_verified : null, - errors: { - email: '', - otp: props.authError != '' ? props.authError : '', - password: props.authError != '' ? props.authError : '', - }, - notification: { - message: null, - severity: 'info' - }, - captcha_value: '', - showPassword: false, + switch (this.state.authFlow) { + case FLOW.OTP: + if (this.state.otpCode == "") { + this.setState({ + ...this.state, disableInput: false, - authFlow: props.flow, - allowNativeAuth: props.allowNativeAuth, - showInfoBanner: props.showInfoBanner, - infoBannerContent: props.infoBannerContent, - } - - if (props.authError != '' && !this.state.user_fullname) { - this.state.user_fullname = props.userName; + errors: { ...this.state.errors, otp: "Single-use code is empty" }, + }); + return false; } - - if (this.state.errors.password && this.state.errors.password.includes("is not yet verified")) { - this.state.errors.password = this.state.errors.password + `Or have another verification email sent to you.`; + break; + default: + if (this.state.user_password == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "Password is empty" }, + }); + return false; } - this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); - this.onValidateEmail = this.onValidateEmail.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.onAuthenticate = this.onAuthenticate.bind(this); - this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); - this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); - this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); - this.onUserPasswordChange = this.onUserPasswordChange.bind(this); - this.onOTPCodeChange = this.onOTPCodeChange.bind(this); - this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); - this.handleClickShowPassword = this.handleClickShowPassword.bind(this); - this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); - this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); - this.resendVerificationEmail = this.resendVerificationEmail.bind(this); - this.handleSnackbarClose = this.handleSnackbarClose.bind(this); - this.showAlert = this.showAlert.bind(this); - } - - showAlert(message, severity) { - this.setState({ + if (this.state.captcha_value == "" && this.shouldShowCaptcha()) { + this.setState({ ...this.state, - notification: { - message: message, - severity: severity - } - }); - } - - emitOtpAction() { - let user_fullname = this.state.user_fullname ? this.state.user_fullname : this.state.user_name; - - emitOTP(this.state.user_name, this.props.token).then((payload) => { - let {response} = payload; - this.setState({ - ...this.state, - authFlow: otp_flow, - errors: { - email: '', - otp: '', - password: '' - }, - user_verified: true, - user_fullname: user_fullname, - }); - }, (error) => { - let {response, status, message} = error; - if(status == 412){ - const {message, errors} = response.body; - this.showAlert(errors[0], 'error'); - return; - } - this.showAlert('Oops... Something went wrong!', 'error'); - }); - return false; - } - - handleEmitOtpAction(ev) { - ev.preventDefault(); - return this.emitOtpAction(); + disableInput: false, + errors: { ...this.state.errors, password: "you must check CAPTCHA" }, + }); + return false; + } } - shouldShowCaptcha() { - return ( - this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && - this.props.hasOwnProperty('loginAttempts') && - this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha - ) - } + return true; + } - onAuthenticate(ev) { - if (this.state.authFlow === otp_flow) { - if (this.state.otpCode == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, otp: 'Single-use code is empty'}}); - ev.preventDefault(); - return false; - } - } else if (this.state.user_password == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'Password is empty'}}); - ev.preventDefault(); - return false; - } + // Password and OTP flows submit as a native form POST: the backend login + // strategies answer with a redirect plus flashed/persisted session state + // (auth errors, login_attempts, the mfa_required '2fa' flow), which only a + // top-level navigation renders correctly. The 2FA screen is rehydrated from + // session by the blade on the post-redirect GET. + onAuthenticate() { - if (this.state.captcha_value == '' && this.shouldShowCaptcha()) { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'you must check CAPTCHA'}}); - ev.preventDefault(); - return false; - } - this.setState({ ...this.state, disableInput: true }); - return true; + if (!this.handleAuthenticateValidation()) { + return false; } - onChangeCaptchaProvider(value) { - this.setState({ ...this.state, captcha_value: value }); + this.setState({ ...this.state, disableInput: true }); + + return true; + } + + onChangeCaptchaProvider(value) { + this.setState({ ...this.state, captcha_value: value }); + } + + onExpireCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } + + onErrorCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } + + onHandleUserNameChange(ev) { + let { value, id } = ev.target; + this.setState({ ...this.state, user_name: value }); + } + + onUserPasswordChange(ev) { + let { errors } = this.state; + let { value, id } = ev.target; + if (value == "") + // clean error + errors[id] = ""; + this.setState({ + ...this.state, + user_password: value, + errors: { ...errors }, + }); + } + + onOTPCodeChange(value) { + this.setState({ ...this.state, otpCode: value }); + } + + onTwoFactorCodeChange(value) { + this.setState({ + ...this.state, + twoFactorCode: value, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onRecoveryCodeChange(ev) { + let { value } = ev.target; + this.setState({ + ...this.state, + recoveryCode: value, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onTrustDeviceChange(ev) { + this.setState({ ...this.state, trustDevice: ev.target.checked }); + } + + /** + * Resets client-side MFA state and returns the user to the password screen. + */ + resetToPasswordFlow() { + this.setState({ + ...this.state, + authFlow: FLOW.PASSWORD, + disableInput: false, + twoFactorCode: "", + user_password: "", + recoveryCode: "", + trustDevice: false, + errors: { + ...this.state.errors, + twofactor: "", + recovery: "", + email: "", + otp: "", + password: "", + }, + }); + this.cancelPendingLogin(); + } + + /** + * Shared error handling for the 2FA verify / recovery AJAX calls. + * @param {*} error superagent error + * @param {string} field 'twofactor' | 'recovery' + */ + handleMfaError(error, field) { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - onExpireCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onErrorCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + if (status === HTTP_CODES.UNAUTHORIZED) { + const msg = + field === "recovery" + ? "Invalid recovery code. Please try again." + : "Invalid or expired verification code. Please try again."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onHandleUserNameChange(ev) { - let { value, id } = ev.target; - this.setState({ ...this.state, user_name: value }); + if (status === HTTP_CODES.PRECONDITION_FAILED) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: "Please enter a valid code." }, + }); + return; } - onUserPasswordChange(ev) { - let {errors} = this.state; - let {value, id} = ev.target; - if (value == "") // clean error - errors[id] = ''; - this.setState({...this.state, user_password: value, errors: {...errors}}); + /** + * No HTTP status: the XHR likely followed a (possibly cross-origin) success redirect + * it could not read. The IDP session may already be established, so reload and let + * the server route us to the right place; a genuine network error just re-shows login. + */ + if (typeof status === "undefined" || status === 0) { + window.location.reload(); + return; } - onOTPCodeChange(value) { - this.setState({...this.state, otpCode: value}); + this.setState({ ...this.state, disableInput: false }); + this.showAlert("Oops... Something went wrong!", "error"); + } + + onVerify2FA() { + if (this.state.disableInput) return; + const { twoFactorCode, trustDevice, mfaMethod } = this.state; + if (twoFactorCode === "") { + this.setState({ + ...this.state, + errors: { + ...this.state.errors, + twofactor: "Verification code is empty", + }, + }); + return; } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, twofactor: "" }, + }); + + verify2FA(twoFactorCode, mfaMethod, trustDevice, this.props.token).then( + (payload) => { + // Success: the backend returns the same-origin post-login destination as JSON + // data (never a redirect for this XHR to follow) - a real top-level navigation + // to it lets the browser complete any further hop natively, cross-origin + // included, which this XHR never could. + const { response } = payload; + window.location.href = + (response && response.redirect_url) || window.location.href; + }, + (error) => { + this.handleMfaError(error, "twofactor"); + }, + ); + } - onValidateEmail(ev) { - - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + onResend2FA() { + const promise = resend2FA(this.state.mfaMethod, this.props.token); - if (user_name == '') { - return false; + promise.then( + (payload) => { + const { response } = payload; + this.setState({ + ...this.state, + otpLength: + response && response.otp_length + ? response.otp_length + : this.state.otpLength, + otpLifetime: + response && response.otp_lifetime + ? response.otp_lifetime + : this.state.otpLifetime, + codeVersion: this.state.codeVersion + 1, + errors: { ...this.state.errors, twofactor: "" }, + }); + this.showAlert( + CODE_RESENT_MESSAGE, + "success", + ); + }, + (error) => { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - if (!emailValidator(user_name)) { - return false; + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.showAlert(msg, "warning"); + return; } - this.setState({ ...this.state, disableInput: true }); - - verifyAccount(user_name, this.props.token).then((payload) => { - let { response } = payload; - - let error = ''; - if (response.is_active === false) { - error = `Your user account is currently locked. Please contact support for further assistance.`; - } else if (response.is_active === true && response.is_verified === false) { - error = 'Your email has not been verified. Please check your inbox or resend the verification email.'; - } - - this.setState({ - ...this.state, - user_pic: response.pic, - user_fullname: response.full_name, - user_verified: true, - user_active: response.is_active, - email_verified: response.is_verified, - authFlow: response.has_password_set ? password_flow : otp_flow, - errors: { - email: error, - otp: '', - password: '' - }, - disableInput: false - }, function () { - //Once the state is updated, it's now possible to trigger emitOtpAction. - //No need to wait for the component to update. - if (!response.has_password_set && response.is_verified !== false) { - this.emitOtpAction(); - } - }); - }, (error) => { - - let { response, status, message } = error; - - let newErrors = {}; + this.showAlert( + "Oops... Something went wrong while resending the code.", + "error", + ); + }, + ); - newErrors['password'] = ''; - newErrors['email'] = " "; + // Returned so the form can reset its expiry countdown once the resend resolves. + return promise; + } + + onVerifyRecovery() { + if (this.state.disableInput) return; + const { recoveryCode } = this.state; + if (recoveryCode === "") { + this.setState({ + ...this.state, + errors: { ...this.state.errors, recovery: "Recovery code is empty" }, + }); + return; + } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, recovery: "" }, + }); + + verifyRecoveryCode(recoveryCode, this.props.token).then( + (payload) => { + // See onVerify2FA() for rationale. + const { response } = payload; + window.location.href = + (response && response.redirect_url) || window.location.href; + }, + (error) => { + this.handleMfaError(error, "recovery"); + }, + ); + } + + onUseRecovery() { + this.setState({ + ...this.state, + authFlow: FLOW.RECOVERY, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onBackToOtp() { + this.setState({ + ...this.state, + authFlow: FLOW.MFA, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onValidateEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (user_name == "") { + return false; + } + if (!emailValidator(user_name)) { + return false; + } + this.setState({ ...this.state, disableInput: true }); + + verifyAccount(user_name, this.props.token).then( + (payload) => { + let { response } = payload; + + let error = ""; + if (response.is_active === false) { + error = `Your user account is currently locked. Please contact support for further assistance.`; + } else if ( + response.is_active === true && + response.is_verified === false + ) { + error = + "Your email has not been verified. Please check your inbox or resend the verification email."; + } - if (status == 429) { - newErrors['email'] = "Too many requests. Try it later."; + this.setState( + { + ...this.state, + user_pic: response.pic, + user_fullname: response.full_name, + user_verified: true, + user_active: response.is_active, + email_verified: response.is_verified, + authFlow: response.has_password_set ? FLOW.PASSWORD : FLOW.OTP, + errors: { + email: error, + otp: "", + password: "", + }, + disableInput: false, + }, + function () { + //Once the state is updated, it's now possible to trigger emitOtpAction. + //No need to wait for the component to update. + if (!response.has_password_set && response.is_verified !== false) { + this.emitOtpAction(); } + }, + ); + }, + (error) => { + let { response, status, message } = error; - this.setState({ - ...this.state, - user_pic: null, - user_fullname: null, - user_verified: false, - errors: newErrors, - disableInput: false - }); - }); - return true; - } + let newErrors = {}; - resendVerificationEmail(ev) { - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + newErrors["password"] = ""; + newErrors["email"] = " "; - if (!user_name) { - this.showAlert( - 'Something went wrong while trying to resend the verification email. Please try again later.', - 'error'); - return; + if (status == HTTP_CODES.TOO_MANY_REQUESTS) { + newErrors["email"] = "Too many requests. Try it later."; } - resendVerificationEmail(user_name, this.props.token).then((payload) => { - this.showAlert( - 'We\'ve sent you a verification email. Please check your inbox and click the link to verify your account.', - 'success'); - }, (error) => { - handleErrorResponse(error, (title, messageLines, type) => { - const message = (messageLines ?? []).join(', ') - this.showAlert(`${title}: ${message}`, type); - }); - }); - } - - handleDelete(ev) { - ev.preventDefault(); this.setState({ - ...this.state, - user_name: null, - user_pic: null, - user_fullname: null, - user_verified: false, - user_active: null, - email_verified: null, - authFlow: "password", - errors: { - email: '', - otp: '', - password: '' - } + ...this.state, + user_pic: null, + user_fullname: null, + user_verified: false, + errors: newErrors, + disableInput: false, }); - return false; - } - - handleClickShowPassword(ev) { - ev.preventDefault(); - this.setState({ ...this.state, showPassword: !this.state.showPassword }) + }, + ); + return true; + } + + resendVerificationEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (!user_name) { + this.showAlert( + "Something went wrong while trying to resend the verification email. Please try again later.", + "error", + ); + return; } - handleMouseDownPassword(ev) { - ev.preventDefault(); + resendVerificationEmail(user_name, this.props.token).then( + (payload) => { + this.showAlert( + "We've sent you a verification email. Please check your inbox and click the link to verify your account.", + "success", + ); + }, + (error) => { + handleErrorResponse(error, (title, messageLines, type) => { + const message = (messageLines ?? []).join(", "); + this.showAlert(`${title}: ${message}`, type); + }); + }, + ); + } + + handleDelete(ev) { + ev.preventDefault(); + if (this.isMfaFlow() || this.isPasswordlessFlow()) { + // A pending 2FA/recovery challenge or passwordless OTP was issued + // server-side (session state + an unredeemed OTP); invalidate it the + // same way "Cancel" does instead of leaving it live/restorable until + // its TTL. + this.cancelPendingLogin(); } + this.setState({ + ...this.state, + user_name: null, + user_pic: null, + user_fullname: null, + user_verified: false, + user_active: null, + email_verified: null, + authFlow: "password", + errors: { + email: "", + otp: "", + password: "", + }, + }); + return false; + } + + handleClickShowPassword(ev) { + ev.preventDefault(); + this.setState({ ...this.state, showPassword: !this.state.showPassword }); + } + + handleMouseDownPassword(ev) { + ev.preventDefault(); + } + + existingUserCanContinue() { + const { user_active, email_verified } = this.state; + return user_active !== false && email_verified !== false; + } + + isMfaFlow() { + return ( + this.state.authFlow === FLOW.MFA || this.state.authFlow === FLOW.RECOVERY + ); + } - existingUserCanContinue() { - const { user_active, email_verified } = this.state; - return user_active !== false && email_verified !== false; - } + isPasswordlessFlow() { + return this.state.authFlow === FLOW.OTP; + } - getSignUpSignInTitle() { - const { errors, user_active } = this.state; + getSignUpSignInTitle() { + const { errors, user_active } = this.state; - if (errors.email && this.existingUserCanContinue()) { - return 'Create an account for:'; - } - return 'Sign in'; + if (errors.email && this.existingUserCanContinue()) { + return "Create an account for:"; } - - handleSnackbarClose() { - this.setState({ - ...this.state, - notification: { - message: null, - severity: 'info' - } - }); - }; - - componentDidUpdate(prevProps, prevState) { - if (this.state.user_verified && this.existingUserCanContinue() && prevState.authFlow !== this.state.authFlow) { - this.setState({ - ...this.state, - captcha_value: '', - }); - } + return "Sign in"; + } + + handleSnackbarClose() { + this.setState({ + ...this.state, + notification: { + message: null, + severity: "info", + }, + }); + } + + componentDidUpdate(prevProps, prevState) { + if ( + this.state.user_verified && + this.existingUserCanContinue() && + prevState.authFlow !== this.state.authFlow + ) { + this.setState({ + ...this.state, + captcha_value: "", + }); } + } + + render() { + const showTwoFactorForm = this.state.authFlow === FLOW.MFA; + const showRecoveryForm = this.state.authFlow === FLOW.RECOVERY; + const isPasswordFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.PASSWORD; + const isOtpFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.OTP; + const showDefaultFlow = !showTwoFactorForm && !showRecoveryForm && !isPasswordFlow && !isOtpFlow; + const createAccountAction = this.props.createAccountAction + + (this.state.user_name ? `?email=${encodeURIComponent(this.state.user_name)}` : ""); - render() { - return ( -