✨ Web version login support send factor code
This commit is contained in:
		| @@ -4,7 +4,7 @@ | ||||
|     ViewData["Title"] = "Profile"; | ||||
| } | ||||
|  | ||||
| <div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12"> | ||||
| <div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12"> | ||||
|     <div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-2xl"> | ||||
|         <h1 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">User Profile</h1> | ||||
|  | ||||
|   | ||||
| @@ -1,54 +1,13 @@ | ||||
| @page "/web/auth/challenge/{id:guid}" | ||||
| @model DysonNetwork.Sphere.Pages.Auth.ChallengeModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Challenge"; | ||||
|     // This page is kept for backward compatibility | ||||
|     // It will automatically redirect to the new SelectFactor page | ||||
|     Response.Redirect($"/web/auth/challenge/{Model.Id}/select-factor"); | ||||
| } | ||||
|  | ||||
| <div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
|     <div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md"> | ||||
|         <h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Authentication Challenge</h1> | ||||
|  | ||||
|         @if (Model.AuthChallenge == null) | ||||
|         { | ||||
|             <p class="text-red-500 text-center">Challenge not found or expired.</p> | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             <p class="text-gray-700 dark:text-gray-300 mb-4">Remaining steps: @Model.AuthChallenge.StepRemain</p> | ||||
|  | ||||
|             @if (Model.AuthChallenge.StepRemain > 0) | ||||
|             { | ||||
|                 <form method="post"> | ||||
|                     <input type="hidden" asp-for="Id"/> | ||||
|                     <div class="mb-4"> | ||||
|                         <label asp-for="SelectedFactorId" | ||||
|                                class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select | ||||
|                             Factor:</label> | ||||
|                         <select asp-for="SelectedFactorId" asp-items="Model.AuthFactors" | ||||
|                                 class="form-select mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"></select> | ||||
|                     </div> | ||||
|                     <div class="mb-4"> | ||||
|                         <label asp-for="Secret" | ||||
|                                class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label> | ||||
|                         <input asp-for="Secret" | ||||
|                                class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2" | ||||
|                                type="password"/> | ||||
|                         <span asp-validation-for="Secret" class="text-red-500 text-sm mt-1"></span> | ||||
|                     </div> | ||||
|                     <button type="submit" | ||||
|                             class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"> | ||||
|                         Submit | ||||
|                     </button> | ||||
|                 </form> | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 <p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p> | ||||
|             } | ||||
|         } | ||||
| <div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
|     <div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md text-center"> | ||||
|         <p>Redirecting to authentication page...</p> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| @section Scripts { | ||||
|     @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } | ||||
| } | ||||
| </div> | ||||
| @@ -1,185 +1,16 @@ | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.AspNetCore.Mvc.Rendering; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Sphere.Auth; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Pages.Auth | ||||
| { | ||||
|     public class ChallengeModel( | ||||
|         AppDatabase db, | ||||
|         AccountService accounts, | ||||
|         AuthService auth, | ||||
|         ActionLogService als, | ||||
|         IConfiguration configuration | ||||
|     ) | ||||
|         : PageModel | ||||
|     public class ChallengeModel() : PageModel | ||||
|     { | ||||
|         [BindProperty(SupportsGet = true)] public Guid Id { get; set; } | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid Id { get; set; } | ||||
|  | ||||
|         public Challenge? AuthChallenge { get; set; } | ||||
|  | ||||
|         [BindProperty] public Guid SelectedFactorId { get; set; } | ||||
|  | ||||
|         [BindProperty] [Required] public string Secret { get; set; } = string.Empty; | ||||
|  | ||||
|         public List<SelectListItem> AuthFactors { get; set; } = new(); | ||||
|  | ||||
|         public async Task<IActionResult> OnGetAsync() | ||||
|         public IActionResult OnGet() | ||||
|         { | ||||
|             await LoadChallengeAndFactors(); | ||||
|             if (AuthChallenge == null) return NotFound(); | ||||
|             if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnPostAsync() | ||||
|         { | ||||
|             if (!ModelState.IsValid) | ||||
|             { | ||||
|                 await LoadChallengeAndFactors(); | ||||
|                 return Page(); | ||||
|             } | ||||
|  | ||||
|             var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == Id); | ||||
|             if (challenge is null) return NotFound("Auth challenge was not found."); | ||||
|  | ||||
|             var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId); | ||||
|             if (factor is null) return NotFound("Auth factor was not found."); | ||||
|             if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled."); | ||||
|             if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy."); | ||||
|  | ||||
|             if (challenge.StepRemain == 0) return Page(); // Challenge already completed | ||||
|             if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) | ||||
|             { | ||||
|                 ModelState.AddModelError(string.Empty, "Challenge expired."); | ||||
|                 await LoadChallengeAndFactors(); | ||||
|                 return Page(); | ||||
|             } | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 if (await accounts.VerifyFactorCode(factor, Secret)) | ||||
|                 { | ||||
|                     challenge.StepRemain -= factor.Trustworthy; | ||||
|                     challenge.StepRemain = Math.Max(0, challenge.StepRemain); | ||||
|                     challenge.BlacklistFactors.Add(factor.Id); | ||||
|                     db.Update(challenge); | ||||
|                     als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, | ||||
|                         new Dictionary<string, object> | ||||
|                         { | ||||
|                             { "challenge_id", challenge.Id }, | ||||
|                             { "factor_id", factor.Id } | ||||
|                         }, Request, challenge.Account | ||||
|                     ); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     throw new ArgumentException("Invalid password."); | ||||
|                 } | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 challenge.FailedAttempts++; | ||||
|                 db.Update(challenge); | ||||
|                 await db.SaveChangesAsync(); | ||||
|                 als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, | ||||
|                     new Dictionary<string, object> | ||||
|                     { | ||||
|                         { "challenge_id", challenge.Id }, | ||||
|                         { "factor_id", factor.Id } | ||||
|                     }, Request, challenge.Account | ||||
|                 ); | ||||
|                 ModelState.AddModelError(string.Empty, ex.Message); | ||||
|                 await LoadChallengeAndFactors(); | ||||
|                 return Page(); | ||||
|             } | ||||
|  | ||||
|             if (challenge.StepRemain == 0) | ||||
|             { | ||||
|                 als.CreateActionLogFromRequest(ActionLogType.NewLogin, | ||||
|                     new Dictionary<string, object> | ||||
|                     { | ||||
|                         { "challenge_id", challenge.Id }, | ||||
|                         { "account_id", challenge.AccountId } | ||||
|                     }, Request, challenge.Account | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             await db.SaveChangesAsync(); | ||||
|             AuthChallenge = challenge; | ||||
|  | ||||
|             if (AuthChallenge.StepRemain == 0) | ||||
|             { | ||||
|                 return await ExchangeTokenAndRedirect(); | ||||
|             } | ||||
|  | ||||
|             await LoadChallengeAndFactors(); | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
|         private async Task LoadChallengeAndFactors() | ||||
|         { | ||||
|             var challenge = await db.AuthChallenges | ||||
|                 .Include(e => e.Account) | ||||
|                 .ThenInclude(e => e.AuthFactors) | ||||
|                 .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|             AuthChallenge = challenge; | ||||
|  | ||||
|             if (AuthChallenge != null) | ||||
|             { | ||||
|                 var factorsResponse = AuthChallenge.Account.AuthFactors | ||||
|                     .Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }) | ||||
|                     .ToList(); | ||||
|  | ||||
|                 AuthFactors = factorsResponse.Select(f => new SelectListItem | ||||
|                 { | ||||
|                     Value = f.Id.ToString(), | ||||
|                     Text = f.Type.ToString() // You might want a more user-friendly display for factor types | ||||
|                 }).ToList(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<IActionResult> ExchangeTokenAndRedirect() | ||||
|         { | ||||
|             var challenge = await db.AuthChallenges | ||||
|                 .Include(e => e.Account) | ||||
|                 .Where(e => e.Id == Id) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             if (challenge is null) return BadRequest("Authorization code not found or expired."); | ||||
|             if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); | ||||
|  | ||||
|             var session = await db.AuthSessions | ||||
|                 .Where(e => e.Challenge == challenge) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             if (session is not null) return BadRequest("Session already exists for this challenge."); | ||||
|  | ||||
|             session = new Session | ||||
|             { | ||||
|                 LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), | ||||
|                 ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), | ||||
|                 Account = challenge.Account, | ||||
|                 Challenge = challenge, | ||||
|             }; | ||||
|  | ||||
|             db.AuthSessions.Add(session); | ||||
|             await db.SaveChangesAsync(); | ||||
|  | ||||
|             var tk = auth.CreateToken(session); | ||||
|             HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions() | ||||
|             { | ||||
|                 HttpOnly = true, | ||||
|                 Secure = !configuration.GetValue<bool>("Debug"), | ||||
|                 SameSite = SameSiteMode.Strict, | ||||
|                 Path = "/" | ||||
|             }); | ||||
|             return RedirectToPage("/Account/Profile"); // Redirect to profile page | ||||
|             return RedirectToPage("SelectFactor", new { id = Id }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -4,7 +4,7 @@ | ||||
|     ViewData["Title"] = "Login"; | ||||
| } | ||||
|  | ||||
| <div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
| <div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
|     <div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md"> | ||||
|         <h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1> | ||||
|  | ||||
|   | ||||
							
								
								
									
										65
									
								
								DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| @page "/web/auth/challenge/{id:guid}/select-factor" | ||||
| @using DysonNetwork.Sphere.Account | ||||
| @model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Select Authentication Method"; | ||||
| } | ||||
|  | ||||
| <div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
|     <div class="bg-white dark:bg-gray-800 px-8 rounded-lg shadow-md w-full max-w-md"> | ||||
|         <h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Select Authentication Method</h1> | ||||
|  | ||||
|         @if (Model.AuthChallenge == null) | ||||
|         { | ||||
|             <p class="text-red-500 text-center">Challenge not found or expired.</p> | ||||
|         } | ||||
|         else if (Model.AuthChallenge.StepRemain == 0) | ||||
|         { | ||||
|             <p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p> | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             <p class="text-gray-700 dark:text-gray-300 mb-4">Please select an authentication method:</p> | ||||
|  | ||||
|             <div class="space-y-4"> | ||||
|                 @foreach (var factor in Model.AuthFactors) | ||||
|                 { | ||||
|                     <form method="post" asp-page-handler="SelectFactor" class="w-full"> | ||||
|                         <input type="hidden" name="factorId" value="@factor.Id"/> | ||||
|                         <button type="submit" | ||||
|                                 class="w-full text-left p-4 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"> | ||||
|                             <div | ||||
|                                 class="font-medium text-gray-900 dark:text-white">@GetFactorDisplayName(factor.Type)</div> | ||||
|                             <div | ||||
|                                 class="text-sm text-gray-500 dark:text-gray-400">@GetFactorDescription(factor.Type)</div> | ||||
|                         </button> | ||||
|                     </form> | ||||
|                 } | ||||
|             </div> | ||||
|         } | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| @functions { | ||||
|  | ||||
|     private string GetFactorDisplayName(AccountAuthFactorType type) => type switch | ||||
|     { | ||||
|         AccountAuthFactorType.InAppCode => "Authenticator App", | ||||
|         AccountAuthFactorType.EmailCode => "Email", | ||||
|         AccountAuthFactorType.TimedCode => "Timed Code", | ||||
|         AccountAuthFactorType.PinCode => "PIN Code", | ||||
|         AccountAuthFactorType.Password => "Password", | ||||
|         _ => type.ToString() | ||||
|     }; | ||||
|  | ||||
|     private string GetFactorDescription(AccountAuthFactorType type) => type switch | ||||
|     { | ||||
|         AccountAuthFactorType.InAppCode => "Enter a code from your authenticator app", | ||||
|         AccountAuthFactorType.EmailCode => "Receive a verification code via email", | ||||
|         AccountAuthFactorType.TimedCode => "Use a time-based verification code", | ||||
|         AccountAuthFactorType.PinCode => "Enter your PIN code", | ||||
|         AccountAuthFactorType.Password => "Enter your password", | ||||
|         _ => string.Empty | ||||
|     }; | ||||
|  | ||||
| } | ||||
							
								
								
									
										78
									
								
								DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using DysonNetwork.Sphere.Auth; | ||||
| using DysonNetwork.Sphere.Account; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Pages.Auth; | ||||
|  | ||||
| public class SelectFactorModel( | ||||
|     AppDatabase db, | ||||
|     AccountService accounts | ||||
| ) | ||||
|     : PageModel | ||||
| { | ||||
|     [BindProperty(SupportsGet = true)] public Guid Id { get; set; } | ||||
|  | ||||
|     public Challenge? AuthChallenge { get; set; } | ||||
|     public List<AccountAuthFactor> AuthFactors { get; set; } = []; | ||||
|  | ||||
|     public async Task<IActionResult> OnGetAsync() | ||||
|     { | ||||
|         await LoadChallengeAndFactors(); | ||||
|         if (AuthChallenge == null) return NotFound(); | ||||
|         if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); | ||||
|         return Page(); | ||||
|     } | ||||
|  | ||||
|     public async Task<IActionResult> OnPostSelectFactorAsync(Guid factorId) | ||||
|     { | ||||
|         var challenge = await db.AuthChallenges | ||||
|             .Include(e => e.Account) | ||||
|             .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|         if (challenge == null) return NotFound(); | ||||
|  | ||||
|         var factor = await db.AccountAuthFactors.FindAsync(factorId); | ||||
|         if (factor?.EnabledAt == null || factor.Trustworthy <= 0) | ||||
|             return BadRequest("Invalid authentication method."); | ||||
|  | ||||
|         // For OTP factors that require code delivery | ||||
|         try | ||||
|         { | ||||
|             await accounts.SendFactorCode(challenge.Account, factor); | ||||
|         } | ||||
|         catch (Exception) | ||||
|         { | ||||
|             ModelState.AddModelError(string.Empty, "An error occurred while sending the verification code."); | ||||
|             await LoadChallengeAndFactors(); | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
|         // Redirect to verify the page with the selected factor | ||||
|         return RedirectToPage("VerifyFactor", new { id = Id, factorId }); | ||||
|     } | ||||
|  | ||||
|     private async Task LoadChallengeAndFactors() | ||||
|     { | ||||
|         AuthChallenge = await db.AuthChallenges | ||||
|             .Include(e => e.Account) | ||||
|             .ThenInclude(e => e.AuthFactors) | ||||
|             .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|         if (AuthChallenge != null) | ||||
|         { | ||||
|             AuthFactors = AuthChallenge.Account.AuthFactors | ||||
|                 .Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }) | ||||
|                 .ToList(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<IActionResult> ExchangeTokenAndRedirect() | ||||
|     { | ||||
|         // This method is kept for backward compatibility | ||||
|         // The actual token exchange is now handled in the VerifyFactor page | ||||
|         await Task.CompletedTask; // Add this to fix the async warning | ||||
|         return RedirectToPage("/Account/Profile"); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										76
									
								
								DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| @page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}" | ||||
| @using DysonNetwork.Sphere.Account | ||||
| @model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Verify Your Identity"; | ||||
| } | ||||
|  | ||||
| <div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
|     <div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md"> | ||||
|         <h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">Verify Your Identity</h1> | ||||
|         <p class="text-center text-gray-600 dark:text-gray-300 mb-6"> | ||||
|             @switch (Model.FactorType) | ||||
|             { | ||||
|                 case AccountAuthFactorType.EmailCode: | ||||
|                     <span>We've sent a verification code to your email.</span> | ||||
|                     break; | ||||
|                 case AccountAuthFactorType.InAppCode: | ||||
|                     <span>Enter the code from your authenticator app.</span> | ||||
|                     break; | ||||
|                 case AccountAuthFactorType.TimedCode: | ||||
|                     <span>Enter your time-based verification code.</span> | ||||
|                     break; | ||||
|                 case AccountAuthFactorType.PinCode: | ||||
|                     <span>Enter your PIN code.</span> | ||||
|                     break; | ||||
|                 case AccountAuthFactorType.Password: | ||||
|                     <span>Enter your password.</span> | ||||
|                     break; | ||||
|                 default: | ||||
|                     <span>Please verify your identity.</span> | ||||
|                     break; | ||||
|             } | ||||
|         </p> | ||||
|  | ||||
|         @if (Model.AuthChallenge == null) | ||||
|         { | ||||
|             <p class="text-red-500 text-center">Challenge not found or expired.</p> | ||||
|         } | ||||
|         else if (Model.AuthChallenge.StepRemain == 0) | ||||
|         { | ||||
|             <p class="text-green-600 dark:text-green-400 text-center">Verification successful. Redirecting...</p> | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             <form method="post" class="space-y-4"> | ||||
|                 <div asp-validation-summary="ModelOnly" class="text-red-500 text-sm"></div> | ||||
|                  | ||||
|                 <div class="mb-4"> | ||||
|                     <label asp-for="Code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> | ||||
|                         @(Model.FactorType == AccountAuthFactorType.Password ? "Use your password" : "Verification Code") | ||||
|                     </label> | ||||
|                     <input asp-for="Code" | ||||
|                            class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2" | ||||
|                            autocomplete="one-time-code" | ||||
|                            autofocus /> | ||||
|                     <span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span> | ||||
|                 </div> | ||||
|  | ||||
|                 <button type="submit" | ||||
|                         class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"> | ||||
|                     Verify | ||||
|                 </button> | ||||
|  | ||||
|                 <div class="text-center mt-4"> | ||||
|                     <a asp-page="SelectFactor" asp-route-id="@Model.Id" class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"> | ||||
|                         ← Back to authentication methods | ||||
|                     </a> | ||||
|                 </div> | ||||
|             </form> | ||||
|         } | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| @section Scripts { | ||||
|     @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } | ||||
| } | ||||
							
								
								
									
										187
									
								
								DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,187 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using DysonNetwork.Sphere.Auth; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Pages.Auth | ||||
| { | ||||
|     public class VerifyFactorModel : PageModel | ||||
|     { | ||||
|         private readonly AppDatabase _db; | ||||
|         private readonly AccountService _accounts; | ||||
|         private readonly AuthService _auth; | ||||
|         private readonly ActionLogService _als; | ||||
|         private readonly IConfiguration _configuration; | ||||
|         private readonly IHttpClientFactory _httpClientFactory; | ||||
|  | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid Id { get; set; } | ||||
|  | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid FactorId { get; set; } | ||||
|  | ||||
|         [BindProperty, Required] | ||||
|         public string Code { get; set; } = string.Empty; | ||||
|  | ||||
|         public Challenge? AuthChallenge { get; set; } | ||||
|         public AccountAuthFactor? Factor { get; set; } | ||||
|         public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode; | ||||
|  | ||||
|         public VerifyFactorModel( | ||||
|             AppDatabase db, | ||||
|             AccountService accounts, | ||||
|             AuthService auth, | ||||
|             ActionLogService als, | ||||
|             IConfiguration configuration, | ||||
|             IHttpClientFactory httpClientFactory) | ||||
|         { | ||||
|             _db = db; | ||||
|             _accounts = accounts; | ||||
|             _auth = auth; | ||||
|             _als = als; | ||||
|             _configuration = configuration; | ||||
|             _httpClientFactory = httpClientFactory; | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnGetAsync() | ||||
|         { | ||||
|             await LoadChallengeAndFactor(); | ||||
|             if (AuthChallenge == null) return NotFound("Challenge not found or expired."); | ||||
|             if (Factor == null) return NotFound("Authentication method not found."); | ||||
|             if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); | ||||
|              | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnPostAsync() | ||||
|         { | ||||
|             if (!ModelState.IsValid) | ||||
|             { | ||||
|                 await LoadChallengeAndFactor(); | ||||
|                 return Page(); | ||||
|             } | ||||
|  | ||||
|             await LoadChallengeAndFactor(); | ||||
|             if (AuthChallenge == null) return NotFound("Challenge not found or expired."); | ||||
|             if (Factor == null) return NotFound("Authentication method not found."); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 if (await _accounts.VerifyFactorCode(Factor, Code)) | ||||
|                 { | ||||
|                     AuthChallenge.StepRemain -= Factor.Trustworthy; | ||||
|                     AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain); | ||||
|                     AuthChallenge.BlacklistFactors.Add(Factor.Id); | ||||
|                     _db.Update(AuthChallenge); | ||||
|  | ||||
|                     _als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, | ||||
|                         new Dictionary<string, object> | ||||
|                         { | ||||
|                             { "challenge_id", AuthChallenge.Id }, | ||||
|                             { "factor_id", Factor?.Id.ToString() ?? string.Empty } | ||||
|                         }, Request, AuthChallenge.Account); | ||||
|  | ||||
|                     await _db.SaveChangesAsync(); | ||||
|  | ||||
|                     if (AuthChallenge.StepRemain == 0) | ||||
|                     { | ||||
|                         _als.CreateActionLogFromRequest(ActionLogType.NewLogin, | ||||
|                             new Dictionary<string, object> | ||||
|                             { | ||||
|                                 { "challenge_id", AuthChallenge.Id }, | ||||
|                                 { "account_id", AuthChallenge.AccountId } | ||||
|                             }, Request, AuthChallenge.Account); | ||||
|  | ||||
|                         return await ExchangeTokenAndRedirect(); | ||||
|                     } | ||||
|  | ||||
|                     else | ||||
|                     { | ||||
|                         // If more steps are needed, redirect back to select factor | ||||
|                         return RedirectToPage("SelectFactor", new { id = Id }); | ||||
|                     } | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     throw new InvalidOperationException("Invalid verification code."); | ||||
|                 } | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 if (AuthChallenge != null) | ||||
|                 { | ||||
|                     AuthChallenge.FailedAttempts++; | ||||
|                     _db.Update(AuthChallenge); | ||||
|                     await _db.SaveChangesAsync(); | ||||
|  | ||||
|                     _als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, | ||||
|                         new Dictionary<string, object> | ||||
|                         { | ||||
|                             { "challenge_id", AuthChallenge.Id }, | ||||
|                             { "factor_id", Factor?.Id.ToString() ?? string.Empty } | ||||
|                         }, Request, AuthChallenge.Account); | ||||
|                 } | ||||
|  | ||||
|  | ||||
|                 ModelState.AddModelError(string.Empty, ex.Message); | ||||
|                 return Page(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task LoadChallengeAndFactor() | ||||
|         { | ||||
|             AuthChallenge = await _db.AuthChallenges | ||||
|                 .Include(e => e.Account) | ||||
|                 .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|             if (AuthChallenge?.Account != null) | ||||
|             { | ||||
|                 Factor = await _db.AccountAuthFactors | ||||
|                     .FirstOrDefaultAsync(e => e.Id == FactorId &&  | ||||
|                                            e.AccountId == AuthChallenge.Account.Id && | ||||
|                                            e.EnabledAt != null &&  | ||||
|                                            e.Trustworthy > 0); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<IActionResult> ExchangeTokenAndRedirect() | ||||
|         { | ||||
|             var challenge = await _db.AuthChallenges | ||||
|                 .Include(e => e.Account) | ||||
|                 .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|             if (challenge == null) return BadRequest("Authorization code not found or expired."); | ||||
|             if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); | ||||
|  | ||||
|             var session = await _db.AuthSessions | ||||
|                 .FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id); | ||||
|  | ||||
|             if (session != null) return BadRequest("Session already exists for this challenge."); | ||||
|  | ||||
|             session = new Session | ||||
|             { | ||||
|                 LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), | ||||
|                 ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), | ||||
|                 Account = challenge.Account, | ||||
|                 Challenge = challenge, | ||||
|             }; | ||||
|  | ||||
|             _db.AuthSessions.Add(session); | ||||
|             await _db.SaveChangesAsync(); | ||||
|  | ||||
|             var token = _auth.CreateToken(session); | ||||
|             Response.Cookies.Append(AuthConstants.CookieTokenName, token, new() | ||||
|             { | ||||
|                 HttpOnly = true, | ||||
|                 Secure = !_configuration.GetValue<bool>("Debug"), | ||||
|                 SameSite = SameSiteMode.Strict, | ||||
|                 Path = "/" | ||||
|             }); | ||||
|  | ||||
|             return RedirectToPage("/Account/Profile"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user