Skip to content
Open
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
102 changes: 101 additions & 1 deletion app/Http/Controllers/Api/UserApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@
use App\Http\Controllers\APICRUDController;
use App\Http\Controllers\Traits\RequestProcessor;
use App\Http\Controllers\UserValidationRulesFactory;
use App\libs\Auth\Models\TwoFactorAuditLog;
use App\ModelSerializers\SerializerRegistry;
use App\Services\Auth\IRecoveryCodeService;
use App\Services\Auth\ITwoFactorAuditService;
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;
use OpenId\Services\IUserService;
use Utils\Db\ITransactionService;
use Utils\IPHelper;
use Utils\Services\ILogService;

/**
Expand All @@ -43,23 +49,47 @@ final class UserApiController extends APICRUDController
*/
private $token_service;

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

/**
* @var ITransactionService
*/
private $tx_service;

/**
* @var ITwoFactorAuditService
*/
private $two_factor_audit_service;

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

/**
Expand Down Expand Up @@ -247,6 +277,76 @@ 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'];

$codes = $this->tx_service->transaction(function () use ($user, $method) {
$user->enable2FA($method);
$this->repository->add($user, false);

return $this->recovery_code_service->generateRecoveryCodes($user);
});

$this->two_factor_audit_service->log(
$user,
TwoFactorAuditLog::EventEnrollmentChanged,
$method,
IPHelper::getUserIp()
);

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
12 changes: 12 additions & 0 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use App\Http\Utils\CountryList;
use App\libs\Auth\Models\TwoFactorAuditLog;
use App\Services\Auth\IDeviceTrustService;
use App\Services\Auth\IRecoveryCodeService;
use App\Services\Auth\ITwoFactorAuditService;
use App\Services\Auth\ITwoFactorGateService;
use Auth\User;
Expand Down Expand Up @@ -157,6 +158,11 @@ final class UserController extends OpenIdController
*/
private $mfa_gate_service;

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

/**
* @param IMementoOpenIdSerializerService $openid_memento_service
* @param IMementoOAuth2SerializerService $oauth2_memento_service
Expand Down Expand Up @@ -196,6 +202,7 @@ public function __construct
IDeviceTrustService $device_trust_service,
ITwoFactorAuditService $two_factor_audit_service,
ITwoFactorGateService $mfa_gate_service,
IRecoveryCodeService $recovery_code_service,
)
{
$this->openid_memento_service = $openid_memento_service;
Expand All @@ -216,6 +223,7 @@ public function __construct
$this->device_trust_service = $device_trust_service;
$this->two_factor_audit_service = $two_factor_audit_service;
$this->mfa_gate_service = $mfa_gate_service;
$this->recovery_code_service = $recovery_code_service;

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

Expand Down Expand Up @@ -1007,6 +1015,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
52 changes: 52 additions & 0 deletions app/Services/Auth/IRecoveryCodeService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?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;

/**
* @param User $user
* @return int count of unused recovery codes
*/
public function countUnusedRecoveryCodes(User $user): int;
}
94 changes: 94 additions & 0 deletions app/Services/Auth/RecoveryCodeService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?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 App\libs\Auth\Models\TwoFactorAuditLog;
use App\libs\Auth\Models\UserRecoveryCode;
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\User;
use Illuminate\Support\Facades\Hash;
use Laminas\Math\Rand;
use models\exceptions\ValidationException;
use Utils\Db\ITransactionService;
use Utils\IPHelper;

/**
* Class RecoveryCodeService
* @package App\Services\Auth
*/
final class RecoveryCodeService implements IRecoveryCodeService
{
private const CODE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

public function __construct(
private readonly IUserRecoveryCodeRepository $repository,
private readonly ITransactionService $tx_service,
private readonly ITwoFactorAuditService $audit_service,
) {
}

/**
* @inheritDoc
*/
public function regenerateRecoveryCodes(User $user, string $currentPassword): array
{
if (!$user->checkPassword(trim($currentPassword))) {
throw new ValidationException('current_password is not correct.');
}

return $this->generateRecoveryCodes($user);
}

/**
* @inheritDoc
*/
public function generateRecoveryCodes(User $user): array
{
$count = (int)config('auth.recovery_codes.count', 10);
$length = (int)config('auth.recovery_codes.length', 8);

$plaintext_codes = [];

$this->tx_service->transaction(function () use ($user, $count, $length, &$plaintext_codes) {
$this->repository->deleteAllForUser($user);

for ($i = 0; $i < $count; $i++) {
$plain = Rand::getString($length, self::CODE_CHARSET, true);
$plaintext_codes[] = $plain;

$code = new UserRecoveryCode();
$code->setUser($user);
$code->setCodeHash(Hash::make($plain));
$this->repository->add($code, false);
}
});

$this->audit_service->log(
$user,
TwoFactorAuditLog::EventRecoveryCodesGenerated,
TwoFactorAuditLog::MethodRecovery,
IPHelper::getUserIp()
);

return array_map(static fn(string $code) => implode('-', str_split($code, 4)), $plaintext_codes);
}

/**
* @inheritDoc
*/
public function countUnusedRecoveryCodes(User $user): int
{
return count($this->repository->getUnusedByUser($user));
}
}
2 changes: 2 additions & 0 deletions app/Services/Auth/TwoFactorServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function register(): void
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
$this->app->singleton(ITwoFactorGateService::class, MFAGateService::class);
$this->app->singleton(IRecoveryCodeService::class, RecoveryCodeService::class);
}

public function provides(): array
Expand All @@ -42,6 +43,7 @@ public function provides(): array
IDeviceTrustService::class,
ITwoFactorAuditService::class,
ITwoFactorGateService::class,
IRecoveryCodeService::class,
];
}
}
2 changes: 2 additions & 0 deletions app/libs/Auth/Models/TwoFactorAuditLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class TwoFactorAuditLog extends BaseEntity
public const EventDeviceRevoked = 'device_revoked';
public const EventRecoveryUsed = 'recovery_used';
public const EventSettingsChanged = 'settings_changed';
public const EventRecoveryCodesGenerated = 'recovery_codes_generated';

public const MethodEmailOtp = 'email_otp';
public const MethodSmsOtp = 'sms_otp';
Expand All @@ -46,6 +47,7 @@ class TwoFactorAuditLog extends BaseEntity
self::EventDeviceRevoked,
self::EventRecoveryUsed,
self::EventSettingsChanged,
self::EventRecoveryCodesGenerated,
];

private const ALLOWED_METHODS = [
Expand Down
6 changes: 6 additions & 0 deletions config/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
'password_shape_warning' => env('AUTH_PASSWORD_SHAPE_WARNING', 'Password must include at least one uppercase letter, one lowercase letter, one number, and one special character (#?!@$%^&*+-).'),
'verification_email_lifetime' => env("AUTH_VERIFICATION_EMAIL_LIFETIME", 600),
'allows_native_auth' => env('AUTH_ALLOWS_NATIVE_AUTH', 1),

'recovery_codes' => [
'count' => env('MFA_RECOVERY_CODES_COUNT', 10),
'length' => env('MFA_RECOVERY_CODE_LENGTH', 8),
'low_threshold' => env('MFA_RECOVERY_CODES_LOW_THRESHOLD', 3),
],
'allows_native_on_config' => env('AUTH_ALLOWS_NATIVE_AUTH_CONFIG', 1),
'allows_opt_auth' => env('AUTH_ALLOWS_OTP_AUTH', 1),
];
Loading
Loading