@@ -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}
0 commit comments