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
74 changes: 73 additions & 1 deletion app/Http/Controllers/Api/UserApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
use App\Http\Controllers\Traits\RequestProcessor;
use App\Http\Controllers\UserValidationRulesFactory;
use App\ModelSerializers\SerializerRegistry;
use App\Services\Auth\IRecoveryCodeService;
use Auth\Repositories\IUserRepository;
use Auth\User;
use Exception;
use Illuminate\Http\Request as LaravelRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Validator;
use models\exceptions\EntityNotFoundException;
use models\exceptions\ValidationException;
use OAuth2\Services\ITokenService;
Expand All @@ -43,23 +45,31 @@ final class UserApiController extends APICRUDController
*/
private $token_service;

/**
* @var IRecoveryCodeService
*/
private $recovery_code_service;

/**
* UserApiController constructor.
* @param IUserRepository $user_repository
* @param ILogService $log_service
* @param IUserService $user_service
* @param ITokenService $token_service
* @param IRecoveryCodeService $recovery_code_service
*/
public function __construct
(
IUserRepository $user_repository,
ILogService $log_service,
IUserService $user_service,
ITokenService $token_service
ITokenService $token_service,
IRecoveryCodeService $recovery_code_service
)
{
parent::__construct($user_repository, $user_service, $log_service);
$this->token_service = $token_service;
$this->recovery_code_service = $recovery_code_service;
}

/**
Expand Down Expand Up @@ -247,6 +257,68 @@ public function updateMe()
return $this->update(Auth::user()->getId());
}

/**
* Enables a 2FA method for the current user and generates the first batch of
* recovery codes for them. Plaintext codes are returned once in the response
* and never persisted.
*
* @return \Illuminate\Http\JsonResponse|mixed
*/
public function enableTwoFactor()
{
if (!Auth::check())
return $this->error403();

return $this->processRequest(function () {
$data = Request::all();
$validator = Validator::make($data, [
'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods),
]);

if (!$validator->passes()) {
return $this->error412($validator->getMessageBag()->getMessages());
}

$user = Auth::user();
$method = $data['method'];

if ($user->isTwoFactorEnabled()) {
return $this->error412(['method' => ['Two-factor authentication is already enabled. Use the regenerate recovery codes endpoint to rotate your codes.']]);
}

$codes = $this->recovery_code_service->enableTwoFactorAndGenerateCodes($user, $method);

return $this->ok(['recovery_codes' => $codes]);
});
}

/**
* Invalidates the current user's recovery codes and generates a fresh batch.
* Plaintext codes are returned once in the response and never persisted.
*
* @return \Illuminate\Http\JsonResponse|mixed
*/
public function regenerateRecoveryCodes()
{
if (!Auth::check())
return $this->error403();

return $this->processRequest(function () {
$data = Request::all();
$validator = Validator::make($data, [
'current_password' => 'required|string',
]);

if (!$validator->passes()) {
return $this->error412($validator->getMessageBag()->getMessages());
}

$codes = $this->recovery_code_service->regenerateRecoveryCodes(Auth::user(), $data['current_password']);

return $this->ok(['recovery_codes' => $codes]);
});
}

public function revokeAllMyTokens()
{
if (!Auth::check())
Expand Down
21 changes: 20 additions & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use App\libs\OAuth2\Strategies\LoginHintProcessStrategy;
use App\ModelSerializers\SerializerRegistry;
use App\Services\Auth\IDeviceTrustService;
use App\Services\Auth\IRecoveryCodeService;
use App\Services\Auth\ITwoFactorAuditService;
use App\Services\Auth\ITwoFactorGateService;
use App\Services\Auth\ITwoFactorRateLimitService;
Expand Down Expand Up @@ -160,6 +161,11 @@ final class UserController extends OpenIdController
*/
private $two_factor_rate_limit_service;

/**
* @var IRecoveryCodeService
*/
private $recovery_code_service;

/**
* @param IMementoOpenIdSerializerService $openid_memento_service
* @param IMementoOAuth2SerializerService $oauth2_memento_service
Expand Down Expand Up @@ -200,6 +206,7 @@ public function __construct
ITwoFactorAuditService $two_factor_audit_service,
ITwoFactorGateService $mfa_gate_service,
ITwoFactorRateLimitService $two_factor_rate_limit_service,
IRecoveryCodeService $recovery_code_service,
)
{
$this->openid_memento_service = $openid_memento_service;
Expand All @@ -221,6 +228,7 @@ public function __construct
$this->two_factor_audit_service = $two_factor_audit_service;
$this->mfa_gate_service = $mfa_gate_service;
$this->two_factor_rate_limit_service = $two_factor_rate_limit_service;
$this->recovery_code_service = $recovery_code_service;

$this->middleware(function ($request, $next) use($login_hint_process_strategy){

Expand Down Expand Up @@ -919,7 +927,14 @@ public function verify2FARecovery()
// 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()]);
return $this->ok([
'redirect_url' => $redirect->getTargetUrl(),
// CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5: the login page
// must be able to warn the user when they've just burned into their
// last few recovery codes, since it may be their only way back in.
'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user),
'recovery_codes_low_threshold' => (int) config('auth.recovery_codes.low_threshold', 3),
]);
} catch (ValidationException $ex) {
Log::warning($ex);
return $this->error412($ex->getMessages());
Expand Down Expand Up @@ -1184,6 +1199,10 @@ public function getProfile()
'actions' => $actions,
'countries' => CountryList::getCountries(),
'languages' => $lang2Code,
'two_factor_enabled' => $user->shouldRequire2FA(),
'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user),
'recovery_codes_total' => (int)config('auth.recovery_codes.count', 10),
'recovery_codes_low_threshold' => (int)config('auth.recovery_codes.low_threshold', 3),
]);
}

Expand Down
64 changes: 64 additions & 0 deletions app/Services/Auth/IRecoveryCodeService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Auth;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Auth\User;
use models\exceptions\ValidationException;

/**
* Interface IRecoveryCodeService
* @package App\Services\Auth
*/
interface IRecoveryCodeService
{
/**
* Invalidates every existing recovery code for the user and generates a fresh
* batch. Plaintext codes are returned once and are never persisted/exposed again.
*
* @param User $user
* @param string $currentPassword
* @return string[] plaintext codes formatted as XXXX-XXXX
* @throws ValidationException if $currentPassword does not match the user's password
*/
public function regenerateRecoveryCodes(User $user, string $currentPassword): array;

/**
* Invalidates every existing recovery code for the user and generates a fresh
* batch, without requiring password confirmation. Intended for first-time
* generation right after 2FA enrollment, where the user's identity is already
* established by the current session.
*
* @param User $user
* @return string[] plaintext codes formatted as XXXX-XXXX
*/
public function generateRecoveryCodes(User $user): array;

/**
* Enrolls the user into the given 2FA method and generates the first batch
* of recovery codes for them, without requiring password confirmation.
* Intended for enrollment via an already-authenticated session.
*
* @param User $user
* @param string $method
* @return string[] plaintext codes formatted as XXXX-XXXX
* @throws ValidationException if $method is not a valid/enabled 2FA method
*/
public function enableTwoFactorAndGenerateCodes(User $user, string $method): array;

/**
* @param User $user
* @return int count of unused recovery codes
*/
public function countUnusedRecoveryCodes(User $user): int;
}
Loading
Loading