✨ Support OIDC
This commit is contained in:
		
							
								
								
									
										127
									
								
								DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| @page "/auth/authorize" | ||||
| @model DysonNetwork.Sphere.Pages.Auth.AuthorizeModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Authorize Application"; | ||||
| } | ||||
|  | ||||
| <div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 transition-colors duration-200 py-12 px-4 sm:px-6 lg:px-8"> | ||||
|     <div class="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 transition-colors duration-200"> | ||||
|         <div class="text-center"> | ||||
|             <h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white"> | ||||
|                 Authorize Application | ||||
|             </h2> | ||||
|             @if (!string.IsNullOrEmpty(Model.AppName)) | ||||
|             { | ||||
|                 <div class="mt-6"> | ||||
|                     <div class="flex items-center justify-center"> | ||||
|                         @if (!string.IsNullOrEmpty(Model.AppLogo)) | ||||
|                         { | ||||
|                             <div class="relative h-16 w-16 flex-shrink-0"> | ||||
|                                 <img class="h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700"  | ||||
|                                      src="@Model.AppLogo"  | ||||
|                                      alt="@Model.AppName logo"  | ||||
|                                      onerror="this.onerror=null; this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzczz0idy02IGgtNiB0ZXh0LWdyYXktNDAwIj48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOS42MDMgMy4wMDRjMS4xNTMgMCAyLjI2LjE4IDMuMzI5LjUxM2EuNzUuNzUgMCAwMS0uMzI4IDEuNDQ1IDE2LjkzIDE2LjkzIDAgMDAtNi4wMS0xuMTAzLjc1Ljc1IDAgMDEtLjY2OC0uNzQ4VjMuNzVBLjc1Ljc1IDAgMDExNi41IDNoMy4xMDN6TTMxLjUgMjEuMDA4Yy0uU0UuNzUgNzUuNzUgMCAwMTEuOTQ4LjA1MWMuMDgxLjkxOC4wNTIgMS44MzgtLjA4NiAyLjczNGEuNzUuNzUgMCAwMS0xLjQ5LS4xNTkgMjEuMzg2IDIxLjM4NiAwIDAwLjA2LTIuNTc1Ljc1Ljc1IDAgMDExLjU3OC0uMDQzem0tMy4wMi0xOC4wMmMuMDYgMS4wOTIuMDQgMi4xODctLjA1NiAzLjI3MmEuNzUuNzUgMCAwMS0xLjQ5Mi0uMTY0Yy4wOTItLjg3NC4xMDctMS43NTYuMDUxLTIuNjA3YS43NS43NSAwIDAxMS40OTctLjEwMXpNNS42MzcgNS42MzJjLS4zMzQuMS0uNjc2LjE4NS0xLjAyNi4yNTdhLjc1Ljc1IDAgMDEuMTQ5LTEuNDljLjQyNS0uMDg1Ljg1Mi0uMTg5IDEuMjY4LS4zMDRhLjc1Ljc1IDAgMDEuMzYgMS40Mzd6TTMuMzMgMTkuNjUzYy0uMTY1LS4zNS0uMzA4LS43MDctNDIuNjUzLjc1Ljc1IDAgMS4zODgtLjU0MiAxLjQ5LTEuMjg1LjA0Ni0uMzI2LjEwNi0uNjUyLjE4LS45NzZhLjc1Ljc1IDAgMTExLjQ2LS41M2MuMTA2LjQzNy4xODkuODgxLjI0NSAxLjM0OGEuNzUuNzUgMCAwMS0xLjQ5LjIzM3pNTEuMzUzIDIuNzY3YS43NS43NSAwIDAxLjc1LS4wMTdsLjAwMS4wMDFoLjAwMWwuMDAyLjAwMWwuMDA3LjAwM2wuMDI0LjAxM2MuMDIuMDEuMDQ1LjAyNS4wNzkuMDQ2LjA2Ny4wNDIuMTYxLjEwMi4yNzUuMTc4LjIzMi4xNTEuNTUuMzgzLjg1Ni42NjdsLjAyNy4wMjRjLjYxNi41NTggMS4yMTIgMS4xNzYgMS43MzMgMS44NDNhLjc1Ljc1IDAgMDEtMS4yNC44N2MtLjQ3LS42NzEtMS4wMzEtMS4yOTItMS42LFsxLjYxNi0xLjYxNmEzLjc1IDMuNzUgMCAwMC01LjMwNS01LjMwNGwtMS4wNi0xuMDZBNy4yNDMgNy4yNDMgMCAwMTUxLjM1MyAyLjc2N3pNNDQuMzc5IDkuNjRsLTEuNTYgMS41NmE2Ljk5IDYuOTkgMCAwMTIuMjMgNC4zMzcgNi45OSA2Ljk5IDAgMDEtMi4yMyA1LjE3NmwtMS41Ni0xLjU2QTguNDkgOC40OSAwIDAwNDUuNSAxNS41YzAtMi4yOTYtLjg3NC00LjQzLTIuMTIxLTYuMDF6bS0zLjUzLTMuNTNsLTEuMDYxLTEuMDZhNy4yNDMgNy4yNDMgMCAwMTkuMTkyIDkuE2x0LTEuMDYgMS4wNjFhNS43NDkgNS43NDkgMCAwMC04LjEzLTguMTN6TTM0LjUgMTUuNWE4Ljk5IDguOTkgMCAwMC0yLjYzMSA2LjM2OS43NS43NSAwIDExLTEuNDk0LS43MzlDNzIuMzkgMjAuMDYgMzMuNSAxNy41NzUgMzMuNSAxNS41YzAtMi4zNzYgMS4wOTktNC40MzggMi44MTEtNS44MTJsLS4zOTYtLjM5NmEuNzUuNzUgMCAwMTEuMDYtMS4wNkwzNy41IDkuNDRWMmgtL4wMDJhLjc1Ljc1IDAgMDEtLjc0OC0uNzVWMS41YS43NS43NSAwIDAxLjc1LS43NWg2YS43NS43NSAwIDAxLjc1Ljc1di4yNWEwIC43NS0uNzUuNzVoLS4wMDF2Ny40NGwzLjUzNy0zLjUzN2EuNzUuNzUgMCAwMTEuMDYgMS4wNmwtLjM5Ni4zOTZDMzUuNDAxIDExLjA2MiAzNC41IDEzLjEyNCAzNC41IDE1LjV6TTM5IDIuMjV2Ni4wMy0uMDAyYTEuNSAxLjUgMCAwMS0uNDQ0IDEuMDZsLTEuMDYxIDEuMDZBOC40OSA4LjQ5IDAgMDAzOSAxNS41YzAgMi4yOTYtLjg3NCA0LjQzLTIuMTIxIDYuMDFsMS41NiAxLjU2QTYuOTkgNi45OSAwIDAwNDIgMTUuNWE2Ljk5IDYuOTkgMCAwMC0yLjIzLTUuMTc2bC0xLjU2LTEuNTZhMS41IDEuNSAwIDAxLS40NC0xLjA2VjIuMjVoLTF6TTI0IDkuNzVhLjc1Ljc1IDAgMDEtLjc1Ljc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTkuNSAxM2EuNzUuNzUgMCAwMS0uNzUtLjc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTUgMTYuMjVhLjc1Ljc1IDAgMDEuNzUtLjc1aC41Yy40MTQgMCAuNzUuMzM2Ljc1Ljc1di41YS43NS43NSAwIDAxLS43NS43NWgtLjVhLjc1Ljc1IDAgMDEtLjc1LS43NXYtLjV6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=';"> | ||||
|                                 <div class="absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"> | ||||
|                                     <span class="text-xs font-medium text-gray-500 dark:text-gray-300">@Model.AppName?[0]</span> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         } | ||||
|                         <div class="ml-4 text-left"> | ||||
|                             <h3 class="text-lg font-medium text-gray-900 dark:text-white">@Model.AppName</h3> | ||||
|                             @if (!string.IsNullOrEmpty(Model.AppUri)) | ||||
|                             { | ||||
|                                 <a href="@Model.AppUri" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors duration-200" target="_blank" rel="noopener noreferrer"> | ||||
|                                     @Model.AppUri | ||||
|                                 </a> | ||||
|                             } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             } | ||||
|             <p class="mt-6 text-sm text-gray-600 dark:text-gray-300"> | ||||
|                 wants to access your account with the following permissions: | ||||
|             </p> | ||||
|         </div> | ||||
|  | ||||
|         <div class="mt-6"> | ||||
|             <ul class="border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-200 dark:divide-gray-700 overflow-hidden"> | ||||
|                 @if (Model.Scope != null) | ||||
|                 { | ||||
|                     var scopeDescriptions = new Dictionary<string, (string Name, string Description)> | ||||
|                     { | ||||
|                         ["openid"] = ("OpenID", "Read your basic profile information"), | ||||
|                         ["profile"] = ("Profile", "View your basic profile information"), | ||||
|                         ["email"] = ("Email", "View your email address"), | ||||
|                         ["offline_access"] = ("Offline Access", "Access your data while you're not using the application") | ||||
|                     }; | ||||
|  | ||||
|                     foreach (var scope in Model.Scope.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s))) | ||||
|                     { | ||||
|                         var scopeInfo = scopeDescriptions.GetValueOrDefault(scope, (scope, scope.Replace('_', ' '))); | ||||
|                         <li class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"> | ||||
|                             <div class="flex items-start"> | ||||
|                                 <div class="flex-shrink-0 pt-0.5"> | ||||
|                                     <svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> | ||||
|                                         <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /> | ||||
|                                     </svg> | ||||
|                                 </div> | ||||
|                                 <div class="ml-3"> | ||||
|                                     <p class="text-sm font-medium text-gray-900 dark:text-white">@scopeInfo.Item1</p> | ||||
|                                     <p class="text-xs text-gray-500 dark:text-gray-400">@scopeInfo.Item2</p> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </li> | ||||
|                     } | ||||
|                 } | ||||
|             </ul> | ||||
|              | ||||
|             <div class="mt-4 text-xs text-gray-500 dark:text-gray-400"> | ||||
|                 <p>By authorizing, you allow this application to access your information on your behalf.</p> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <form method="post" class="mt-8 space-y-4"> | ||||
|             <input type="hidden" asp-for="ClientIdString" /> | ||||
|             <input type="hidden" asp-for="ResponseType" name="response_type" /> | ||||
|             <input type="hidden" asp-for="RedirectUri" name="redirect_uri" /> | ||||
|             <input type="hidden" asp-for="Scope" name="scope" /> | ||||
|             <input type="hidden" asp-for="State" name="state" /> | ||||
|             <input type="hidden" asp-for="Nonce" name="nonce" /> | ||||
|             <input type="hidden" asp-for="ReturnUrl" name="returnUrl" /> | ||||
|             <input type="hidden" name="code_challenge" value="@HttpContext.Request.Query["code_challenge"]" /> | ||||
|             <input type="hidden" name="code_challenge_method" value="@HttpContext.Request.Query["code_challenge_method"]" /> | ||||
|             <input type="hidden" name="response_mode" value="@HttpContext.Request.Query["response_mode"]" /> | ||||
|              | ||||
|             <div class="flex flex-col space-y-3"> | ||||
|                 <button type="submit" name="allow" value="true"  | ||||
|                         class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200"> | ||||
|                     Allow | ||||
|                 </button> | ||||
|                 <button type="submit" name="allow" value="false"  | ||||
|                         class="w-full flex justify-center py-2.5 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200"> | ||||
|                     Deny | ||||
|                 </button> | ||||
|             </div> | ||||
|              | ||||
|             <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> | ||||
|                 <p class="text-xs text-center text-gray-500 dark:text-gray-400"> | ||||
|                     You can change these permissions later in your account settings. | ||||
|                 </p> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| @functions { | ||||
|     private string GetScopeDisplayName(string scope) | ||||
|     { | ||||
|         return scope switch | ||||
|         { | ||||
|             "openid" => "View your basic profile information", | ||||
|             "profile" => "View your profile information (name, picture, etc.)", | ||||
|             "email" => "View your email address", | ||||
|             "offline_access" => "Access your information while you're not using the app", | ||||
|             _ => scope | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										148
									
								
								DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Pages.Auth; | ||||
|  | ||||
| [Authorize] | ||||
| public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
| { | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? ReturnUrl { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true, Name = "client_id")] | ||||
|     [Required(ErrorMessage = "The client_id parameter is required")] | ||||
|     public string? ClientIdString { get; set; } | ||||
|      | ||||
|     public Guid ClientId { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true, Name = "response_type")] | ||||
|     public string ResponseType { get; set; } = "code"; | ||||
|  | ||||
|     [BindProperty(SupportsGet = true, Name = "redirect_uri")] | ||||
|     public string? RedirectUri { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? State { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? Nonce { get; set; } | ||||
|  | ||||
|      | ||||
|     [BindProperty(SupportsGet = true, Name = "code_challenge")] | ||||
|     public string? CodeChallenge { get; set; } | ||||
|      | ||||
|     [BindProperty(SupportsGet = true, Name = "code_challenge_method")] | ||||
|     public string? CodeChallengeMethod { get; set; } | ||||
|      | ||||
|     [BindProperty(SupportsGet = true, Name = "response_mode")] | ||||
|     public string? ResponseMode { get; set; } | ||||
|  | ||||
|     public string? AppName { get; set; } | ||||
|     public string? AppLogo { get; set; } | ||||
|     public string? AppUri { get; set; } | ||||
|     public string[]? RequestedScopes { get; set; } | ||||
|  | ||||
|     public async Task<IActionResult> OnGetAsync() | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId)) | ||||
|         { | ||||
|             ModelState.AddModelError("client_id", "Invalid client_id format"); | ||||
|             return BadRequest("Invalid client_id format"); | ||||
|         } | ||||
|          | ||||
|         ClientId = clientId; | ||||
|  | ||||
|         var client = await oidcService.FindClientByIdAsync(ClientId); | ||||
|         if (client == null) | ||||
|         { | ||||
|             ModelState.AddModelError("client_id", "Client not found"); | ||||
|             return NotFound("Client not found"); | ||||
|         } | ||||
|  | ||||
|         AppName = client.Name; | ||||
|         AppLogo = client.LogoUri; | ||||
|         AppUri = client.ClientUri; | ||||
|         RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray(); | ||||
|  | ||||
|         return Page(); | ||||
|     } | ||||
|  | ||||
|     public async Task<IActionResult> OnPostAsync(bool allow) | ||||
|     { | ||||
|         // First validate the client ID | ||||
|         if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId)) | ||||
|         { | ||||
|             ModelState.AddModelError("client_id", "Invalid client_id format"); | ||||
|             return BadRequest("Invalid client_id format"); | ||||
|         } | ||||
|          | ||||
|         ClientId = clientId; | ||||
|  | ||||
|         // Check if client exists | ||||
|         var client = await oidcService.FindClientByIdAsync(ClientId); | ||||
|         if (client == null) | ||||
|         { | ||||
|             ModelState.AddModelError("client_id", "Client not found"); | ||||
|             return NotFound("Client not found"); | ||||
|         } | ||||
|  | ||||
|         if (!allow) | ||||
|         { | ||||
|             // User denied the authorization request | ||||
|             if (string.IsNullOrEmpty(RedirectUri)) | ||||
|                 return BadRequest("No redirect_uri provided"); | ||||
|                  | ||||
|             var deniedUriBuilder = new UriBuilder(RedirectUri); | ||||
|             var deniedQuery = System.Web.HttpUtility.ParseQueryString(deniedUriBuilder.Query); | ||||
|             deniedQuery["error"] = "access_denied"; | ||||
|             deniedQuery["error_description"] = "The user denied the authorization request"; | ||||
|             if (!string.IsNullOrEmpty(State)) deniedQuery["state"] = State; | ||||
|             deniedUriBuilder.Query = deniedQuery.ToString(); | ||||
|              | ||||
|             return Redirect(deniedUriBuilder.ToString()); | ||||
|         } | ||||
|  | ||||
|         // User approved the request | ||||
|         if (string.IsNullOrEmpty(RedirectUri)) | ||||
|         { | ||||
|             ModelState.AddModelError("redirect_uri", "No redirect_uri provided"); | ||||
|             return BadRequest("No redirect_uri provided"); | ||||
|         } | ||||
|  | ||||
|         // Generate authorization code | ||||
|         var authCode = await oidcService.GenerateAuthorizationCodeAsync( | ||||
|             clientId: ClientId, | ||||
|             userId: User.Identity?.Name ?? string.Empty, | ||||
|             redirectUri: RedirectUri, | ||||
|             scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(), | ||||
|             codeChallenge: CodeChallenge, | ||||
|             codeChallengeMethod: CodeChallengeMethod, | ||||
|             nonce: Nonce); | ||||
|  | ||||
|         // Build the redirect URI with the authorization code | ||||
|         var redirectUri = new UriBuilder(RedirectUri); | ||||
|         var query = System.Web.HttpUtility.ParseQueryString(redirectUri.Query); | ||||
|          | ||||
|         // Add the authorization code | ||||
|         query["code"] = authCode; | ||||
|          | ||||
|         // Add state if provided (for CSRF protection) | ||||
|         if (!string.IsNullOrEmpty(State)) | ||||
|         { | ||||
|             query["state"] = State; | ||||
|         } | ||||
|          | ||||
|         // Set the query string | ||||
|         redirectUri.Query = query.ToString(); | ||||
|  | ||||
|         // Redirect back to the client with the authorization code | ||||
|         return Redirect(redirectUri.ToString()); | ||||
|     } | ||||
| } | ||||
| @@ -7,10 +7,13 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|     { | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid Id { get; set; } | ||||
|          | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public string? ReturnUrl { get; set; } | ||||
|  | ||||
|         public IActionResult OnGet() | ||||
|         { | ||||
|             return RedirectToPage("SelectFactor", new { id = Id }); | ||||
|             return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2,6 +2,7 @@ | ||||
| @model DysonNetwork.Sphere.Pages.Auth.LoginModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Login"; | ||||
|     var returnUrl = Model.ReturnUrl ?? ""; | ||||
| } | ||||
|  | ||||
| <div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
| @@ -9,6 +10,7 @@ | ||||
|         <h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1> | ||||
|  | ||||
|         <form method="post"> | ||||
|             <input type="hidden" asp-for="ReturnUrl" value="@returnUrl" /> | ||||
|             <div class="mb-4"> | ||||
|                 <label asp-for="Username" | ||||
|                        class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label> | ||||
|   | ||||
| @@ -18,6 +18,10 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|     ) : PageModel | ||||
|     { | ||||
|         [BindProperty] [Required] public string Username { get; set; } = string.Empty; | ||||
|          | ||||
|         [BindProperty] | ||||
|         [FromQuery] | ||||
|         public string? ReturnUrl { get; set; } | ||||
|  | ||||
|         public void OnGet() | ||||
|         { | ||||
| @@ -36,6 +40,12 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|                 ModelState.AddModelError(string.Empty, "Account was not found."); | ||||
|                 return Page(); | ||||
|             } | ||||
|              | ||||
|             // Store the return URL in TempData to preserve it during the login flow | ||||
|             if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl)) | ||||
|             { | ||||
|                 TempData["ReturnUrl"] = ReturnUrl; | ||||
|             } | ||||
|  | ||||
|             var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|             var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); | ||||
| @@ -71,11 +81,13 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|             await db.AuthChallenges.AddAsync(challenge); | ||||
|             await db.SaveChangesAsync(); | ||||
|  | ||||
|             als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt, | ||||
|                 new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account | ||||
|             ); | ||||
|  | ||||
|             return RedirectToPage("Challenge", new { id = challenge.Id }); | ||||
|             // If we have a return URL, pass it to the verify page | ||||
|             if (TempData.TryGetValue("ReturnUrl", out var returnUrl) && returnUrl is string url) | ||||
|             { | ||||
|                 return RedirectToPage("SelectFactor", new { id = challenge.Id, returnUrl = url }); | ||||
|             } | ||||
|              | ||||
|             return RedirectToPage("SelectFactor", new { id = challenge.Id }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -13,6 +13,9 @@ public class SelectFactorModel( | ||||
|     : PageModel | ||||
| { | ||||
|     [BindProperty(SupportsGet = true)] public Guid Id { get; set; } | ||||
|     [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } | ||||
|     [BindProperty] public Guid SelectedFactorId { get; set; } | ||||
|     [BindProperty] public string? Hint { get; set; } | ||||
|  | ||||
|     public Challenge? AuthChallenge { get; set; } | ||||
|     public List<AccountAuthFactor> AuthFactors { get; set; } = []; | ||||
| @@ -25,7 +28,7 @@ public class SelectFactorModel( | ||||
|         return Page(); | ||||
|     } | ||||
|  | ||||
|     public async Task<IActionResult> OnPostSelectFactorAsync(Guid factorId, string? hint = null) | ||||
|     public async Task<IActionResult> OnPostSelectFactorAsync() | ||||
|     { | ||||
|         var challenge = await db.AuthChallenges | ||||
|             .Include(e => e.Account) | ||||
| @@ -33,23 +36,29 @@ public class SelectFactorModel( | ||||
|  | ||||
|         if (challenge == null) return NotFound(); | ||||
|  | ||||
|         var factor = await db.AccountAuthFactors.FindAsync(factorId); | ||||
|         var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId); | ||||
|         if (factor?.EnabledAt == null || factor.Trustworthy <= 0) | ||||
|             return BadRequest("Invalid authentication method."); | ||||
|              | ||||
|         // Store return URL in TempData to pass to the next step | ||||
|         if (!string.IsNullOrEmpty(ReturnUrl)) | ||||
|         { | ||||
|             TempData["ReturnUrl"] = ReturnUrl; | ||||
|         } | ||||
|  | ||||
|         // For OTP factors that require code delivery | ||||
|         try | ||||
|         { | ||||
|             // Validate hint for factors that require it | ||||
|             // For OTP factors that require code delivery | ||||
|             if (factor.Type == AccountAuthFactorType.EmailCode  | ||||
|                 && string.IsNullOrWhiteSpace(hint)) | ||||
|                 && string.IsNullOrWhiteSpace(Hint)) | ||||
|             { | ||||
|                 ModelState.AddModelError(string.Empty, $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to."); | ||||
|                 await LoadChallengeAndFactors(); | ||||
|                 return Page(); | ||||
|             } | ||||
|  | ||||
|             await accounts.SendFactorCode(challenge.Account, factor, hint); | ||||
|             await accounts.SendFactorCode(challenge.Account, factor, Hint); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
| @@ -58,8 +67,12 @@ public class SelectFactorModel( | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
|         // Redirect to verify the page with the selected factor | ||||
|         return RedirectToPage("VerifyFactor", new { id = Id, factorId }); | ||||
|         // Redirect to verify page with return URL if available | ||||
|         if (!string.IsNullOrEmpty(ReturnUrl)) | ||||
|         { | ||||
|             return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl }); | ||||
|         } | ||||
|         return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id }); | ||||
|     } | ||||
|  | ||||
|     private async Task LoadChallengeAndFactors() | ||||
|   | ||||
| @@ -22,6 +22,9 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|  | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid FactorId { get; set; } | ||||
|          | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public string? ReturnUrl { get; set; } | ||||
|  | ||||
|         [BindProperty, Required] | ||||
|         public string Code { get; set; } = string.Empty; | ||||
| @@ -159,21 +162,21 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|             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 | ||||
|             if (session == null) | ||||
|             { | ||||
|                 LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), | ||||
|                 ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), | ||||
|                 Account = challenge.Account, | ||||
|                 Challenge = challenge, | ||||
|             }; | ||||
|  | ||||
|             _db.AuthSessions.Add(session); | ||||
|             await _db.SaveChangesAsync(); | ||||
|                 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() | ||||
|             Response.Cookies.Append("access_token", token, new CookieOptions | ||||
|             { | ||||
|                 HttpOnly = true, | ||||
|                 Secure = !_configuration.GetValue<bool>("Debug"), | ||||
| @@ -181,7 +184,19 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|                 Path = "/" | ||||
|             }); | ||||
|  | ||||
|             return RedirectToPage("/Account/Profile"); | ||||
|             // Redirect to the return URL if provided and valid, otherwise to the home page | ||||
|             if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl)) | ||||
|             { | ||||
|                 return Redirect(ReturnUrl); | ||||
|             } | ||||
|              | ||||
|             // Check TempData for return URL (in case it was passed through multiple steps) | ||||
|             if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl && !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) | ||||
|             { | ||||
|                 return Redirect(returnUrl); | ||||
|             } | ||||
|  | ||||
|             return RedirectToPage("/Index"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user