Skip to content

Commit 9146757

Browse files
feat: Add MultiFactor Authentication
1 parent f406fbf commit 9146757

4 files changed

Lines changed: 452 additions & 0 deletions

File tree

app/libs/Auth/Models/User.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ class User extends BaseEntity
7676
self::SpamTypeHam
7777
];
7878

79+
public const MFAMethod_OTP = 'email_otp';
80+
public const MFAMethod_SMS = 'sms_otp';
81+
public const MFAMethod_TOTP = 'totp';
82+
public const MFAMethod_PASSKEY = 'passkey';
83+
84+
public const ValidMFAMethods = [
85+
self::MFAMethod_OTP
86+
];
87+
7988
/**
8089
* @var string
8190
*/
@@ -303,6 +312,25 @@ class User extends BaseEntity
303312
*/
304313
#[ORM\Column(name: 'email_verified_date', nullable: true, type: 'datetime')]
305314
private $email_verified_date;
315+
316+
/**
317+
* @var bool
318+
*/
319+
#[ORM\Column(name: 'two_factor_enabled', type: 'boolean', options: ['default' => false])]
320+
private $two_factor_enabled;
321+
322+
/**
323+
* @var string
324+
*/
325+
#[ORM\Column(name: 'two_factor_method', type: 'string', length: 32, options: ['default' => self::MFAMethod_OTP])]
326+
private $two_factor_method;
327+
328+
/**
329+
* @var \DateTime|null
330+
*/
331+
#[ORM\Column(name: 'two_factor_enforced_at', nullable: true, type: 'datetime')]
332+
private $two_factor_enforced_at;
333+
306334
/**
307335
* @var string
308336
*/
@@ -457,6 +485,9 @@ public function __construct()
457485
parent::__construct();
458486
$this->active = true;
459487
$this->email_verified = false;
488+
$this->two_factor_enabled = false;
489+
$this->two_factor_method = self::MFAMethod_OTP;
490+
$this->two_factor_enforced_at = null;
460491
// user profile settings
461492
$this->public_profile_show_photo = false;
462493
$this->public_profile_show_email = false;
@@ -2359,4 +2390,142 @@ public function getAuthPasswordName()
23592390
return 'password';
23602391
}
23612392

2393+
// --- Two-factor authentication ---------------------------------------
2394+
2395+
public function isTwoFactorEnabled(): bool
2396+
{
2397+
return (bool) $this->two_factor_enabled;
2398+
}
2399+
2400+
public function setTwoFactorEnabled(bool $enabled): void
2401+
{
2402+
$this->two_factor_enabled = $enabled;
2403+
}
2404+
2405+
public function getTwoFactorMethod(): string
2406+
{
2407+
return $this->two_factor_method;
2408+
}
2409+
2410+
/**
2411+
* @throws ValidationException
2412+
*/
2413+
protected function setTwoFactorMethod(string $method): void
2414+
{
2415+
$this->two_factor_method = $method;
2416+
}
2417+
2418+
public function getTwoFactorEnforcedAt(): ?\DateTime
2419+
{
2420+
return $this->two_factor_enforced_at;
2421+
}
2422+
2423+
public function setTwoFactorEnforcedAt(?\DateTime $at): void
2424+
{
2425+
$this->two_factor_enforced_at = $at;
2426+
}
2427+
2428+
/**
2429+
* Whether this user is required to complete 2FA to sign in.
2430+
*
2431+
* A user is required when they belong to any of the groups listed in
2432+
* config('two_factor.enforced_groups'); otherwise the stored flag applies.
2433+
*/
2434+
public function shouldRequire2FA(): bool
2435+
{
2436+
$enforcedGroups = config('two_factor.enforced_groups', []);
2437+
foreach ($enforcedGroups as $slug) {
2438+
if($this->belongToGroup($slug)) {
2439+
return true;
2440+
}
2441+
}
2442+
return (bool) $this->two_factor_enabled;
2443+
}
2444+
2445+
/**
2446+
* @throws ValidationException
2447+
*/
2448+
public function enable2FA(string $method): void
2449+
{
2450+
$availableMethods = $this->getAvailableTwoFactorMethods();
2451+
if(!in_array($method, self::ValidMFAMethods, true)) {
2452+
throw new ValidationException(
2453+
sprintf(
2454+
"Invalid 2FA method '%s'. Allowed methods: %s. Enabled methods: %s",
2455+
$method,
2456+
implode(', ', self::ValidMFAMethods),
2457+
implode(', ', $availableMethods)
2458+
)
2459+
);
2460+
}
2461+
2462+
if(!in_array($method, $availableMethods, true)) {
2463+
throw new ValidationException(
2464+
sprintf(
2465+
"Disabled 2FA method '%s'. Enabled methods: %s",
2466+
$method,
2467+
implode(', ', $availableMethods)
2468+
)
2469+
);
2470+
}
2471+
2472+
$this->setTwoFactorMethod($method);
2473+
$this->setTwoFactorEnabled(true);
2474+
$this->setTwoFactorEnforcedAt(new \DateTime('now', new \DateTimeZone('UTC')));
2475+
}
2476+
2477+
public function disable2FA(): void
2478+
{
2479+
$this->setTwoFactorEnabled(false);
2480+
$this->setTwoFactorEnforcedAt(null);
2481+
}
2482+
2483+
/**
2484+
* Returns the set of 2FA methods currently available to this user.
2485+
* Phase I only supports email_otp; other methods are stubs that will
2486+
* light up in Phase II/III once the backing verifications exist.
2487+
*
2488+
* @return string[]
2489+
*/
2490+
public function getAvailableTwoFactorMethods(): array
2491+
{
2492+
$methods = [];
2493+
if($this->isEmailVerified() && in_array(self::MFAMethod_OTP, self::ValidMFAMethods, true)) {
2494+
$methods[] = self::MFAMethod_OTP;
2495+
}
2496+
if($this->isPhoneNumberVerified() && in_array(self::MFAMethod_SMS, self::ValidMFAMethods, true)) {
2497+
$methods[] = self::MFAMethod_SMS;
2498+
}
2499+
if($this->isTOTPConfirmed() && in_array(self::MFAMethod_TOTP, self::ValidMFAMethods, true)) {
2500+
$methods[] = self::MFAMethod_TOTP;
2501+
}
2502+
if($this->isPassKeyEnabled() && in_array(self::MFAMethod_PASSKEY, self::ValidMFAMethods, true)) {
2503+
$methods[] = self::MFAMethod_PASSKEY;
2504+
}
2505+
return $methods;
2506+
}
2507+
2508+
public function isTwoFactorMethodEnabled(string $method): bool
2509+
{
2510+
return in_array($method, $this->getAvailableTwoFactorMethods(), true);
2511+
}
2512+
2513+
// Phase II stub
2514+
public function isPhoneNumberVerified(): bool
2515+
{
2516+
return false;
2517+
}
2518+
2519+
// Phase III stub
2520+
public function isTOTPConfirmed(): bool
2521+
{
2522+
return false;
2523+
}
2524+
2525+
// Phase III stub
2526+
public function isPassKeyEnabled(): bool
2527+
{
2528+
return false;
2529+
}
2530+
23622531
}

config/two_factor.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use App\libs\Auth\Models\IGroupSlugs;
16+
17+
return [
18+
/*
19+
|--------------------------------------------------------------------------
20+
| Enforced Groups
21+
|--------------------------------------------------------------------------
22+
|
23+
| Users that belong to any of these groups are required to complete 2FA
24+
| regardless of the value of their `two_factor_enabled` flag.
25+
|
26+
*/
27+
'enforced_groups' => [
28+
IGroupSlugs::SuperAdminGroup,
29+
IGroupSlugs::AdminGroup,
30+
IGroupSlugs::OAuth2ServerAdminGroup,
31+
IGroupSlugs::OpenIdServerAdminsGroup,
32+
],
33+
];

phpunit.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<testsuite name="OTEL Custom Formatters Test Suite">
2323
<directory>./tests/OpenTelemetry/Formatters/</directory>
2424
</testsuite>
25+
<testsuite name="Two Factor Authentication Test Suite">
26+
<file>./tests/TwoFactorRepositoriesTest.php</file>
27+
<file>./tests/unit/UserTwoFactorTest.php</file>
28+
</testsuite>
2529
</testsuites>
2630
<php>
2731
<env name="APP_ENV" value="testing"/>

0 commit comments

Comments
 (0)