From 6e15f4807bbfcc98bdb7b92220f739d303f02921 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 17:36:11 +0000 Subject: [PATCH 1/2] feat(identity): phone number confirmation flow (closes #174) Mirrors the existing email confirmation surface for phone numbers. Users can now send a verification code to their phone, enter the 6-digit token to atomically save and confirm the number, and remove the number entirely. The Manage profile page shows a verified badge, and admins can see verification state and force re-verification from the user security tab. Introduces ISmsSender + ConsoleSmsSender so real providers (Twilio, etc.) can plug in via DI without touching the endpoints. --- .../AdminConstants.cs | 1 + .../Endpoints/Admin/AdminUsersEndpoint.cs | 11 + .../src/SimpleModule.Admin/Locales/en.json | 9 + .../src/SimpleModule.Admin/Locales/keys.ts | 9 + .../Pages/Admin/UsersEdit.tsx | 19 +- .../Admin/components/UserSecurityTab.tsx | 38 ++- .../AdminUserDto.cs | 2 + .../ISmsSender.cs | 11 + .../IUserAdminContracts.cs | 1 + .../UsersConstants.cs | 3 + .../Manage/ConfirmPhoneNumberEndpoint.cs | 91 +++++++ .../Pages/Account/Manage/ManageIndex.tsx | 134 ++++++++-- .../Account/Manage/ManageIndexEndpoint.cs | 10 +- .../Manage/RemovePhoneNumberEndpoint.cs | 67 +++++ .../SendPhoneVerificationCodeEndpoint.cs | 76 ++++++ .../Services/ConsoleSmsSender.cs | 28 +++ .../SimpleModule.Users/UserAdminService.cs | 11 + .../src/SimpleModule.Users/UsersModule.cs | 1 + modules/Users/src/SimpleModule.Users/types.ts | 2 + .../PhoneConfirmationEndpointTests.cs | 233 ++++++++++++++++++ packages/SimpleModule.Client/src/routes.ts | 4 + 21 files changed, 731 insertions(+), 30 deletions(-) create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/ISmsSender.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/Manage/RemovePhoneNumberEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Services/ConsoleSmsSender.cs create mode 100644 modules/Users/tests/SimpleModule.Users.Tests/Integration/PhoneConfirmationEndpointTests.cs diff --git a/modules/Admin/src/SimpleModule.Admin.Contracts/AdminConstants.cs b/modules/Admin/src/SimpleModule.Admin.Contracts/AdminConstants.cs index 4e1fde6f..cf74fc40 100644 --- a/modules/Admin/src/SimpleModule.Admin.Contracts/AdminConstants.cs +++ b/modules/Admin/src/SimpleModule.Admin.Contracts/AdminConstants.cs @@ -32,6 +32,7 @@ public static class Routes public const string UsersLockApi = "/admin/users/{id}/lock"; public const string UsersUnlockApi = "/admin/users/{id}/unlock"; public const string UsersForceReverifyApi = "/admin/users/{id}/force-reverify"; + public const string UsersForcePhoneReverifyApi = "/admin/users/{id}/force-phone-reverify"; public const string UsersDisable2faApi = "/admin/users/{id}/disable-2fa"; public const string UsersDeactivateApi = "/admin/users/{id}/deactivate"; public const string UsersReactivateApi = "/admin/users/{id}/reactivate"; diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs index 69b6ea1b..e91bcf7b 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs @@ -174,6 +174,17 @@ async Task (string id, IUserAdminContracts userAdmin) => } ); + // POST /admin/users/{id}/force-phone-reverify — Force phone re-verification + group.MapPost( + "/{id}/force-phone-reverify", + async Task (string id, IUserAdminContracts userAdmin) => + { + await userAdmin.ForcePhoneReverificationAsync(UserId.From(id)); + + return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); + } + ); + // POST /admin/users/{id}/disable-2fa — Disable two-factor authentication group.MapPost( "/{id}/disable-2fa", diff --git a/modules/Admin/src/SimpleModule.Admin/Locales/en.json b/modules/Admin/src/SimpleModule.Admin/Locales/en.json index a4922a70..8aabea42 100644 --- a/modules/Admin/src/SimpleModule.Admin/Locales/en.json +++ b/modules/Admin/src/SimpleModule.Admin/Locales/en.json @@ -77,6 +77,12 @@ "UsersEdit.EmailVerified": "Verified", "UsersEdit.EmailNotVerified": "Not verified", "UsersEdit.ForceReverifyButton": "Force Re-verification", + "UsersEdit.PhoneVerificationTitle": "Phone Verification", + "UsersEdit.PhoneVerificationStatus": "Status: {status}", + "UsersEdit.PhoneVerified": "Verified", + "UsersEdit.PhoneNotVerified": "Not verified", + "UsersEdit.PhoneNotSet": "No phone number set", + "UsersEdit.ForceReverifyPhoneButton": "Force Phone Re-verification", "UsersEdit.TwoFactorTitle": "Two-Factor Authentication", "UsersEdit.TwoFactorStatus": "Status: {status}", "UsersEdit.TwoFactorEnabled": "Enabled", @@ -104,6 +110,9 @@ "UsersEdit.ConfirmReverifyTitle": "Force Re-verification", "UsersEdit.ConfirmReverifyDescription": "This will require the user to re-verify their email address. Are you sure?", "UsersEdit.ConfirmReverifyAction": "Force Re-verification", + "UsersEdit.ConfirmReverifyPhoneTitle": "Force Phone Re-verification", + "UsersEdit.ConfirmReverifyPhoneDescription": "This will require the user to re-verify their phone number. Are you sure?", + "UsersEdit.ConfirmReverifyPhoneAction": "Force Phone Re-verification", "UsersEdit.ConfirmDisable2faTitle": "Disable Two-Factor Authentication", "UsersEdit.ConfirmDisable2faDescription": "This will disable 2FA and reset the authenticator for this user. Are you sure?", "UsersEdit.ConfirmDisable2faAction": "Disable 2FA", diff --git a/modules/Admin/src/SimpleModule.Admin/Locales/keys.ts b/modules/Admin/src/SimpleModule.Admin/Locales/keys.ts index d0fe2050..19bec2c1 100644 --- a/modules/Admin/src/SimpleModule.Admin/Locales/keys.ts +++ b/modules/Admin/src/SimpleModule.Admin/Locales/keys.ts @@ -112,6 +112,9 @@ export const AdminKeys = { ConfirmReverifyAction: 'UsersEdit.ConfirmReverifyAction', ConfirmReverifyDescription: 'UsersEdit.ConfirmReverifyDescription', ConfirmReverifyTitle: 'UsersEdit.ConfirmReverifyTitle', + ConfirmReverifyPhoneAction: 'UsersEdit.ConfirmReverifyPhoneAction', + ConfirmReverifyPhoneDescription: 'UsersEdit.ConfirmReverifyPhoneDescription', + ConfirmReverifyPhoneTitle: 'UsersEdit.ConfirmReverifyPhoneTitle', ConfirmRevokeAllAction: 'UsersEdit.ConfirmRevokeAllAction', ConfirmRevokeAllDescription: 'UsersEdit.ConfirmRevokeAllDescription', ConfirmRevokeAllTitle: 'UsersEdit.ConfirmRevokeAllTitle', @@ -126,6 +129,11 @@ export const AdminKeys = { EmailVerificationStatus: 'UsersEdit.EmailVerificationStatus', EmailVerificationTitle: 'UsersEdit.EmailVerificationTitle', EmailVerified: 'UsersEdit.EmailVerified', + PhoneVerificationTitle: 'UsersEdit.PhoneVerificationTitle', + PhoneVerificationStatus: 'UsersEdit.PhoneVerificationStatus', + PhoneVerified: 'UsersEdit.PhoneVerified', + PhoneNotVerified: 'UsersEdit.PhoneNotVerified', + PhoneNotSet: 'UsersEdit.PhoneNotSet', ErrorPasswordMismatch: 'UsersEdit.ErrorPasswordMismatch', FailedLoginAttempts: 'UsersEdit.FailedLoginAttempts', FieldConfirmPassword: 'UsersEdit.FieldConfirmPassword', @@ -134,6 +142,7 @@ export const AdminKeys = { FieldEmailConfirmed: 'UsersEdit.FieldEmailConfirmed', FieldNewPassword: 'UsersEdit.FieldNewPassword', ForceReverifyButton: 'UsersEdit.ForceReverifyButton', + ForceReverifyPhoneButton: 'UsersEdit.ForceReverifyPhoneButton', LastLogin: 'UsersEdit.LastLogin', LastLoginNever: 'UsersEdit.LastLoginNever', LockButton: 'UsersEdit.LockButton', diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx index 0d19be48..b9b86a15 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx @@ -30,6 +30,8 @@ interface UserDetail { displayName: string; email: string; emailConfirmed: boolean; + phoneNumber: string | null; + phoneNumberConfirmed: boolean; twoFactorEnabled: boolean; roles: string[]; isLockedOut: boolean; @@ -63,7 +65,13 @@ interface Props { currentUserId: string; } -type ConfirmAction = 'deactivate' | 'reverify' | 'disable2fa' | 'revokeAll' | null; +type ConfirmAction = + | 'deactivate' + | 'reverify' + | 'reverifyPhone' + | 'disable2fa' + | 'revokeAll' + | null; export default function UsersEdit({ user, @@ -94,6 +102,9 @@ export default function UsersEdit({ case 'reverify': router.post(`/admin/users/${user.id}/force-reverify`); break; + case 'reverifyPhone': + router.post(`/admin/users/${user.id}/force-phone-reverify`); + break; case 'disable2fa': router.post(`/admin/users/${user.id}/disable-2fa`); break; @@ -118,6 +129,11 @@ export default function UsersEdit({ description: t(AdminKeys.UsersEdit.ConfirmReverifyDescription), action: t(AdminKeys.UsersEdit.ConfirmReverifyAction), }, + reverifyPhone: { + title: t(AdminKeys.UsersEdit.ConfirmReverifyPhoneTitle), + description: t(AdminKeys.UsersEdit.ConfirmReverifyPhoneDescription), + action: t(AdminKeys.UsersEdit.ConfirmReverifyPhoneAction), + }, disable2fa: { title: t(AdminKeys.UsersEdit.ConfirmDisable2faTitle), description: t(AdminKeys.UsersEdit.ConfirmDisable2faDescription), @@ -183,6 +199,7 @@ export default function UsersEdit({ user={user} isSelf={isSelf} onReverify={() => setConfirmAction('reverify')} + onReverifyPhone={() => setConfirmAction('reverifyPhone')} onDisable2fa={() => setConfirmAction('disable2fa')} /> )} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx index cf33d235..eb51c9d6 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/components/UserSecurityTab.tsx @@ -17,6 +17,8 @@ import { AdminKeys } from '@/Locales/keys'; interface UserDetail { id: string; emailConfirmed: boolean; + phoneNumber: string | null; + phoneNumberConfirmed: boolean; twoFactorEnabled: boolean; isLockedOut: boolean; accessFailedCount: number; @@ -28,10 +30,17 @@ interface Props { user: UserDetail; isSelf: boolean; onReverify: () => void; + onReverifyPhone: () => void; onDisable2fa: () => void; } -export function UserSecurityTab({ user, isSelf, onReverify, onDisable2fa }: Props) { +export function UserSecurityTab({ + user, + isSelf, + onReverify, + onReverifyPhone, + onDisable2fa, +}: Props) { const { t } = useTranslation('Admin'); const [passwordError, setPasswordError] = useState(null); @@ -128,6 +137,33 @@ export function UserSecurityTab({ user, isSelf, onReverify, onDisable2fa }: Prop + + + {t(AdminKeys.UsersEdit.PhoneVerificationTitle)} + + + {user.phoneNumber ? ( + <> +

{user.phoneNumber}

+

+ {t(AdminKeys.UsersEdit.PhoneVerificationStatus, { + status: user.phoneNumberConfirmed + ? t(AdminKeys.UsersEdit.PhoneVerified) + : t(AdminKeys.UsersEdit.PhoneNotVerified), + })} +

+ {user.phoneNumberConfirmed && ( + + )} + + ) : ( +

{t(AdminKeys.UsersEdit.PhoneNotSet)}

+ )} +
+
+ {t(AdminKeys.UsersEdit.TwoFactorTitle)} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs b/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs index ddc7826e..379f5abf 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs @@ -9,6 +9,8 @@ public class AdminUserDto public string DisplayName { get; set; } = string.Empty; public string? Email { get; set; } public bool EmailConfirmed { get; set; } + public string? PhoneNumber { get; set; } + public bool PhoneNumberConfirmed { get; set; } public bool TwoFactorEnabled { get; set; } public List Roles { get; set; } = []; public bool IsLockedOut { get; set; } diff --git a/modules/Users/src/SimpleModule.Users.Contracts/ISmsSender.cs b/modules/Users/src/SimpleModule.Users.Contracts/ISmsSender.cs new file mode 100644 index 00000000..d06dc30e --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/ISmsSender.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Users.Contracts; + +public interface ISmsSender +{ + Task SendVerificationCodeAsync( + ApplicationUser user, + string phoneNumber, + string code, + CancellationToken cancellationToken = default + ); +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs b/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs index 3844bfba..97343c7a 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs @@ -21,5 +21,6 @@ Task> GetUsersPagedAsync( Task DeactivateAsync(UserId id); Task ReactivateAsync(UserId id); Task ForceEmailReverificationAsync(UserId id); + Task ForcePhoneReverificationAsync(UserId id); Task DisableTwoFactorAsync(UserId id); } diff --git a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs index 178240ed..a420f08f 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs @@ -53,6 +53,9 @@ public static class Routes public const string ResetAuthenticator = "/Manage/ResetAuthenticator"; public const string GenerateRecoveryCodes = "/Manage/GenerateRecoveryCodes"; public const string SignOutEverywhere = "/Manage/SignOutEverywhere"; + public const string SendPhoneVerificationCode = "/Manage/SendPhoneVerificationCode"; + public const string ConfirmPhoneNumber = "/Manage/ConfirmPhoneNumber"; + public const string RemovePhoneNumber = "/Manage/RemovePhoneNumber"; } public static class TokenPurposes diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs new file mode 100644 index 00000000..9b1d253e --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ConfirmPhoneNumberEndpoint.cs @@ -0,0 +1,91 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account.Manage; + +public class ConfirmPhoneNumberEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.ConfirmPhoneNumber; + + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + Route, + async ( + [FromForm] string? phoneNumber, + [FromForm] string? code, + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.Redirect("/Identity/Account/Login"); + } + + var username = await userManager.GetUserNameAsync(user); + + if (string.IsNullOrWhiteSpace(phoneNumber) || string.IsNullOrWhiteSpace(code)) + { + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = await userManager.GetPhoneNumberAsync(user), + isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ), + pendingPhoneNumber = phoneNumber, + statusMessage = "Error: Phone number and verification code are required.", + } + ); + } + + var result = await userManager.ChangePhoneNumberAsync(user, phoneNumber, code); + if (!result.Succeeded) + { + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = await userManager.GetPhoneNumberAsync(user), + isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ), + pendingPhoneNumber = phoneNumber, + statusMessage = "Error: Invalid or expired verification code.", + } + ); + } + + await signInManager.RefreshSignInAsync(user); + + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = await userManager.GetPhoneNumberAsync(user), + isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ), + statusMessage = "Your phone number has been verified.", + } + ); + } + ) + .RequireAuthorization() + .DisableAntiforgery(); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx index 36ed63bc..14cb441a 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx @@ -17,51 +17,131 @@ import ManageLayout from '@/components/ManageLayout'; interface Props { username?: string; - phoneNumber?: string; + phoneNumber?: string | null; + isPhoneNumberConfirmed?: boolean; + pendingPhoneNumber?: string | null; statusMessage?: string; } -export default function ManageIndex({ username, phoneNumber, statusMessage }: Props) { +export default function ManageIndex({ + username, + phoneNumber, + isPhoneNumberConfirmed, + pendingPhoneNumber, + statusMessage, +}: Props) { const [confirmOpen, setConfirmOpen] = useState(false); + const [inputPhone, setInputPhone] = useState(pendingPhoneNumber ?? phoneNumber ?? ''); - function handleSubmit(e: React.FormEvent) { + function confirmSignOutEverywhere() { + router.post('/Identity/Account/Manage/SignOutEverywhere'); + } + + function sendVerificationCode(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); - router.post('/Identity/Account/Manage', formData); + router.post('/Identity/Account/Manage/SendPhoneVerificationCode', formData); } - function confirmSignOutEverywhere() { - router.post('/Identity/Account/Manage/SignOutEverywhere'); + function verifyCode(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post('/Identity/Account/Manage/ConfirmPhoneNumber', formData); } + function removePhone() { + router.post('/Identity/Account/Manage/RemovePhoneNumber'); + } + + const hasSavedPhone = !!phoneNumber; + const showVerifiedBadge = + isPhoneNumberConfirmed && + hasSavedPhone && + (!pendingPhoneNumber || pendingPhoneNumber === phoneNumber); + return (

Profile

{statusMessage && ( -
+
{statusMessage}
)} -
- - - - - - - - - - - -
+ + + + + + + + +
+ +
+

Phone number

+

+ Verify your phone number to use it for account recovery and two-factor authentication. +

+ +
+ + + +
+ setInputPhone(e.target.value)} + placeholder="Please enter your phone number." + /> + {showVerifiedBadge && ( + + ✓ + + )} +
+
+
+ + {hasSavedPhone && ( + + )} +
+
+
+ + {pendingPhoneNumber && ( +
+ + + + + + + + +
+ )} +

diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndexEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndexEndpoint.cs index ea69b86a..7ebdd700 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndexEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndexEndpoint.cs @@ -28,10 +28,18 @@ public void Map(IEndpointRouteBuilder app) var username = await userManager.GetUserNameAsync(user); var phoneNumber = await userManager.GetPhoneNumberAsync(user); + var isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ); return Inertia.Render( "Users/Account/Manage/Index", - new { username, phoneNumber } + new + { + username, + phoneNumber, + isPhoneNumberConfirmed, + } ); } ) diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/RemovePhoneNumberEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/RemovePhoneNumberEndpoint.cs new file mode 100644 index 00000000..ce7d92cb --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/RemovePhoneNumberEndpoint.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account.Manage; + +public class RemovePhoneNumberEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.RemovePhoneNumber; + + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + Route, + async ( + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.Redirect("/Identity/Account/Login"); + } + + var username = await userManager.GetUserNameAsync(user); + var setResult = await userManager.SetPhoneNumberAsync(user, null); + if (!setResult.Succeeded) + { + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = await userManager.GetPhoneNumberAsync(user), + isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ), + statusMessage = "Error: Unable to remove phone number.", + } + ); + } + + await signInManager.RefreshSignInAsync(user); + + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = (string?)null, + isPhoneNumberConfirmed = false, + statusMessage = "Your phone number has been removed.", + } + ); + } + ) + .RequireAuthorization() + .DisableAntiforgery(); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs new file mode 100644 index 00000000..7b56475b --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/SendPhoneVerificationCodeEndpoint.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account.Manage; + +public class SendPhoneVerificationCodeEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.SendPhoneVerificationCode; + + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + Route, + async ( + [FromForm] string? phoneNumber, + ClaimsPrincipal principal, + UserManager userManager, + ISmsSender smsSender + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return TypedResults.Redirect("/Identity/Account/Login"); + } + + var username = await userManager.GetUserNameAsync(user); + var currentPhoneNumber = await userManager.GetPhoneNumberAsync(user); + var isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync( + user + ); + + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = currentPhoneNumber, + isPhoneNumberConfirmed, + statusMessage = "Error: Please enter a phone number.", + } + ); + } + + var code = await userManager.GenerateChangePhoneNumberTokenAsync( + user, + phoneNumber + ); + await smsSender.SendVerificationCodeAsync(user, phoneNumber, code); + + return Inertia.Render( + "Users/Account/Manage/Index", + new + { + username, + phoneNumber = currentPhoneNumber, + isPhoneNumberConfirmed, + pendingPhoneNumber = phoneNumber, + statusMessage = "Verification code sent. Please check your phone.", + } + ); + } + ) + .RequireAuthorization() + .DisableAntiforgery(); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Services/ConsoleSmsSender.cs b/modules/Users/src/SimpleModule.Users/Services/ConsoleSmsSender.cs new file mode 100644 index 00000000..14f9f535 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Services/ConsoleSmsSender.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Services; + +public partial class ConsoleSmsSender(ILogger logger) : ISmsSender +{ + public Task SendVerificationCodeAsync( + ApplicationUser user, + string phoneNumber, + string code, + CancellationToken cancellationToken = default + ) + { + LogVerificationCode(logger, phoneNumber, code); + return Task.CompletedTask; + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Phone verification code for {PhoneNumber}: {Code}" + )] + private static partial void LogVerificationCode( + ILogger logger, + string phoneNumber, + string code + ); +} diff --git a/modules/Users/src/SimpleModule.Users/UserAdminService.cs b/modules/Users/src/SimpleModule.Users/UserAdminService.cs index 79e8e8a1..f228d976 100644 --- a/modules/Users/src/SimpleModule.Users/UserAdminService.cs +++ b/modules/Users/src/SimpleModule.Users/UserAdminService.cs @@ -254,6 +254,15 @@ public async Task ForceEmailReverificationAsync(UserId id) await userManager.UpdateAsync(user); } + public async Task ForcePhoneReverificationAsync(UserId id) + { + var user = + await userManager.FindByIdAsync(id.Value) ?? throw new NotFoundException("User", id); + + user.PhoneNumberConfirmed = false; + await userManager.UpdateAsync(user); + } + public async Task DisableTwoFactorAsync(UserId id) { var user = @@ -270,6 +279,8 @@ private static AdminUserDto MapToAdminDto(ApplicationUser user, List rol DisplayName = user.DisplayName, Email = user.Email, EmailConfirmed = user.EmailConfirmed, + PhoneNumber = user.PhoneNumber, + PhoneNumberConfirmed = user.PhoneNumberConfirmed, TwoFactorEnabled = user.TwoFactorEnabled, Roles = roles, IsLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd > DateTimeOffset.UtcNow, diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index a3866047..7750b586 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -85,6 +85,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddHostedService(); services.AddSingleton, ConsoleEmailSender>(); services.AddSingleton(); + services.AddSingleton(); } public void ConfigurePermissions(PermissionRegistryBuilder builder) diff --git a/modules/Users/src/SimpleModule.Users/types.ts b/modules/Users/src/SimpleModule.Users/types.ts index a1037622..65fcdffb 100644 --- a/modules/Users/src/SimpleModule.Users/types.ts +++ b/modules/Users/src/SimpleModule.Users/types.ts @@ -4,6 +4,8 @@ export interface AdminUserDto { displayName: string; email: string; emailConfirmed: boolean; + phoneNumber: string; + phoneNumberConfirmed: boolean; twoFactorEnabled: boolean; roles: string[]; isLockedOut: boolean; diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/PhoneConfirmationEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/PhoneConfirmationEndpointTests.cs new file mode 100644 index 00000000..f93f3489 --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/PhoneConfirmationEndpointTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class PhoneConfirmationEndpointTests +{ + private const string SendCodePath = "/Identity/Account/Manage/SendPhoneVerificationCode"; + private const string ConfirmPath = "/Identity/Account/Manage/ConfirmPhoneNumber"; + private const string RemovePath = "/Identity/Account/Manage/RemovePhoneNumber"; + + private static readonly WebApplicationFactoryClientOptions NoRedirect = new() + { + AllowAutoRedirect = false, + }; + + private readonly SimpleModuleWebApplicationFactory _factory; + + public PhoneConfirmationEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + private async Task SeedUserAsync(string id) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var existing = await userManager.FindByIdAsync(id); + if (existing is not null) + return existing; + + var user = new ApplicationUser + { + Id = id, + UserName = $"{id}@example.com", + Email = $"{id}@example.com", + DisplayName = "Phone Test User", + }; + var result = await userManager.CreateAsync(user, "TestPass1234!"); + result.Succeeded.Should().BeTrue(); + return (await userManager.FindByIdAsync(id))!; + } + + private async Task GenerateChangeTokenAsync(string userId, string phoneNumber) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId); + return await userManager.GenerateChangePhoneNumberTokenAsync(user!, phoneNumber); + } + + private async Task<(string? phoneNumber, bool confirmed)> GetPhoneStateAsync(string userId) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId); + return user is null ? (null, false) : (user.PhoneNumber, user.PhoneNumberConfirmed); + } + + private async Task SetPhoneNumberDirectlyAsync( + string userId, + string phoneNumber, + bool confirmed + ) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId); + await userManager.SetPhoneNumberAsync(user!, phoneNumber); + if (confirmed) + { + user!.PhoneNumberConfirmed = true; + await userManager.UpdateAsync(user); + } + } + + [Fact] + public async Task SendPhoneVerificationCode_WhenUnauthenticated_Returns401() + { + using var client = _factory.CreateClient(NoRedirect); + using var form = new FormUrlEncodedContent([ + new KeyValuePair("phoneNumber", "+15551234567"), + ]); + + var response = await client.PostAsync(SendCodePath, form); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task SendPhoneVerificationCode_Authenticated_ReturnsOkAndLeavesPhoneUnconfirmed() + { + const string userId = "phone-send-user"; + await SeedUserAsync(userId); + + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, userId) + ); + using var form = new FormUrlEncodedContent([ + new KeyValuePair("phoneNumber", "+15550001111"), + ]); + + var response = await client.PostAsync(SendCodePath, form); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var (phone, confirmed) = await GetPhoneStateAsync(userId); + phone.Should().BeNull("send-code should not save the phone number until verification"); + confirmed.Should().BeFalse(); + } + + [Fact] + public async Task ConfirmPhoneNumber_WithValidCode_SetsPhoneAndConfirmsIt() + { + const string userId = "phone-confirm-happy-user"; + const string phoneNumber = "+15550001112"; + await SeedUserAsync(userId); + var token = await GenerateChangeTokenAsync(userId, phoneNumber); + + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, userId) + ); + using var form = new FormUrlEncodedContent([ + new KeyValuePair("phoneNumber", phoneNumber), + new KeyValuePair("code", token), + ]); + + var response = await client.PostAsync(ConfirmPath, form); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var (phone, confirmed) = await GetPhoneStateAsync(userId); + phone.Should().Be(phoneNumber); + confirmed.Should().BeTrue(); + } + + [Fact] + public async Task ConfirmPhoneNumber_WithInvalidCode_DoesNotConfirm() + { + const string userId = "phone-confirm-bad-user"; + const string phoneNumber = "+15550001113"; + await SeedUserAsync(userId); + + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, userId) + ); + using var form = new FormUrlEncodedContent([ + new KeyValuePair("phoneNumber", phoneNumber), + new KeyValuePair("code", "000000"), + ]); + + var response = await client.PostAsync(ConfirmPath, form); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var (phone, confirmed) = await GetPhoneStateAsync(userId); + phone.Should().BeNull("no save should occur when token is invalid"); + confirmed.Should().BeFalse(); + } + + [Fact] + public async Task ConfirmPhoneNumber_ChangingNumberFromConfirmed_ResetsAndReconfirmsForNewNumber() + { + const string userId = "phone-change-user"; + const string originalPhone = "+15550002221"; + const string newPhone = "+15550002222"; + await SeedUserAsync(userId); + await SetPhoneNumberDirectlyAsync(userId, originalPhone, confirmed: true); + + // sanity check + var (preChangePhone, preConfirmed) = await GetPhoneStateAsync(userId); + preChangePhone.Should().Be(originalPhone); + preConfirmed.Should().BeTrue(); + + var token = await GenerateChangeTokenAsync(userId, newPhone); + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, userId) + ); + using var form = new FormUrlEncodedContent([ + new KeyValuePair("phoneNumber", newPhone), + new KeyValuePair("code", token), + ]); + + var response = await client.PostAsync(ConfirmPath, form); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var (phone, confirmed) = await GetPhoneStateAsync(userId); + phone.Should().Be(newPhone); + confirmed + .Should() + .BeTrue("verification of the new number replaces the previous confirmation atomically"); + } + + [Fact] + public async Task RemovePhoneNumber_Authenticated_ClearsPhoneAndConfirmation() + { + const string userId = "phone-remove-user"; + await SeedUserAsync(userId); + await SetPhoneNumberDirectlyAsync(userId, "+15550003333", confirmed: true); + + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + var response = await client.PostAsync(RemovePath, null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var (phone, confirmed) = await GetPhoneStateAsync(userId); + phone.Should().BeNull(); + confirmed.Should().BeFalse(); + } + + [Fact] + public async Task RemovePhoneNumber_Unauthenticated_Returns401() + { + using var client = _factory.CreateClient(NoRedirect); + + var response = await client.PostAsync(RemovePath, null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 3a453388..95f7b7dd 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -163,11 +163,15 @@ export const routes = { twoFactorAuthentication: () => '/Identity/Account/Manage/TwoFactorAuthentication' as const, unlockAccount: () => '/Identity/Account/UnlockAccount' as const, changePassword: () => '/Identity/Account/Manage/ChangePassword' as const, + confirmPhoneNumber: () => '/Identity/Account/Manage/ConfirmPhoneNumber' as const, deletePersonalData: () => '/Identity/Account/Manage/DeletePersonalData' as const, email: () => '/Identity/Account/Manage/Email' as const, externalLogins: () => '/Identity/Account/Manage/ExternalLogins' as const, manageIndex: () => '/Identity/Account/Manage' as const, personalData: () => '/Identity/Account/Manage/PersonalData' as const, + removePhoneNumber: () => '/Identity/Account/Manage/RemovePhoneNumber' as const, + sendPhoneVerificationCode: () => + '/Identity/Account/Manage/SendPhoneVerificationCode' as const, setPassword: () => '/Identity/Account/Manage/SetPassword' as const, }, }, From 0797e28ca83b8eca8b05dc03c9a3568a5661fd4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:40:24 +0000 Subject: [PATCH 2/2] fix(identity): sync phone input on Inertia re-render + admin reverify test - Re-sync `inputPhone` in ManageIndex via useEffect when the server returns a new phoneNumber (e.g. after Remove). Inertia preserves component state across POST responses, so the useState initializer wasn't picking up the cleared number. - Add integration test asserting POST /admin/users/{id}/force-phone-reverify clears PhoneNumberConfirmed and redirects, while preserving the phone number itself. Addresses Copilot review comments on #198. Resend cooldown and 6-digit code brute-force throttling tracked in #199 across both email and phone flows. --- .../Integration/AdminUsersEndpointTests.cs | 29 +++++++++++++++++++ .../Pages/Account/Manage/ManageIndex.tsx | 8 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs index 96b31068..686f2cc5 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs @@ -210,4 +210,33 @@ public async Task DeactivateUser_Self_ReturnsBadRequest() response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + + [Fact] + public async Task ForcePhoneReverify_ValidUser_ClearsPhoneNumberConfirmedAndRedirects() + { + var userId = await SeedTestUserAsync(); + using (var scope = _factory.Services.CreateScope()) + { + var userManager = scope.ServiceProvider.GetRequiredService< + UserManager + >(); + var user = await userManager.FindByIdAsync(userId); + await userManager.SetPhoneNumberAsync(user!, "+15550009999"); + user!.PhoneNumberConfirmed = true; + await userManager.UpdateAsync(user); + } + + var client = CreateAdminClient(); + var response = await client.PostAsync($"/admin/users/{userId}/force-phone-reverify", null); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + using var scope2 = _factory.Services.CreateScope(); + var userManager2 = scope2.ServiceProvider.GetRequiredService< + UserManager + >(); + var after = await userManager2.FindByIdAsync(userId); + after!.PhoneNumberConfirmed.Should().BeFalse(); + after.PhoneNumber.Should().Be("+15550009999", "phone number itself should be preserved"); + } } diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx index 14cb441a..55cb1a27 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx @@ -12,7 +12,7 @@ import { Input, Label, } from '@simplemodule/ui'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import ManageLayout from '@/components/ManageLayout'; interface Props { @@ -33,6 +33,12 @@ export default function ManageIndex({ const [confirmOpen, setConfirmOpen] = useState(false); const [inputPhone, setInputPhone] = useState(pendingPhoneNumber ?? phoneNumber ?? ''); + // Inertia preserves component state across these POST responses, so re-sync + // the input when the server returns a new phoneNumber (e.g. after Remove). + useEffect(() => { + setInputPhone(pendingPhoneNumber ?? phoneNumber ?? ''); + }, [phoneNumber, pendingPhoneNumber]); + function confirmSignOutEverywhere() { router.post('/Identity/Account/Manage/SignOutEverywhere'); }