Skip to content

Commit 15aee43

Browse files
committed
feat: recovery code management
Signed-off-by: romanetar <roman_ag@hotmail.com>
1 parent 1b6f66d commit 15aee43

29 files changed

Lines changed: 1290 additions & 14 deletions

app/Http/Controllers/Api/UserApiController.php

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,24 @@
1515
use App\Http\Controllers\APICRUDController;
1616
use App\Http\Controllers\Traits\RequestProcessor;
1717
use App\Http\Controllers\UserValidationRulesFactory;
18+
use App\libs\Auth\Models\TwoFactorAuditLog;
1819
use App\ModelSerializers\SerializerRegistry;
20+
use App\Services\Auth\IRecoveryCodeService;
21+
use App\Services\Auth\ITwoFactorAuditService;
1922
use Auth\Repositories\IUserRepository;
2023
use Auth\User;
2124
use Exception;
2225
use Illuminate\Http\Request as LaravelRequest;
2326
use Illuminate\Support\Facades\Auth;
2427
use Illuminate\Support\Facades\Log;
2528
use Illuminate\Support\Facades\Request;
29+
use Illuminate\Support\Facades\Validator;
2630
use models\exceptions\EntityNotFoundException;
2731
use models\exceptions\ValidationException;
2832
use OAuth2\Services\ITokenService;
2933
use OpenId\Services\IUserService;
34+
use Utils\Db\ITransactionService;
35+
use Utils\IPHelper;
3036
use Utils\Services\ILogService;
3137

3238
/**
@@ -43,23 +49,47 @@ final class UserApiController extends APICRUDController
4349
*/
4450
private $token_service;
4551

52+
/**
53+
* @var IRecoveryCodeService
54+
*/
55+
private $recovery_code_service;
56+
57+
/**
58+
* @var ITransactionService
59+
*/
60+
private $tx_service;
61+
62+
/**
63+
* @var ITwoFactorAuditService
64+
*/
65+
private $two_factor_audit_service;
66+
4667
/**
4768
* UserApiController constructor.
4869
* @param IUserRepository $user_repository
4970
* @param ILogService $log_service
5071
* @param IUserService $user_service
5172
* @param ITokenService $token_service
73+
* @param IRecoveryCodeService $recovery_code_service
74+
* @param ITransactionService $tx_service
75+
* @param ITwoFactorAuditService $two_factor_audit_service
5276
*/
5377
public function __construct
5478
(
5579
IUserRepository $user_repository,
5680
ILogService $log_service,
5781
IUserService $user_service,
58-
ITokenService $token_service
82+
ITokenService $token_service,
83+
IRecoveryCodeService $recovery_code_service,
84+
ITransactionService $tx_service,
85+
ITwoFactorAuditService $two_factor_audit_service
5986
)
6087
{
6188
parent::__construct($user_repository, $user_service, $log_service);
6289
$this->token_service = $token_service;
90+
$this->recovery_code_service = $recovery_code_service;
91+
$this->tx_service = $tx_service;
92+
$this->two_factor_audit_service = $two_factor_audit_service;
6393
}
6494

6595
/**
@@ -247,6 +277,76 @@ public function updateMe()
247277
return $this->update(Auth::user()->getId());
248278
}
249279

280+
/**
281+
* Enables a 2FA method for the current user and generates the first batch of
282+
* recovery codes for them. Plaintext codes are returned once in the response
283+
* and never persisted.
284+
*
285+
* @return \Illuminate\Http\JsonResponse|mixed
286+
*/
287+
public function enableTwoFactor()
288+
{
289+
if (!Auth::check())
290+
return $this->error403();
291+
292+
return $this->processRequest(function () {
293+
$data = Request::all();
294+
$validator = Validator::make($data, [
295+
'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods),
296+
]);
297+
298+
if (!$validator->passes()) {
299+
return $this->error412($validator->getMessageBag()->getMessages());
300+
}
301+
302+
$user = Auth::user();
303+
$method = $data['method'];
304+
305+
$codes = $this->tx_service->transaction(function () use ($user, $method) {
306+
$user->enable2FA($method);
307+
$this->repository->add($user, false);
308+
309+
return $this->recovery_code_service->generateRecoveryCodes($user);
310+
});
311+
312+
$this->two_factor_audit_service->log(
313+
$user,
314+
TwoFactorAuditLog::EventEnrollmentChanged,
315+
$method,
316+
IPHelper::getUserIp()
317+
);
318+
319+
return $this->ok(['recovery_codes' => $codes]);
320+
});
321+
}
322+
323+
/**
324+
* Invalidates the current user's recovery codes and generates a fresh batch.
325+
* Plaintext codes are returned once in the response and never persisted.
326+
*
327+
* @return \Illuminate\Http\JsonResponse|mixed
328+
*/
329+
public function regenerateRecoveryCodes()
330+
{
331+
if (!Auth::check())
332+
return $this->error403();
333+
334+
return $this->processRequest(function () {
335+
$data = Request::all();
336+
$validator = Validator::make($data, [
337+
'current_password' => 'required|string',
338+
]);
339+
340+
if (!$validator->passes()) {
341+
return $this->error412($validator->getMessageBag()->getMessages());
342+
}
343+
344+
$codes = $this->recovery_code_service->regenerateRecoveryCodes(Auth::user(), $data['current_password']);
345+
346+
return $this->ok(['recovery_codes' => $codes]);
347+
});
348+
}
349+
250350
public function revokeAllMyTokens()
251351
{
252352
if (!Auth::check())

app/Http/Controllers/UserController.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use App\Http\Utils\CountryList;
2222
use App\libs\Auth\Models\TwoFactorAuditLog;
2323
use App\Services\Auth\IDeviceTrustService;
24+
use App\Services\Auth\IRecoveryCodeService;
2425
use App\Services\Auth\ITwoFactorAuditService;
2526
use App\Services\Auth\ITwoFactorGateService;
2627
use Auth\User;
@@ -157,6 +158,11 @@ final class UserController extends OpenIdController
157158
*/
158159
private $mfa_gate_service;
159160

161+
/**
162+
* @var IRecoveryCodeService
163+
*/
164+
private $recovery_code_service;
165+
160166
/**
161167
* @param IMementoOpenIdSerializerService $openid_memento_service
162168
* @param IMementoOAuth2SerializerService $oauth2_memento_service
@@ -196,6 +202,7 @@ public function __construct
196202
IDeviceTrustService $device_trust_service,
197203
ITwoFactorAuditService $two_factor_audit_service,
198204
ITwoFactorGateService $mfa_gate_service,
205+
IRecoveryCodeService $recovery_code_service,
199206
)
200207
{
201208
$this->openid_memento_service = $openid_memento_service;
@@ -216,6 +223,7 @@ public function __construct
216223
$this->device_trust_service = $device_trust_service;
217224
$this->two_factor_audit_service = $two_factor_audit_service;
218225
$this->mfa_gate_service = $mfa_gate_service;
226+
$this->recovery_code_service = $recovery_code_service;
219227

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

@@ -1007,6 +1015,10 @@ public function getProfile()
10071015
'actions' => $actions,
10081016
'countries' => CountryList::getCountries(),
10091017
'languages' => $lang2Code,
1018+
'two_factor_enabled' => $user->shouldRequire2FA(),
1019+
'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user),
1020+
'recovery_codes_total' => (int)config('auth.recovery_codes.count', 10),
1021+
'recovery_codes_low_threshold' => (int)config('auth.recovery_codes.low_threshold', 3),
10101022
]);
10111023
}
10121024

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2026 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use Auth\User;
17+
use models\exceptions\ValidationException;
18+
19+
/**
20+
* Interface IRecoveryCodeService
21+
* @package App\Services\Auth
22+
*/
23+
interface IRecoveryCodeService
24+
{
25+
/**
26+
* Invalidates every existing recovery code for the user and generates a fresh
27+
* batch. Plaintext codes are returned once and are never persisted/exposed again.
28+
*
29+
* @param User $user
30+
* @param string $currentPassword
31+
* @return string[] plaintext codes formatted as XXXX-XXXX
32+
* @throws ValidationException if $currentPassword does not match the user's password
33+
*/
34+
public function regenerateRecoveryCodes(User $user, string $currentPassword): array;
35+
36+
/**
37+
* Invalidates every existing recovery code for the user and generates a fresh
38+
* batch, without requiring password confirmation. Intended for first-time
39+
* generation right after 2FA enrollment, where the user's identity is already
40+
* established by the current session.
41+
*
42+
* @param User $user
43+
* @return string[] plaintext codes formatted as XXXX-XXXX
44+
*/
45+
public function generateRecoveryCodes(User $user): array;
46+
47+
/**
48+
* @param User $user
49+
* @return int count of unused recovery codes
50+
*/
51+
public function countUnusedRecoveryCodes(User $user): int;
52+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2026 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use App\libs\Auth\Models\TwoFactorAuditLog;
17+
use App\libs\Auth\Models\UserRecoveryCode;
18+
use Auth\Repositories\IUserRecoveryCodeRepository;
19+
use Auth\User;
20+
use Illuminate\Support\Facades\Hash;
21+
use Laminas\Math\Rand;
22+
use models\exceptions\ValidationException;
23+
use Utils\Db\ITransactionService;
24+
use Utils\IPHelper;
25+
26+
/**
27+
* Class RecoveryCodeService
28+
* @package App\Services\Auth
29+
*/
30+
final class RecoveryCodeService implements IRecoveryCodeService
31+
{
32+
private const CODE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
33+
34+
public function __construct(
35+
private readonly IUserRecoveryCodeRepository $repository,
36+
private readonly ITransactionService $tx_service,
37+
private readonly ITwoFactorAuditService $audit_service,
38+
) {
39+
}
40+
41+
/**
42+
* @inheritDoc
43+
*/
44+
public function regenerateRecoveryCodes(User $user, string $currentPassword): array
45+
{
46+
if (!$user->checkPassword(trim($currentPassword))) {
47+
throw new ValidationException('current_password is not correct.');
48+
}
49+
50+
return $this->generateRecoveryCodes($user);
51+
}
52+
53+
/**
54+
* @inheritDoc
55+
*/
56+
public function generateRecoveryCodes(User $user): array
57+
{
58+
$count = (int)config('auth.recovery_codes.count', 10);
59+
$length = (int)config('auth.recovery_codes.length', 8);
60+
61+
$plaintext_codes = [];
62+
63+
$this->tx_service->transaction(function () use ($user, $count, $length, &$plaintext_codes) {
64+
$this->repository->deleteAllForUser($user);
65+
66+
for ($i = 0; $i < $count; $i++) {
67+
$plain = Rand::getString($length, self::CODE_CHARSET, true);
68+
$plaintext_codes[] = $plain;
69+
70+
$code = new UserRecoveryCode();
71+
$code->setUser($user);
72+
$code->setCodeHash(Hash::make($plain));
73+
$this->repository->add($code, false);
74+
}
75+
});
76+
77+
$this->audit_service->log(
78+
$user,
79+
TwoFactorAuditLog::EventRecoveryCodesGenerated,
80+
TwoFactorAuditLog::MethodRecovery,
81+
IPHelper::getUserIp()
82+
);
83+
84+
return array_map(static fn(string $code) => implode('-', str_split($code, 4)), $plaintext_codes);
85+
}
86+
87+
/**
88+
* @inheritDoc
89+
*/
90+
public function countUnusedRecoveryCodes(User $user): int
91+
{
92+
return count($this->repository->getUnusedByUser($user));
93+
}
94+
}

app/Services/Auth/TwoFactorServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function register(): void
3434
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
3535
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
3636
$this->app->singleton(ITwoFactorGateService::class, MFAGateService::class);
37+
$this->app->singleton(IRecoveryCodeService::class, RecoveryCodeService::class);
3738
}
3839

3940
public function provides(): array
@@ -42,6 +43,7 @@ public function provides(): array
4243
IDeviceTrustService::class,
4344
ITwoFactorAuditService::class,
4445
ITwoFactorGateService::class,
46+
IRecoveryCodeService::class,
4547
];
4648
}
4749
}

app/libs/Auth/Models/TwoFactorAuditLog.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class TwoFactorAuditLog extends BaseEntity
2929
public const EventDeviceRevoked = 'device_revoked';
3030
public const EventRecoveryUsed = 'recovery_used';
3131
public const EventSettingsChanged = 'settings_changed';
32+
public const EventRecoveryCodesGenerated = 'recovery_codes_generated';
3233

3334
public const MethodEmailOtp = 'email_otp';
3435
public const MethodSmsOtp = 'sms_otp';
@@ -46,6 +47,7 @@ class TwoFactorAuditLog extends BaseEntity
4647
self::EventDeviceRevoked,
4748
self::EventRecoveryUsed,
4849
self::EventSettingsChanged,
50+
self::EventRecoveryCodesGenerated,
4951
];
5052

5153
private const ALLOWED_METHODS = [

config/auth.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@
107107
'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 (#?!@$%^&*+-).'),
108108
'verification_email_lifetime' => env("AUTH_VERIFICATION_EMAIL_LIFETIME", 600),
109109
'allows_native_auth' => env('AUTH_ALLOWS_NATIVE_AUTH', 1),
110+
111+
'recovery_codes' => [
112+
'count' => env('MFA_RECOVERY_CODES_COUNT', 10),
113+
'length' => env('MFA_RECOVERY_CODE_LENGTH', 8),
114+
'low_threshold' => env('MFA_RECOVERY_CODES_LOW_THRESHOLD', 3),
115+
],
110116
'allows_native_on_config' => env('AUTH_ALLOWS_NATIVE_AUTH_CONFIG', 1),
111117
'allows_opt_auth' => env('AUTH_ALLOWS_OTP_AUTH', 1),
112118
];

0 commit comments

Comments
 (0)