Compare commits
	
		
			3 Commits
		
	
	
		
			ee5d6ef821
			...
			d4fa08d320
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d4fa08d320 | |||
| 8bd0ea0fa1 | |||
| 9ab31d79ce | 
| @@ -196,6 +196,41 @@ public class AppDatabase( | ||||
|             .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) | ||||
|             .HasIndex(p => p.SearchVector) | ||||
|             .HasMethod("GIN"); | ||||
|  | ||||
|         modelBuilder.Entity<CustomApp>() | ||||
|             .Property(c => c.RedirectUris) | ||||
|             .HasConversion( | ||||
|                 v => string.Join(",", v), | ||||
|                 v => v.Split(",", StringSplitOptions.RemoveEmptyEntries).ToArray()); | ||||
|              | ||||
|         modelBuilder.Entity<CustomApp>() | ||||
|             .Property(c => c.PostLogoutRedirectUris) | ||||
|             .HasConversion( | ||||
|                 v => v != null ? string.Join(",", v) : "", | ||||
|                 v => !string.IsNullOrEmpty(v) ? v.Split(",", StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>()); | ||||
|                  | ||||
|         modelBuilder.Entity<CustomApp>() | ||||
|             .Property(c => c.AllowedScopes) | ||||
|             .HasConversion( | ||||
|                 v => v != null ? string.Join(" ", v) : "", | ||||
|                 v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "openid", "profile", "email" }); | ||||
|                  | ||||
|         modelBuilder.Entity<CustomApp>() | ||||
|             .Property(c => c.AllowedGrantTypes) | ||||
|             .HasConversion( | ||||
|                 v => v != null ? string.Join(" ", v) : "", | ||||
|                 v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "authorization_code", "refresh_token" }); | ||||
|                  | ||||
|         modelBuilder.Entity<CustomAppSecret>() | ||||
|             .HasIndex(s => s.Secret) | ||||
|             .IsUnique(); | ||||
|              | ||||
|         modelBuilder.Entity<CustomApp>() | ||||
|             .HasMany(c => c.Secrets) | ||||
|             .WithOne(s => s.App) | ||||
|             .HasForeignKey(s => s.AppId) | ||||
|             .OnDelete(DeleteBehavior.Cascade); | ||||
|  | ||||
|         modelBuilder.Entity<Post.Post>() | ||||
|             .HasOne(p => p.RepliedPost) | ||||
|             .WithMany() | ||||
|   | ||||
| @@ -0,0 +1,305 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.Options; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; | ||||
|  | ||||
| [Route("connect")] | ||||
| [ApiController] | ||||
| public class OidcProviderController( | ||||
|     OidcProviderService oidcService, | ||||
|     IOptions<OidcProviderOptions> options, | ||||
|     ILogger<OidcProviderController> logger | ||||
| ) | ||||
|     : ControllerBase | ||||
| { | ||||
|     [HttpGet("authorize")] | ||||
|     public async Task<IActionResult> Authorize( | ||||
|         [Required][FromQuery(Name = "client_id")] Guid clientId, | ||||
|         [Required][FromQuery(Name = "response_type")] string responseType, | ||||
|         [FromQuery(Name = "redirect_uri")] string? redirectUri, | ||||
|         [FromQuery] string? scope, | ||||
|         [FromQuery] string? state, | ||||
|         [FromQuery] string? nonce, | ||||
|         [FromQuery(Name = "code_challenge")] string? codeChallenge, | ||||
|         [FromQuery(Name = "code_challenge_method")] string? codeChallengeMethod, | ||||
|         [FromQuery(Name = "response_mode")] string? responseMode | ||||
|     ) | ||||
|     { | ||||
|         // Check if user is authenticated | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) | ||||
|         { | ||||
|             // Not authenticated - redirect to login with return URL | ||||
|             var loginUrl = "/Auth/Login"; | ||||
|             var returnUrl = $"{Request.Path}{Request.QueryString}"; | ||||
|             return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}"); | ||||
|         } | ||||
|          | ||||
|         // Validate client | ||||
|         var client = await oidcService.FindClientByIdAsync(clientId); | ||||
|         if (client == null) | ||||
|             return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client not found" }); | ||||
|              | ||||
|         // Check if user has already granted permission to this client | ||||
|         // For now, we'll always show the consent page. In a real app, you might store consent decisions. | ||||
|         // If you want to implement "remember my decision", you would check that here. | ||||
|         var consentRequired = true; | ||||
|          | ||||
|         if (consentRequired) | ||||
|         { | ||||
|             // Redirect to consent page with all the OAuth parameters | ||||
|             var consentUrl = $"/Auth/Authorize?client_id={clientId}"; | ||||
|             if (!string.IsNullOrEmpty(responseType)) consentUrl += $"&response_type={Uri.EscapeDataString(responseType)}"; | ||||
|             if (!string.IsNullOrEmpty(redirectUri)) consentUrl += $"&redirect_uri={Uri.EscapeDataString(redirectUri)}"; | ||||
|             if (!string.IsNullOrEmpty(scope)) consentUrl += $"&scope={Uri.EscapeDataString(scope)}"; | ||||
|             if (!string.IsNullOrEmpty(state)) consentUrl += $"&state={Uri.EscapeDataString(state)}"; | ||||
|             if (!string.IsNullOrEmpty(nonce)) consentUrl += $"&nonce={Uri.EscapeDataString(nonce)}"; | ||||
|             if (!string.IsNullOrEmpty(codeChallenge)) consentUrl += $"&code_challenge={Uri.EscapeDataString(codeChallenge)}"; | ||||
|             if (!string.IsNullOrEmpty(codeChallengeMethod)) consentUrl += $"&code_challenge_method={Uri.EscapeDataString(codeChallengeMethod)}"; | ||||
|             if (!string.IsNullOrEmpty(responseMode)) consentUrl += $"&response_mode={Uri.EscapeDataString(responseMode)}"; | ||||
|              | ||||
|             return Redirect(consentUrl); | ||||
|         } | ||||
|  | ||||
|         // Skip redirect_uri validation for apps in Developing status | ||||
|         if (client.Status != CustomAppStatus.Developing) | ||||
|         { | ||||
|             // Validate redirect URI for non-Developing apps | ||||
|             if (!string.IsNullOrEmpty(redirectUri) && !(client.RedirectUris?.Contains(redirectUri) ?? false)) | ||||
|                 return BadRequest( | ||||
|                     new ErrorResponse { Error = "invalid_request", ErrorDescription = "Invalid redirect_uri" }); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             logger.LogWarning("Skipping redirect_uri validation for app {AppId} in Developing status", clientId); | ||||
|  | ||||
|             // If no redirect_uri is provided and we're in development, use the first one | ||||
|             if (string.IsNullOrEmpty(redirectUri) && client.RedirectUris?.Any() == true) | ||||
|             { | ||||
|                 redirectUri = client.RedirectUris.First(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Generate authorization code | ||||
|         var code = Guid.NewGuid().ToString("N"); | ||||
|         var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); | ||||
|  | ||||
|         // In a real implementation, you'd store this code with the user's consent and requested scopes | ||||
|         // and validate it in the token endpoint | ||||
|  | ||||
|         // For now, we'll just return the code directly (simplified for example) | ||||
|         var response = new AuthorizationResponse | ||||
|         { | ||||
|             Code = code, | ||||
|             State = state, | ||||
|             Scope = scope, | ||||
|             Issuer = options.Value.IssuerUri | ||||
|         }; | ||||
|  | ||||
|         // Redirect back to the client with the authorization code | ||||
|         var finalRedirectUri = new UriBuilder(redirectUri ?? client.RedirectUris?.First() ?? throw new InvalidOperationException("No redirect URI provided and no default redirect URI found")); | ||||
|         var query = System.Web.HttpUtility.ParseQueryString(finalRedirectUri.Query); | ||||
|         query["code"] = response.Code; | ||||
|         if (!string.IsNullOrEmpty(response.State)) | ||||
|             query["state"] = response.State; | ||||
|         if (!string.IsNullOrEmpty(response.Scope)) | ||||
|             query["scope"] = response.Scope; | ||||
|  | ||||
|         finalRedirectUri.Query = query.ToString(); | ||||
|         return Redirect(finalRedirectUri.Uri.ToString()); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("token")] | ||||
|     [Consumes("application/x-www-form-urlencoded")] | ||||
|     public async Task<IActionResult> Token([FromForm] TokenRequest request) | ||||
|     { | ||||
|         if (request.GrantType == "authorization_code") | ||||
|         { | ||||
|             // Validate client credentials | ||||
|             if (request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret)) | ||||
|                 return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client credentials are required" }); | ||||
|  | ||||
|             var client = await oidcService.FindClientByIdAsync(request.ClientId.Value); | ||||
|             if (client == null || !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret)) | ||||
|                 return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Invalid client credentials" }); | ||||
|  | ||||
|             // Validate the authorization code | ||||
|             var authCode = await oidcService.ValidateAuthorizationCodeAsync( | ||||
|                 request.Code ?? string.Empty, | ||||
|                 request.ClientId.Value, | ||||
|                 request.RedirectUri, | ||||
|                 request.CodeVerifier); | ||||
|                  | ||||
|             if (authCode == null) | ||||
|             { | ||||
|                 logger.LogWarning("Invalid or expired authorization code: {Code}", request.Code); | ||||
|                 return BadRequest(new ErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid or expired authorization code" }); | ||||
|             } | ||||
|              | ||||
|             // Generate tokens | ||||
|             var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                 clientId: request.ClientId.Value, | ||||
|                 subjectId: authCode.UserId, | ||||
|                 scopes: authCode.Scopes, | ||||
|                 authorizationCode: request.Code); | ||||
|  | ||||
|             return Ok(tokenResponse); | ||||
|         } | ||||
|         else if (request.GrantType == "refresh_token") | ||||
|         { | ||||
|             // Handle refresh token request | ||||
|             // In a real implementation, you would validate the refresh token | ||||
|             // and issue a new access token | ||||
|             return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|         } | ||||
|  | ||||
|         return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("userinfo")] | ||||
|     [Authorize(AuthenticationSchemes = "Bearer")] | ||||
|     public async Task<IActionResult> UserInfo() | ||||
|     { | ||||
|         var authHeader = HttpContext.Request.Headers.Authorization.ToString(); | ||||
|         if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) | ||||
|         { | ||||
|             var loginUrl = "/Account/Login"; // Update this path to your actual login page path | ||||
|             var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; | ||||
|             return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}"); | ||||
|         } | ||||
|  | ||||
|         var token = authHeader["Bearer ".Length..].Trim(); | ||||
|         var jwtToken = oidcService.ValidateToken(token); | ||||
|  | ||||
|         if (jwtToken == null) | ||||
|         { | ||||
|             var loginUrl = "/Account/Login"; // Update this path to your actual login page path | ||||
|             var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; | ||||
|             return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}"); | ||||
|         } | ||||
|  | ||||
|         // Get user info based on the subject claim from the token | ||||
|         var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); | ||||
|         var userName = User.FindFirstValue(ClaimTypes.Name); | ||||
|         var userEmail = User.FindFirstValue(ClaimTypes.Email); | ||||
|  | ||||
|         // Get requested scopes from the token | ||||
|         var scopes = jwtToken.Claims | ||||
|             .Where(c => c.Type == "scope") | ||||
|             .SelectMany(c => c.Value.Split(' ')) | ||||
|             .ToHashSet(); | ||||
|  | ||||
|         var userInfo = new Dictionary<string, object> | ||||
|         { | ||||
|             ["sub"] = userId ?? "anonymous" | ||||
|         }; | ||||
|  | ||||
|         // Include standard claims based on scopes | ||||
|         if (scopes.Contains("profile") || scopes.Contains("name")) | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(userName)) | ||||
|                 userInfo["name"] = userName; | ||||
|         } | ||||
|  | ||||
|         if (scopes.Contains("email") && !string.IsNullOrEmpty(userEmail)) | ||||
|         { | ||||
|             userInfo["email"] = userEmail; | ||||
|             userInfo["email_verified"] = true; // In a real app, check if email is verified | ||||
|         } | ||||
|  | ||||
|         return Ok(userInfo); | ||||
|     } | ||||
|  | ||||
|     [HttpGet(".well-known/openid-configuration")] | ||||
|     public IActionResult GetConfiguration() | ||||
|     { | ||||
|         var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}".TrimEnd('/'); | ||||
|         var issuer = options.Value.IssuerUri.TrimEnd('/'); | ||||
|  | ||||
|         return Ok(new | ||||
|         { | ||||
|             issuer = issuer, | ||||
|             authorization_endpoint = $"{baseUrl}/connect/authorize", | ||||
|             token_endpoint = $"{baseUrl}/connect/token", | ||||
|             userinfo_endpoint = $"{baseUrl}/connect/userinfo", | ||||
|             jwks_uri = $"{baseUrl}/.well-known/openid-configuration/jwks", | ||||
|             scopes_supported = new[] { "openid", "profile", "email" }, | ||||
|             response_types_supported = new[] { "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" }, | ||||
|             grant_types_supported = new[] { "authorization_code", "refresh_token" }, | ||||
|             token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" }, | ||||
|             id_token_signing_alg_values_supported = new[] { "HS256" }, | ||||
|             subject_types_supported = new[] { "public" }, | ||||
|             claims_supported = new[] { "sub", "name", "email", "email_verified" }, | ||||
|             code_challenge_methods_supported = new[] { "S256" }, | ||||
|             response_modes_supported = new[] { "query", "fragment", "form_post" }, | ||||
|             request_parameter_supported = true, | ||||
|             request_uri_parameter_supported = true, | ||||
|             require_request_uri_registration = false | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("jwks")] | ||||
|     public IActionResult Jwks() | ||||
|     { | ||||
|         // In a production environment, you should use asymmetric keys (RSA or EC) | ||||
|         // and expose only the public key here. This is a simplified example using HMAC. | ||||
|         // For production, consider using RSA or EC keys and proper key rotation. | ||||
|  | ||||
|         var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey); | ||||
|         var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8]) | ||||
|             .Replace("+", "-") | ||||
|             .Replace("/", "_") | ||||
|             .Replace("=", ""); | ||||
|  | ||||
|         return Ok(new | ||||
|         { | ||||
|             keys = new[] | ||||
|             { | ||||
|                 new | ||||
|                 { | ||||
|                     kty = "oct", | ||||
|                     use = "sig", | ||||
|                     kid = keyId, | ||||
|                     k = Convert.ToBase64String(keyBytes), | ||||
|                     alg = "HS256" | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class TokenRequest | ||||
| { | ||||
|     [JsonPropertyName("grant_type")] | ||||
|     public string? GrantType { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("code")] | ||||
|     public string? Code { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("redirect_uri")] | ||||
|     public string? RedirectUri { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("client_id")] | ||||
|     public Guid? ClientId { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("client_secret")] | ||||
|     public string? ClientSecret { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("refresh_token")] | ||||
|     public string? RefreshToken { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|      | ||||
|     [JsonPropertyName("code_verifier")] | ||||
|     public string? CodeVerifier { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Models; | ||||
|  | ||||
| public class AuthorizationCodeInfo | ||||
| { | ||||
|     public Guid ClientId { get; set; } | ||||
|     public string UserId { get; set; } = string.Empty; | ||||
|     public string RedirectUri { get; set; } = string.Empty; | ||||
|     public List<string> Scopes { get; set; } = new(); | ||||
|     public string? CodeChallenge { get; set; } | ||||
|     public string? CodeChallengeMethod { get; set; } | ||||
|     public string? Nonce { get; set; } | ||||
|     public Instant Expiration { get; set; } | ||||
|     public Instant CreatedAt { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| using System; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
|  | ||||
| public class OidcProviderOptions | ||||
| { | ||||
|     public string IssuerUri { get; set; } = "https://your-issuer-uri.com"; | ||||
|     public string SigningKey { get; set; } = "replace-with-a-secure-random-key"; | ||||
|     public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1); | ||||
|     public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30); | ||||
|     public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5); | ||||
|     public bool RequireHttpsMetadata { get; set; } = true; | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class AuthorizationResponse | ||||
| { | ||||
|     [JsonPropertyName("code")] | ||||
|     public string Code { get; set; } = null!; | ||||
|  | ||||
|     [JsonPropertyName("state")] | ||||
|     public string? State { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("session_state")] | ||||
|     public string? SessionState { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("iss")] | ||||
|     public string? Issuer { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class ErrorResponse | ||||
| { | ||||
|     [JsonPropertyName("error")] | ||||
|     public string Error { get; set; } = null!; | ||||
|  | ||||
|     [JsonPropertyName("error_description")] | ||||
|     public string? ErrorDescription { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("error_uri")] | ||||
|     public string? ErrorUri { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("state")] | ||||
|     public string? State { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class TokenResponse | ||||
| { | ||||
|     [JsonPropertyName("access_token")] | ||||
|     public string AccessToken { get; set; } = null!; | ||||
|  | ||||
|     [JsonPropertyName("expires_in")] | ||||
|     public int ExpiresIn { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("token_type")] | ||||
|     public string TokenType { get; set; } = "Bearer"; | ||||
|  | ||||
|     [JsonPropertyName("refresh_token")] | ||||
|     public string? RefreshToken { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("id_token")] | ||||
|     public string? IdToken { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,301 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Encodings.Web; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Models; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Responses; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
|  | ||||
| public class OidcProviderService( | ||||
|     AppDatabase db, | ||||
|     IClock clock, | ||||
|     IOptions<OidcProviderOptions> options, | ||||
|     ILogger<OidcProviderService> logger | ||||
| ) | ||||
| { | ||||
|     private readonly OidcProviderOptions _options = options.Value; | ||||
|  | ||||
|     public async Task<CustomApp?> FindClientByIdAsync(Guid clientId) | ||||
|     { | ||||
|         return await db.CustomApps | ||||
|             .Include(c => c.Secrets) | ||||
|             .FirstOrDefaultAsync(c => c.Id == clientId); | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId) | ||||
|     { | ||||
|         return await db.CustomApps | ||||
|             .Include(c => c.Secrets) | ||||
|             .FirstOrDefaultAsync(c => c.Id == appId); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret) | ||||
|     { | ||||
|         var client = await FindClientByIdAsync(clientId); | ||||
|         if (client == null) return false; | ||||
|  | ||||
|         var secret = client.Secrets | ||||
|             .Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant())) | ||||
|             .FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing | ||||
|  | ||||
|         return secret != null; | ||||
|     } | ||||
|  | ||||
|     public async Task<TokenResponse> GenerateTokenResponseAsync( | ||||
|         Guid clientId, | ||||
|         string subjectId, | ||||
|         IEnumerable<string>? scopes = null, | ||||
|         string? authorizationCode = null) | ||||
|     { | ||||
|         var client = await FindClientByIdAsync(clientId); | ||||
|         if (client == null) | ||||
|             throw new InvalidOperationException("Client not found"); | ||||
|  | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; | ||||
|         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); | ||||
|  | ||||
|         // Generate access token | ||||
|         var accessToken = GenerateJwtToken(client, subjectId, expiresAt, scopes); | ||||
|         var refreshToken = GenerateRefreshToken(); | ||||
|  | ||||
|         // In a real implementation, you would store the token in the database | ||||
|         // For this example, we'll just return the token without storing it | ||||
|         // as we don't have a dedicated OIDC token table | ||||
|  | ||||
|         return new TokenResponse | ||||
|         { | ||||
|             AccessToken = accessToken, | ||||
|             ExpiresIn = expiresIn, | ||||
|             TokenType = "Bearer", | ||||
|             RefreshToken = refreshToken, | ||||
|             Scope = scopes != null ? string.Join(" ", scopes) : null | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private string GenerateJwtToken(CustomApp client, string subjectId, Instant expiresAt, IEnumerable<string>? scopes = null) | ||||
|     { | ||||
|         var tokenHandler = new JwtSecurityTokenHandler(); | ||||
|         var key = Encoding.ASCII.GetBytes(_options.SigningKey); | ||||
|  | ||||
|         var tokenDescriptor = new SecurityTokenDescriptor | ||||
|         { | ||||
|             Subject = new ClaimsIdentity(new[] | ||||
|             { | ||||
|                 new Claim(JwtRegisteredClaimNames.Sub, subjectId), | ||||
|                 new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), | ||||
|                 new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(), | ||||
|                     ClaimValueTypes.Integer64), | ||||
|                 new Claim("client_id", client.Id.ToString()) | ||||
|             }), | ||||
|             Expires = expiresAt.ToDateTimeUtc(), | ||||
|             Issuer = _options.IssuerUri, | ||||
|             Audience = client.Id.ToString(), | ||||
|             SigningCredentials = new SigningCredentials( | ||||
|                 new SymmetricSecurityKey(key), | ||||
|                 SecurityAlgorithms.HmacSha256Signature) | ||||
|         }; | ||||
|  | ||||
|         // Add scopes as claims if provided, otherwise use client's default scopes | ||||
|         var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>(); | ||||
|         if (effectiveScopes.Any()) | ||||
|         { | ||||
|             tokenDescriptor.Subject.AddClaims( | ||||
|                 effectiveScopes.Select(scope => new Claim("scope", scope))); | ||||
|         } | ||||
|  | ||||
|         var token = tokenHandler.CreateToken(tokenDescriptor); | ||||
|         return tokenHandler.WriteToken(token); | ||||
|     } | ||||
|  | ||||
|     private static string GenerateRefreshToken() | ||||
|     { | ||||
|         using var rng = RandomNumberGenerator.Create(); | ||||
|         var bytes = new byte[32]; | ||||
|         rng.GetBytes(bytes); | ||||
|         return Convert.ToBase64String(bytes) | ||||
|             .Replace("+", "-") | ||||
|             .Replace("/", "_") | ||||
|             .Replace("=", ""); | ||||
|     } | ||||
|  | ||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) | ||||
|     { | ||||
|         // In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2 | ||||
|         // For now, we'll do a simple comparison, but you should replace this with proper hashing | ||||
|         return string.Equals(secret, hashedSecret, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public JwtSecurityToken? ValidateToken(string token) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             var tokenHandler = new JwtSecurityTokenHandler(); | ||||
|             var key = Encoding.ASCII.GetBytes(_options.SigningKey); | ||||
|  | ||||
|             tokenHandler.ValidateToken(token, new TokenValidationParameters | ||||
|             { | ||||
|                 ValidateIssuerSigningKey = true, | ||||
|                 IssuerSigningKey = new SymmetricSecurityKey(key), | ||||
|                 ValidateIssuer = true, | ||||
|                 ValidIssuer = _options.IssuerUri, | ||||
|                 ValidateAudience = true, | ||||
|                 ValidateLifetime = true, | ||||
|                 ClockSkew = TimeSpan.Zero | ||||
|             }, out var validatedToken); | ||||
|  | ||||
|             return (JwtSecurityToken)validatedToken; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Token validation failed"); | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static readonly Dictionary<string, AuthorizationCodeInfo> _authorizationCodes = new(); | ||||
|      | ||||
|     public async Task<string> GenerateAuthorizationCodeAsync( | ||||
|         Guid clientId, | ||||
|         string userId, | ||||
|         string redirectUri, | ||||
|         IEnumerable<string> scopes, | ||||
|         string? codeChallenge = null, | ||||
|         string? codeChallengeMethod = null, | ||||
|         string? nonce = null) | ||||
|     { | ||||
|         // Generate a random code | ||||
|         var code = GenerateRandomString(32); | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|          | ||||
|         // Store the code with its metadata | ||||
|         _authorizationCodes[code] = new AuthorizationCodeInfo | ||||
|         { | ||||
|             ClientId = clientId, | ||||
|             UserId = userId, | ||||
|             RedirectUri = redirectUri, | ||||
|             Scopes = scopes.ToList(), | ||||
|             CodeChallenge = codeChallenge, | ||||
|             CodeChallengeMethod = codeChallengeMethod, | ||||
|             Nonce = nonce, | ||||
|             Expiration = now.Plus(Duration.FromTimeSpan(_options.AuthorizationCodeLifetime)), | ||||
|             CreatedAt = now | ||||
|         }; | ||||
|          | ||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); | ||||
|         return code; | ||||
|     } | ||||
|      | ||||
|     public async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( | ||||
|         string code, | ||||
|         Guid clientId, | ||||
|         string? redirectUri = null, | ||||
|         string? codeVerifier = null) | ||||
|     { | ||||
|         if (!_authorizationCodes.TryGetValue(code, out var authCode) || authCode == null) | ||||
|         { | ||||
|             logger.LogWarning("Authorization code not found: {Code}", code); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|          | ||||
|         // Check if code has expired | ||||
|         if (now > authCode.Expiration) | ||||
|         { | ||||
|             logger.LogWarning("Authorization code expired: {Code}", code); | ||||
|             _authorizationCodes.Remove(code); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Verify client ID matches | ||||
|         if (authCode.ClientId != clientId) | ||||
|         { | ||||
|             logger.LogWarning("Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",  | ||||
|                 code, authCode.ClientId, clientId); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Verify redirect URI if provided | ||||
|         if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri) | ||||
|         { | ||||
|             logger.LogWarning("Redirect URI mismatch for code {Code}", code); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Verify PKCE code challenge if one was provided during authorization | ||||
|         if (!string.IsNullOrEmpty(authCode.CodeChallenge)) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(codeVerifier)) | ||||
|             { | ||||
|                 logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code); | ||||
|                 return null; | ||||
|             } | ||||
|              | ||||
|             var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch | ||||
|             { | ||||
|                 "S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"), | ||||
|                 "PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"), | ||||
|                 _ => false // Unsupported code challenge method | ||||
|             }; | ||||
|              | ||||
|             if (!isValid) | ||||
|             { | ||||
|                 logger.LogWarning("PKCE code verifier validation failed for code {Code}", code); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Code is valid, remove it from the store (codes are single-use) | ||||
|         _authorizationCodes.Remove(code); | ||||
|          | ||||
|         return authCode; | ||||
|     } | ||||
|      | ||||
|     private static string GenerateRandomString(int length) | ||||
|     { | ||||
|         const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"; | ||||
|         var random = RandomNumberGenerator.Create(); | ||||
|         var result = new char[length]; | ||||
|          | ||||
|         for (int i = 0; i < length; i++) | ||||
|         { | ||||
|             var randomNumber = new byte[4]; | ||||
|             random.GetBytes(randomNumber); | ||||
|             var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length); | ||||
|             result[i] = chars[index]; | ||||
|         } | ||||
|          | ||||
|         return new string(result); | ||||
|     } | ||||
|      | ||||
|     private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(codeVerifier)) return false; | ||||
|          | ||||
|         if (method == "S256") | ||||
|         { | ||||
|             using var sha256 = SHA256.Create(); | ||||
|             var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); | ||||
|             var base64 = Base64UrlEncoder.Encode(hash); | ||||
|             return string.Equals(base64, codeChallenge, StringComparison.Ordinal); | ||||
|         } | ||||
|          | ||||
|         if (method == "PLAIN") | ||||
|         { | ||||
|             return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal); | ||||
|         } | ||||
|          | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										4000
									
								
								DysonNetwork.Sphere/Data/Migrations/20250628172328_AddOidcProviderSupport.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										4000
									
								
								DysonNetwork.Sphere/Data/Migrations/20250628172328_AddOidcProviderSupport.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,139 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Data.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddOidcProviderSupport : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.RenameColumn( | ||||
|                 name: "remarks", | ||||
|                 table: "custom_app_secrets", | ||||
|                 newName: "description"); | ||||
|  | ||||
|             migrationBuilder.AddColumn<bool>( | ||||
|                 name: "allow_offline_access", | ||||
|                 table: "custom_apps", | ||||
|                 type: "boolean", | ||||
|                 nullable: false, | ||||
|                 defaultValue: false); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "allowed_grant_types", | ||||
|                 table: "custom_apps", | ||||
|                 type: "character varying(256)", | ||||
|                 maxLength: 256, | ||||
|                 nullable: false, | ||||
|                 defaultValue: ""); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "allowed_scopes", | ||||
|                 table: "custom_apps", | ||||
|                 type: "character varying(256)", | ||||
|                 maxLength: 256, | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "client_uri", | ||||
|                 table: "custom_apps", | ||||
|                 type: "character varying(1024)", | ||||
|                 maxLength: 1024, | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "logo_uri", | ||||
|                 table: "custom_apps", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "post_logout_redirect_uris", | ||||
|                 table: "custom_apps", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "redirect_uris", | ||||
|                 table: "custom_apps", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: false, | ||||
|                 defaultValue: ""); | ||||
|  | ||||
|             migrationBuilder.AddColumn<bool>( | ||||
|                 name: "require_pkce", | ||||
|                 table: "custom_apps", | ||||
|                 type: "boolean", | ||||
|                 nullable: false, | ||||
|                 defaultValue: false); | ||||
|  | ||||
|             migrationBuilder.AddColumn<bool>( | ||||
|                 name: "is_oidc", | ||||
|                 table: "custom_app_secrets", | ||||
|                 type: "boolean", | ||||
|                 nullable: false, | ||||
|                 defaultValue: false); | ||||
|  | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_custom_app_secrets_secret", | ||||
|                 table: "custom_app_secrets", | ||||
|                 column: "secret", | ||||
|                 unique: true); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropIndex( | ||||
|                 name: "ix_custom_app_secrets_secret", | ||||
|                 table: "custom_app_secrets"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "allow_offline_access", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "allowed_grant_types", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "allowed_scopes", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "client_uri", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "logo_uri", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "post_logout_redirect_uris", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "redirect_uris", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "require_pkce", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "is_oidc", | ||||
|                 table: "custom_app_secrets"); | ||||
|  | ||||
|             migrationBuilder.RenameColumn( | ||||
|                 name: "description", | ||||
|                 table: "custom_app_secrets", | ||||
|                 newName: "remarks"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -21,7 +21,17 @@ public class CustomApp : ModelBase | ||||
|     public Instant? VerifiedAt { get; set; } | ||||
|     [MaxLength(4096)] public string? VerifiedAs { get; set; } | ||||
|      | ||||
|     [JsonIgnore] private ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>(); | ||||
|     // OIDC/OAuth specific properties | ||||
|     [MaxLength(4096)] public string? LogoUri { get; set; } | ||||
|     [MaxLength(1024)] public string? ClientUri { get; set; } | ||||
|     [MaxLength(4096)] public string[] RedirectUris { get; set; } = []; | ||||
|     [MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; } | ||||
|     [MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"]; | ||||
|     [MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"]; | ||||
|     public bool RequirePkce { get; set; } = true; | ||||
|     public bool AllowOfflineAccess { get; set; } = false; | ||||
|      | ||||
|     [JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>(); | ||||
|  | ||||
|     public Guid PublisherId { get; set; } | ||||
|     public Publisher.Publisher Developer { get; set; } = null!; | ||||
| @@ -31,8 +41,9 @@ public class CustomAppSecret : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(1024)] public string Secret { get; set; } = null!; | ||||
|     [MaxLength(4096)] public string? Remarks { get; set; } = null!; | ||||
|     [MaxLength(4096)] public string? Description { get; set; } = null!; | ||||
|     public Instant? ExpiredAt { get; set; } | ||||
|     public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth | ||||
|      | ||||
|     public Guid AppId { get; set; } | ||||
|     public CustomApp App { get; set; } = null!; | ||||
|   | ||||
| @@ -1500,6 +1500,26 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<bool>("AllowOfflineAccess") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("allow_offline_access"); | ||||
|  | ||||
|                     b.Property<string>("AllowedGrantTypes") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("allowed_grant_types"); | ||||
|  | ||||
|                     b.Property<string>("AllowedScopes") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("allowed_scopes"); | ||||
|  | ||||
|                     b.Property<string>("ClientUri") | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("client_uri"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
| @@ -1508,16 +1528,36 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("LogoUri") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("logo_uri"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<string>("PostLogoutRedirectUris") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("post_logout_redirect_uris"); | ||||
|  | ||||
|                     b.Property<Guid>("PublisherId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("publisher_id"); | ||||
|  | ||||
|                     b.Property<string>("RedirectUris") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("redirect_uris"); | ||||
|  | ||||
|                     b.Property<bool>("RequirePkce") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("require_pkce"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
| @@ -1569,14 +1609,18 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("Remarks") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("remarks"); | ||||
|                     b.Property<bool>("IsOidc") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_oidc"); | ||||
|  | ||||
|                     b.Property<string>("Secret") | ||||
|                         .IsRequired() | ||||
| @@ -1594,6 +1638,10 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                     b.HasIndex("AppId") | ||||
|                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||
|  | ||||
|                     b.HasIndex("Secret") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_custom_app_secrets_secret"); | ||||
|  | ||||
|                     b.ToTable("custom_app_secrets", (string)null); | ||||
|                 }); | ||||
|  | ||||
| @@ -3444,7 +3492,7 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|             modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App") | ||||
|                         .WithMany() | ||||
|                         .WithMany("Secrets") | ||||
|                         .HasForeignKey("AppId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
| @@ -3895,6 +3943,11 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                     b.Navigation("Articles"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b => | ||||
|                 { | ||||
|                     b.Navigation("Secrets"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b => | ||||
|                 { | ||||
|                     b.Navigation("Members"); | ||||
|   | ||||
| @@ -4,75 +4,340 @@ | ||||
|     ViewData["Title"] = "Profile"; | ||||
| } | ||||
|  | ||||
| <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> | ||||
|  | ||||
|         @if (Model.Account != null) | ||||
|         { | ||||
|             <div class="mb-6"> | ||||
|                 <h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Account Information</h2> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Username:</strong> @Model.Account.Name</p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Nickname:</strong> @Model.Account.Nick</p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Language:</strong> @Model.Account.Language</p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"> | ||||
|                     <strong>Activated:</strong> @Model.Account.ActivatedAt?.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) | ||||
|                 </p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Superuser:</strong> @Model.Account.IsSuperuser | ||||
|                 </p> | ||||
| @if (Model.Account != null) | ||||
| { | ||||
|     <div class="h-full bg-gray-100 dark:bg-gray-900 py-8 px-4"> | ||||
|         <div class="max-w-6xl mx-auto"> | ||||
|             <!-- Header --> | ||||
|             <div class="mb-8"> | ||||
|                 <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Profile Settings</h1> | ||||
|                 <p class="text-gray-600 dark:text-gray-400 mt-2">Manage your account information and preferences</p> | ||||
|             </div> | ||||
|  | ||||
|             <div class="mb-6"> | ||||
|                 <h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Profile Details</h2> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"> | ||||
|                     <strong>Name:</strong> @Model.Account.Profile.FirstName @Model.Account.Profile.MiddleName @Model.Account.Profile.LastName | ||||
|                 </p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Bio:</strong> @Model.Account.Profile.Bio</p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Gender:</strong> @Model.Account.Profile.Gender | ||||
|                 </p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"> | ||||
|                     <strong>Location:</strong> @Model.Account.Profile.Location</p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"> | ||||
|                     <strong>Birthday:</strong> @Model.Account.Profile.Birthday?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) | ||||
|                 </p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"> | ||||
|                     <strong>Experience:</strong> @Model.Account.Profile.Experience</p> | ||||
|                 <p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Level:</strong> @Model.Account.Profile.Level | ||||
|                 </p> | ||||
|             </div> | ||||
|             <!-- Two Column Layout --> | ||||
|             <div class="flex flex-col md:flex-row gap-8"> | ||||
|                 <!-- Left Pane - Profile Card --> | ||||
|                 <div class="w-full md:w-1/3 lg:w-1/4"> | ||||
|                     <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 sticky top-8"> | ||||
|                         <div class="flex flex-col items-center text-center"> | ||||
|                             <!-- Avatar --> | ||||
|                             <div | ||||
|                                 class="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mb-4 overflow-hidden"> | ||||
|                                 <span class="text-4xl text-gray-500 dark:text-gray-400"> | ||||
|                                     @Model.Account.Name?.Substring(0, 1).ToUpper() | ||||
|                                 </span> | ||||
|                             </div> | ||||
|  | ||||
|             <div class="mb-6"> | ||||
|                 <h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Access Token</h2> | ||||
|                 <div class="flex items-center"> | ||||
|                     <input type="text" id="accessToken" value="@Model.AccessToken" readonly | ||||
|                            class="form-input flex-grow 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"/> | ||||
|                     <button onclick="copyAccessToken()" | ||||
|                             class="ml-4 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"> | ||||
|                         Copy | ||||
|                     </button> | ||||
|                             <!-- Basic Info --> | ||||
|                             <h2 class="text-xl font-semibold text-gray-900 dark:text-white"> | ||||
|                                 @Model.Account.Nick | ||||
|                             </h2> | ||||
|                             <p class="text-gray-600 dark:text-gray-400">@Model.Account.Name</p> | ||||
|  | ||||
|                             <!-- Stats --> | ||||
|                             <div | ||||
|                                 class="mt-4 flex justify-around w-full border-t border-gray-200 dark:border-gray-700 pt-4"> | ||||
|                                 <div class="text-center"> | ||||
|                                     <div | ||||
|                                         class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Level</div> | ||||
|                                     <div class="text-sm text-gray-500 dark:text-gray-400">Level</div> | ||||
|                                 </div> | ||||
|                                 <div class="text-center"> | ||||
|                                     <div | ||||
|                                         class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Experience</div> | ||||
|                                     <div class="text-sm text-gray-500 dark:text-gray-400">XP</div> | ||||
|                                 </div> | ||||
|                                 <div class="text-center"> | ||||
|                                     <div | ||||
|                                         class="text-lg font-semibold text-gray-900 dark:text-white"> | ||||
|                                         @Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM/dd") | ||||
|                                     </div> | ||||
|                                     <div class="text-sm text-gray-500 dark:text-gray-400">Member since</div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <!-- Right Pane - Tabbed Content --> | ||||
|                 <div class="flex-1"> | ||||
|                     <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden"> | ||||
|                         <!-- Tabs --> | ||||
|                         <div class="border-b border-gray-200 dark:border-gray-700"> | ||||
|                             <nav class="flex -mb-px"> | ||||
|                                 <button type="button" | ||||
|                                         class="tab-button active py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200" | ||||
|                                         data-tab="profile"> | ||||
|                                     Profile | ||||
|                                 </button> | ||||
|                                 <button type="button" | ||||
|                                         class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200" | ||||
|                                         data-tab="security"> | ||||
|                                     Security | ||||
|                                 </button> | ||||
|                                 <button type="button" | ||||
|                                         class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200" | ||||
|                                         data-tab="sessions"> | ||||
|                                     Sessions | ||||
|                                 </button> | ||||
|                             </nav> | ||||
|                         </div> | ||||
|  | ||||
|                         <!-- Tab Content --> | ||||
|                         <div class="p-6"> | ||||
|                             <!-- Profile Tab --> | ||||
|                             <div id="profile-tab" class="tab-content"> | ||||
|                                 <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Profile | ||||
|                                     Information</h2> | ||||
|  | ||||
|                                 <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | ||||
|                                     <div> | ||||
|                                         <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Basic | ||||
|                                             Information</h3> | ||||
|                                         <dl class="space-y-4"> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Full | ||||
|                                                     Name | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white"> | ||||
|                                                     @($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim()) | ||||
|                                                 </dd> | ||||
|                                             </div> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> | ||||
|                                                     Username | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Name</dd> | ||||
|                                             </div> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> | ||||
|                                                     Nickname | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Nick</dd> | ||||
|                                             </div> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> | ||||
|                                                     Gender | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Gender</dd> | ||||
|                                             </div> | ||||
|                                         </dl> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <div> | ||||
|                                         <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Additional | ||||
|                                             Details</h3> | ||||
|                                         <dl class="space-y-4"> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> | ||||
|                                                     Location | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Location</dd> | ||||
|                                             </div> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"> | ||||
|                                                     Birthday | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white"> | ||||
|                                                     @Model.Account.Profile.Birthday?.ToString("MMMM d, yyyy", System.Globalization.CultureInfo.InvariantCulture) | ||||
|                                                 </dd> | ||||
|                                             </div> | ||||
|                                             <div> | ||||
|                                                 <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Bio | ||||
|                                                 </dt> | ||||
|                                                 <dd class="mt-1 text-sm text-gray-900 dark:text-white"> | ||||
|                                                     @(string.IsNullOrEmpty(Model.Account.Profile.Bio) ? "No bio provided" : Model.Account.Profile.Bio) | ||||
|                                                 </dd> | ||||
|                                             </div> | ||||
|                                         </dl> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700"> | ||||
|                                     <button type="button" | ||||
|                                             class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> | ||||
|                                         Edit Profile | ||||
|                                     </button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|  | ||||
|                             <!-- Security Tab --> | ||||
|                             <div id="security-tab" class="tab-content hidden"> | ||||
|                                 <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Security | ||||
|                                     Settings</h2> | ||||
|  | ||||
|                                 <div class="space-y-6"> | ||||
|                                     <div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg"> | ||||
|                                         <div class="px-4 py-5 sm:px-6"> | ||||
|                                             <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white"> | ||||
|                                                 Access Token</h3> | ||||
|                                             <p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">Use this | ||||
|                                                 token to authenticate with the API</p> | ||||
|                                         </div> | ||||
|                                         <div class="border-t border-gray-200 dark:border-gray-700 px-4 py-5 sm:px-6"> | ||||
|                                             <div class="flex items-center"> | ||||
|                                                 <input type="password" id="accessToken" value="@Model.AccessToken" | ||||
|                                                        readonly | ||||
|                                                        class="form-input flex-grow 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 py-2 px-4"/> | ||||
|                                                 <button onclick="copyAccessToken()" | ||||
|                                                         class="ml-4 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"> | ||||
|                                                     Copy | ||||
|                                                 </button> | ||||
|                                             </div> | ||||
|                                             <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> | ||||
|                                                 Keep this token secure and do not share it with anyone. | ||||
|                                             </p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|  | ||||
|                             <!-- Sessions Tab --> | ||||
|                             <div id="sessions-tab" class="tab-content hidden"> | ||||
|                                 <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Active | ||||
|                                     Sessions</h2> | ||||
|                                 <p class="text-gray-600 dark:text-gray-400 mb-6">This is a list of devices that have | ||||
|                                     logged into your account. Revoke any sessions that you do not recognize.</p> | ||||
|  | ||||
|                                 <div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg"> | ||||
|                                     <ul class="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|                                         <li class="px-4 py-4 sm:px-6"> | ||||
|                                             <div class="flex items-center justify-between"> | ||||
|                                                 <div class="flex items-center"> | ||||
|                                                     <div | ||||
|                                                         class="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center"> | ||||
|                                                         <svg class="h-6 w-6 text-blue-600 dark:text-blue-400" | ||||
|                                                              fill="currentColor" viewBox="0 0 20 20"> | ||||
|                                                             <path fill-rule="evenodd" | ||||
|                                                                   d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z" | ||||
|                                                                   clip-rule="evenodd"/> | ||||
|                                                         </svg> | ||||
|                                                     </div> | ||||
|                                                     <div class="ml-4"> | ||||
|                                                         <div class="text-sm font-medium text-gray-900 dark:text-white"> | ||||
|                                                             Current Session | ||||
|                                                         </div> | ||||
|                                                         <div class="text-sm text-gray-500 dark:text-gray-400"> | ||||
|                                                             @($"{Request.Headers["User-Agent"]} • {DateTime.Now:MMMM d, yyyy 'at' h:mm tt}") | ||||
|                                                         </div> | ||||
|                                                     </div> | ||||
|                                                 </div> | ||||
|                                                 <div class="ml-4 flex-shrink-0"> | ||||
|                                                     <span | ||||
|                                                         class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> | ||||
|                                                         Active now | ||||
|                                                     </span> | ||||
|                                                 </div> | ||||
|                                             </div> | ||||
|                                         </li> | ||||
|                                     </ul> | ||||
|                                     <div class="bg-gray-50 dark:bg-gray-800 px-4 py-4 sm:px-6 text-right"> | ||||
|                                         <button type="button" | ||||
|                                                 class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> | ||||
|                                             Sign out all other sessions | ||||
|                                         </button> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <!-- Logout Button --> | ||||
|                     <div class="mt-6 flex justify-end"> | ||||
|                         <form method="post" asp-page-handler="Logout"> | ||||
|                             <button type="submit" | ||||
|                                     class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> | ||||
|                                 Sign out | ||||
|                             </button> | ||||
|                         </form> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <form method="post" asp-page-handler="Logout" class="text-center"> | ||||
|                 <button type="submit" | ||||
|                         class="bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"> | ||||
|                     Logout | ||||
|                 </button> | ||||
|             </form> | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             <p class="text-red-500 text-center">User profile not found. Please log in.</p> | ||||
|         } | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| } | ||||
| else | ||||
| { | ||||
|     <div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900"> | ||||
|         <div class="max-w-md w-full p-8 bg-white dark:bg-gray-800 rounded-lg shadow-md text-center"> | ||||
|             <div class="text-red-500 text-5xl mb-4"> | ||||
|                 <i class="fas fa-exclamation-circle"></i> | ||||
|             </div> | ||||
|             <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Profile Not Found</h2> | ||||
|             <p class="text-gray-600 dark:text-gray-400 mb-6">User profile not found. Please log in to continue.</p> | ||||
|             <a href="/auth/login" | ||||
|                class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> | ||||
|                 Go to Login | ||||
|             </a> | ||||
|         </div> | ||||
|     </div> | ||||
| } | ||||
|  | ||||
| <script> | ||||
|     function copyAccessToken() { | ||||
|         var copyText = document.getElementById("accessToken"); | ||||
|         copyText.select(); | ||||
|         copyText.setSelectionRange(0, 99999); /* For mobile devices */ | ||||
|         document.execCommand("copy"); | ||||
|         alert("Access Token copied to clipboard!"); | ||||
|     } | ||||
| </script> | ||||
| @section Scripts { | ||||
|     <script> | ||||
|         // Tab functionality | ||||
|         document.addEventListener('DOMContentLoaded', function () { | ||||
|             // Get all tab buttons and content | ||||
|             const tabButtons = document.querySelectorAll('.tab-button'); | ||||
|             const tabContents = document.querySelectorAll('.tab-content'); | ||||
|  | ||||
|             // Add click event listeners to tab buttons | ||||
|             tabButtons.forEach(button => { | ||||
|                 button.addEventListener('click', function () { | ||||
|                     const tabId = this.getAttribute('data-tab'); | ||||
|  | ||||
|                     // Update active tab button | ||||
|                     tabButtons.forEach(btn => btn.classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400')); | ||||
|                     this.classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400'); | ||||
|  | ||||
|                     // Show corresponding tab content | ||||
|                     tabContents.forEach(content => { | ||||
|                         content.classList.add('hidden'); | ||||
|                         if (content.id === `${tabId}-tab`) { | ||||
|                             content.classList.remove('hidden'); | ||||
|                         } | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             // Show first tab by default | ||||
|             if (tabButtons.length > 0) { | ||||
|                 tabButtons[0].click(); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Copy access token to clipboard | ||||
|         function copyAccessToken() { | ||||
|             const copyText = document.getElementById("accessToken"); | ||||
|             copyText.select(); | ||||
|             copyText.setSelectionRange(0, 99999); | ||||
|             document.execCommand("copy"); | ||||
|  | ||||
|             // Show tooltip or notification | ||||
|             const originalText = event.target.innerHTML; | ||||
|             event.target.innerHTML = '<i class="fas fa-check mr-1"></i> Copied!'; | ||||
|             event.target.disabled = true; | ||||
|  | ||||
|             setTimeout(() => { | ||||
|                 event.target.innerHTML = originalText; | ||||
|                 event.target.disabled = false; | ||||
|             }, 2000); | ||||
|         } | ||||
|  | ||||
|         // Toggle password visibility | ||||
|         function togglePasswordVisibility(inputId) { | ||||
|             const input = document.getElementById(inputId); | ||||
|             const icon = document.querySelector(`[onclick="togglePasswordVisibility('${inputId}')"] i`); | ||||
|  | ||||
|             if (input.type === 'password') { | ||||
|                 input.type = 'text'; | ||||
|                 icon.classList.remove('fa-eye'); | ||||
|                 icon.classList.add('fa-eye-slash'); | ||||
|             } else { | ||||
|                 input.type = 'password'; | ||||
|                 icon.classList.remove('fa-eye-slash'); | ||||
|                 icon.classList.add('fa-eye'); | ||||
|             } | ||||
|         } | ||||
|     </script> | ||||
| } | ||||
							
								
								
									
										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()); | ||||
|     } | ||||
| } | ||||
| @@ -8,9 +8,12 @@ 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> | ||||
| @@ -21,6 +23,15 @@ | ||||
|                 Next | ||||
|             </button> | ||||
|         </form> | ||||
|  | ||||
|         <div class="mt-8 flex flex-col text-sm text-center"> | ||||
|             <span class="text-gray-900 dark:text-white opacity-80">Have no account?</span> | ||||
|  | ||||
|             <a href="https://solian.app/#/auth/create-account" | ||||
|                class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"> | ||||
|                 Create a new account → | ||||
|             </a> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,10 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|     { | ||||
|         [BindProperty] [Required] public string Username { get; set; } = string.Empty; | ||||
|          | ||||
|         [BindProperty] | ||||
|         [FromQuery] | ||||
|         public string? ReturnUrl { get; set; } | ||||
|  | ||||
|         public void OnGet() | ||||
|         { | ||||
|         } | ||||
| @@ -37,6 +41,12 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|                 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(); | ||||
|             var now = Instant.FromDateTimeUtc(DateTime.UtcNow); | ||||
| @@ -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 | ||||
|             ); | ||||
|             // 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("Challenge", new { id = challenge.Id }); | ||||
|             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() | ||||
|   | ||||
| @@ -52,6 +52,7 @@ | ||||
|                     <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" | ||||
|                            type="password" | ||||
|                            autofocus /> | ||||
|                     <span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span> | ||||
|                 </div> | ||||
|   | ||||
| @@ -23,6 +23,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"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson; | ||||
| using StackExchange.Redis; | ||||
| using System.Text.Json; | ||||
| using System.Threading.RateLimiting; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
| using DysonNetwork.Sphere.Connection.WebReader; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using DysonNetwork.Sphere.Discovery; | ||||
| @@ -234,6 +235,7 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddScoped<SafetyService>(); | ||||
|         services.AddScoped<DiscoveryService>(); | ||||
|         services.AddScoped<CustomAppService>(); | ||||
|         services.AddScoped<OidcProviderService>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|   | ||||
| @@ -17,14 +17,17 @@ | ||||
|     --color-green-100: oklch(96.2% 0.044 156.743); | ||||
|     --color-green-200: oklch(92.5% 0.084 155.995); | ||||
|     --color-green-400: oklch(79.2% 0.209 151.711); | ||||
|     --color-green-500: oklch(72.3% 0.219 149.579); | ||||
|     --color-green-600: oklch(62.7% 0.194 149.214); | ||||
|     --color-green-800: oklch(44.8% 0.119 151.328); | ||||
|     --color-green-900: oklch(39.3% 0.095 152.535); | ||||
|     --color-blue-100: oklch(93.2% 0.032 255.585); | ||||
|     --color-blue-300: oklch(80.9% 0.105 251.813); | ||||
|     --color-blue-400: oklch(70.7% 0.165 254.624); | ||||
|     --color-blue-500: oklch(62.3% 0.214 259.815); | ||||
|     --color-blue-600: oklch(54.6% 0.245 262.881); | ||||
|     --color-blue-700: oklch(48.8% 0.243 264.376); | ||||
|     --color-blue-900: oklch(37.9% 0.146 265.522); | ||||
|     --color-gray-50: oklch(98.5% 0.002 247.839); | ||||
|     --color-gray-100: oklch(96.7% 0.003 264.542); | ||||
|     --color-gray-200: oklch(92.8% 0.006 264.531); | ||||
| @@ -40,7 +43,10 @@ | ||||
|     --container-md: 28rem; | ||||
|     --container-lg: 32rem; | ||||
|     --container-2xl: 42rem; | ||||
|     --container-6xl: 72rem; | ||||
|     --container-7xl: 80rem; | ||||
|     --text-xs: 0.75rem; | ||||
|     --text-xs--line-height: calc(1 / 0.75); | ||||
|     --text-sm: 0.875rem; | ||||
|     --text-sm--line-height: calc(1.25 / 0.875); | ||||
|     --text-lg: 1.125rem; | ||||
| @@ -53,11 +59,14 @@ | ||||
|     --text-3xl--line-height: calc(2.25 / 1.875); | ||||
|     --text-4xl: 2.25rem; | ||||
|     --text-4xl--line-height: calc(2.5 / 2.25); | ||||
|     --text-5xl: 3rem; | ||||
|     --text-5xl--line-height: 1; | ||||
|     --text-6xl: 3.75rem; | ||||
|     --text-6xl--line-height: 1; | ||||
|     --font-weight-medium: 500; | ||||
|     --font-weight-semibold: 600; | ||||
|     --font-weight-bold: 700; | ||||
|     --font-weight-extrabold: 800; | ||||
|     --tracking-tight: -0.025em; | ||||
|     --radius-md: 0.375rem; | ||||
|     --radius-lg: 0.5rem; | ||||
| @@ -222,12 +231,24 @@ | ||||
|   .fixed { | ||||
|     position: fixed; | ||||
|   } | ||||
|   .relative { | ||||
|     position: relative; | ||||
|   } | ||||
|   .static { | ||||
|     position: static; | ||||
|   } | ||||
|   .sticky { | ||||
|     position: sticky; | ||||
|   } | ||||
|   .inset-0 { | ||||
|     inset: calc(var(--spacing) * 0); | ||||
|   } | ||||
|   .top-0 { | ||||
|     top: calc(var(--spacing) * 0); | ||||
|   } | ||||
|   .top-8 { | ||||
|     top: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .right-0 { | ||||
|     right: calc(var(--spacing) * 0); | ||||
|   } | ||||
| @@ -264,9 +285,15 @@ | ||||
|   .mt-1 { | ||||
|     margin-top: calc(var(--spacing) * 1); | ||||
|   } | ||||
|   .mt-2 { | ||||
|     margin-top: calc(var(--spacing) * 2); | ||||
|   } | ||||
|   .mt-4 { | ||||
|     margin-top: calc(var(--spacing) * 4); | ||||
|   } | ||||
|   .mt-5 { | ||||
|     margin-top: calc(var(--spacing) * 5); | ||||
|   } | ||||
|   .mt-6 { | ||||
|     margin-top: calc(var(--spacing) * 6); | ||||
|   } | ||||
| @@ -279,6 +306,12 @@ | ||||
|   .mt-\[64px\] { | ||||
|     margin-top: 64px; | ||||
|   } | ||||
|   .mr-1 { | ||||
|     margin-right: calc(var(--spacing) * 1); | ||||
|   } | ||||
|   .-mb-px { | ||||
|     margin-bottom: -1px; | ||||
|   } | ||||
|   .mb-1 { | ||||
|     margin-bottom: calc(var(--spacing) * 1); | ||||
|   } | ||||
| @@ -297,6 +330,9 @@ | ||||
|   .mb-8 { | ||||
|     margin-bottom: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .ml-3 { | ||||
|     margin-left: calc(var(--spacing) * 3); | ||||
|   } | ||||
|   .ml-4 { | ||||
|     margin-left: calc(var(--spacing) * 4); | ||||
|   } | ||||
| @@ -312,18 +348,39 @@ | ||||
|   .flex { | ||||
|     display: flex; | ||||
|   } | ||||
|   .grid { | ||||
|     display: grid; | ||||
|   } | ||||
|   .hidden { | ||||
|     display: none; | ||||
|   } | ||||
|   .inline { | ||||
|     display: inline; | ||||
|   } | ||||
|   .inline-flex { | ||||
|     display: inline-flex; | ||||
|   } | ||||
|   .table { | ||||
|     display: table; | ||||
|   } | ||||
|   .h-5 { | ||||
|     height: calc(var(--spacing) * 5); | ||||
|   } | ||||
|   .h-6 { | ||||
|     height: calc(var(--spacing) * 6); | ||||
|   } | ||||
|   .h-8 { | ||||
|     height: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .h-10 { | ||||
|     height: calc(var(--spacing) * 10); | ||||
|   } | ||||
|   .h-16 { | ||||
|     height: calc(var(--spacing) * 16); | ||||
|   } | ||||
|   .h-32 { | ||||
|     height: calc(var(--spacing) * 32); | ||||
|   } | ||||
|   .h-\[calc\(100dvh-118px\)\] { | ||||
|     height: calc(100dvh - 118px); | ||||
|   } | ||||
| @@ -333,18 +390,48 @@ | ||||
|   .min-h-screen { | ||||
|     min-height: 100vh; | ||||
|   } | ||||
|   .w-5 { | ||||
|     width: calc(var(--spacing) * 5); | ||||
|   } | ||||
|   .w-6 { | ||||
|     width: calc(var(--spacing) * 6); | ||||
|   } | ||||
|   .w-8 { | ||||
|     width: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .w-10 { | ||||
|     width: calc(var(--spacing) * 10); | ||||
|   } | ||||
|   .w-16 { | ||||
|     width: calc(var(--spacing) * 16); | ||||
|   } | ||||
|   .w-32 { | ||||
|     width: calc(var(--spacing) * 32); | ||||
|   } | ||||
|   .w-full { | ||||
|     width: 100%; | ||||
|   } | ||||
|   .max-w-2xl { | ||||
|     max-width: var(--container-2xl); | ||||
|   } | ||||
|   .max-w-6xl { | ||||
|     max-width: var(--container-6xl); | ||||
|   } | ||||
|   .max-w-lg { | ||||
|     max-width: var(--container-lg); | ||||
|   } | ||||
|   .max-w-md { | ||||
|     max-width: var(--container-md); | ||||
|   } | ||||
|   .flex-1 { | ||||
|     flex: 1; | ||||
|   } | ||||
|   .flex-shrink { | ||||
|     flex-shrink: 1; | ||||
|   } | ||||
|   .flex-shrink-0 { | ||||
|     flex-shrink: 0; | ||||
|   } | ||||
|   .flex-grow { | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| @@ -357,18 +444,56 @@ | ||||
|   .resize { | ||||
|     resize: both; | ||||
|   } | ||||
|   .list-inside { | ||||
|     list-style-position: inside; | ||||
|   } | ||||
|   .list-disc { | ||||
|     list-style-type: disc; | ||||
|   } | ||||
|   .grid-cols-1 { | ||||
|     grid-template-columns: repeat(1, minmax(0, 1fr)); | ||||
|   } | ||||
|   .flex-col { | ||||
|     flex-direction: column; | ||||
|   } | ||||
|   .items-center { | ||||
|     align-items: center; | ||||
|   } | ||||
|   .items-start { | ||||
|     align-items: flex-start; | ||||
|   } | ||||
|   .justify-around { | ||||
|     justify-content: space-around; | ||||
|   } | ||||
|   .justify-between { | ||||
|     justify-content: space-between; | ||||
|   } | ||||
|   .justify-center { | ||||
|     justify-content: center; | ||||
|   } | ||||
|   .justify-end { | ||||
|     justify-content: flex-end; | ||||
|   } | ||||
|   .gap-6 { | ||||
|     gap: calc(var(--spacing) * 6); | ||||
|   } | ||||
|   .gap-8 { | ||||
|     gap: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .space-y-1 { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       --tw-space-y-reverse: 0; | ||||
|       margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); | ||||
|       margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); | ||||
|     } | ||||
|   } | ||||
|   .space-y-3 { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       --tw-space-y-reverse: 0; | ||||
|       margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); | ||||
|       margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); | ||||
|     } | ||||
|   } | ||||
|   .space-y-4 { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       --tw-space-y-reverse: 0; | ||||
| @@ -376,14 +501,48 @@ | ||||
|       margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); | ||||
|     } | ||||
|   } | ||||
|   .space-y-6 { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       --tw-space-y-reverse: 0; | ||||
|       margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); | ||||
|       margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); | ||||
|     } | ||||
|   } | ||||
|   .space-y-8 { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       --tw-space-y-reverse: 0; | ||||
|       margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); | ||||
|       margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); | ||||
|     } | ||||
|   } | ||||
|   .gap-x-6 { | ||||
|     column-gap: calc(var(--spacing) * 6); | ||||
|   } | ||||
|   .divide-y { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       --tw-divide-y-reverse: 0; | ||||
|       border-bottom-style: var(--tw-border-style); | ||||
|       border-top-style: var(--tw-border-style); | ||||
|       border-top-width: calc(1px * var(--tw-divide-y-reverse)); | ||||
|       border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); | ||||
|     } | ||||
|   } | ||||
|   .divide-gray-200 { | ||||
|     :where(& > :not(:last-child)) { | ||||
|       border-color: var(--color-gray-200); | ||||
|     } | ||||
|   } | ||||
|   .truncate { | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
|   } | ||||
|   .overflow-hidden { | ||||
|     overflow: hidden; | ||||
|   } | ||||
|   .rounded-full { | ||||
|     border-radius: calc(infinity * 1px); | ||||
|   } | ||||
|   .rounded-lg { | ||||
|     border-radius: var(--radius-lg); | ||||
|   } | ||||
| @@ -398,9 +557,33 @@ | ||||
|     border-style: var(--tw-border-style); | ||||
|     border-width: 2px; | ||||
|   } | ||||
|   .border-t { | ||||
|     border-top-style: var(--tw-border-style); | ||||
|     border-top-width: 1px; | ||||
|   } | ||||
|   .border-b { | ||||
|     border-bottom-style: var(--tw-border-style); | ||||
|     border-bottom-width: 1px; | ||||
|   } | ||||
|   .border-b-2 { | ||||
|     border-bottom-style: var(--tw-border-style); | ||||
|     border-bottom-width: 2px; | ||||
|   } | ||||
|   .border-blue-500 { | ||||
|     border-color: var(--color-blue-500); | ||||
|   } | ||||
|   .border-gray-200 { | ||||
|     border-color: var(--color-gray-200); | ||||
|   } | ||||
|   .border-gray-300 { | ||||
|     border-color: var(--color-gray-300); | ||||
|   } | ||||
|   .border-transparent { | ||||
|     border-color: transparent; | ||||
|   } | ||||
|   .bg-blue-100 { | ||||
|     background-color: var(--color-blue-100); | ||||
|   } | ||||
|   .bg-blue-500 { | ||||
|     background-color: var(--color-blue-500); | ||||
|   } | ||||
| @@ -413,6 +596,9 @@ | ||||
|   .bg-gray-100 { | ||||
|     background-color: var(--color-gray-100); | ||||
|   } | ||||
|   .bg-gray-200 { | ||||
|     background-color: var(--color-gray-200); | ||||
|   } | ||||
|   .bg-green-100 { | ||||
|     background-color: var(--color-green-100); | ||||
|   } | ||||
| @@ -425,6 +611,9 @@ | ||||
|   .bg-yellow-100 { | ||||
|     background-color: var(--color-yellow-100); | ||||
|   } | ||||
|   .object-cover { | ||||
|     object-fit: cover; | ||||
|   } | ||||
|   .p-4 { | ||||
|     padding: calc(var(--spacing) * 4); | ||||
|   } | ||||
| @@ -434,6 +623,12 @@ | ||||
|   .p-8 { | ||||
|     padding: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .px-2 { | ||||
|     padding-inline: calc(var(--spacing) * 2); | ||||
|   } | ||||
|   .px-2\.5 { | ||||
|     padding-inline: calc(var(--spacing) * 2.5); | ||||
|   } | ||||
|   .px-3 { | ||||
|     padding-inline: calc(var(--spacing) * 3); | ||||
|   } | ||||
| @@ -446,15 +641,42 @@ | ||||
|   .px-8 { | ||||
|     padding-inline: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .py-0 { | ||||
|     padding-block: calc(var(--spacing) * 0); | ||||
|   } | ||||
|   .py-0\.5 { | ||||
|     padding-block: calc(var(--spacing) * 0.5); | ||||
|   } | ||||
|   .py-2 { | ||||
|     padding-block: calc(var(--spacing) * 2); | ||||
|   } | ||||
|   .py-2\.5 { | ||||
|     padding-block: calc(var(--spacing) * 2.5); | ||||
|   } | ||||
|   .py-3 { | ||||
|     padding-block: calc(var(--spacing) * 3); | ||||
|   } | ||||
|   .py-4 { | ||||
|     padding-block: calc(var(--spacing) * 4); | ||||
|   } | ||||
|   .py-5 { | ||||
|     padding-block: calc(var(--spacing) * 5); | ||||
|   } | ||||
|   .py-8 { | ||||
|     padding-block: calc(var(--spacing) * 8); | ||||
|   } | ||||
|   .py-12 { | ||||
|     padding-block: calc(var(--spacing) * 12); | ||||
|   } | ||||
|   .pt-0\.5 { | ||||
|     padding-top: calc(var(--spacing) * 0.5); | ||||
|   } | ||||
|   .pt-4 { | ||||
|     padding-top: calc(var(--spacing) * 4); | ||||
|   } | ||||
|   .pt-6 { | ||||
|     padding-top: calc(var(--spacing) * 6); | ||||
|   } | ||||
|   .pt-8 { | ||||
|     padding-top: calc(var(--spacing) * 8); | ||||
|   } | ||||
| @@ -467,6 +689,9 @@ | ||||
|   .text-left { | ||||
|     text-align: left; | ||||
|   } | ||||
|   .text-right { | ||||
|     text-align: right; | ||||
|   } | ||||
|   .text-2xl { | ||||
|     font-size: var(--text-2xl); | ||||
|     line-height: var(--tw-leading, var(--text-2xl--line-height)); | ||||
| @@ -479,6 +704,10 @@ | ||||
|     font-size: var(--text-4xl); | ||||
|     line-height: var(--tw-leading, var(--text-4xl--line-height)); | ||||
|   } | ||||
|   .text-5xl { | ||||
|     font-size: var(--text-5xl); | ||||
|     line-height: var(--tw-leading, var(--text-5xl--line-height)); | ||||
|   } | ||||
|   .text-lg { | ||||
|     font-size: var(--text-lg); | ||||
|     line-height: var(--tw-leading, var(--text-lg--line-height)); | ||||
| @@ -491,6 +720,14 @@ | ||||
|     font-size: var(--text-xl); | ||||
|     line-height: var(--tw-leading, var(--text-xl--line-height)); | ||||
|   } | ||||
|   .text-xs { | ||||
|     font-size: var(--text-xs); | ||||
|     line-height: var(--tw-leading, var(--text-xs--line-height)); | ||||
|   } | ||||
|   .leading-6 { | ||||
|     --tw-leading: calc(var(--spacing) * 6); | ||||
|     line-height: calc(var(--spacing) * 6); | ||||
|   } | ||||
|   .leading-8 { | ||||
|     --tw-leading: calc(var(--spacing) * 8); | ||||
|     line-height: calc(var(--spacing) * 8); | ||||
| @@ -499,6 +736,10 @@ | ||||
|     --tw-font-weight: var(--font-weight-bold); | ||||
|     font-weight: var(--font-weight-bold); | ||||
|   } | ||||
|   .font-extrabold { | ||||
|     --tw-font-weight: var(--font-weight-extrabold); | ||||
|     font-weight: var(--font-weight-extrabold); | ||||
|   } | ||||
|   .font-medium { | ||||
|     --tw-font-weight: var(--font-weight-medium); | ||||
|     font-weight: var(--font-weight-medium); | ||||
| @@ -529,6 +770,9 @@ | ||||
|   .text-gray-900 { | ||||
|     color: var(--color-gray-900); | ||||
|   } | ||||
|   .text-green-500 { | ||||
|     color: var(--color-green-500); | ||||
|   } | ||||
|   .text-green-600 { | ||||
|     color: var(--color-green-600); | ||||
|   } | ||||
| @@ -553,6 +797,10 @@ | ||||
|   .opacity-80 { | ||||
|     opacity: 80%; | ||||
|   } | ||||
|   .shadow { | ||||
|     --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); | ||||
|     box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); | ||||
|   } | ||||
|   .shadow-\[0_-1px_3px_0_rgba\(0\,0\,0\,0\.1\)\] { | ||||
|     --tw-shadow: 0 -1px 3px 0 var(--tw-shadow-color, rgba(0,0,0,0.1)); | ||||
|     box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); | ||||
| @@ -585,6 +833,14 @@ | ||||
|     transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); | ||||
|     transition-duration: var(--tw-duration, var(--default-transition-duration)); | ||||
|   } | ||||
|   .duration-150 { | ||||
|     --tw-duration: 150ms; | ||||
|     transition-duration: 150ms; | ||||
|   } | ||||
|   .duration-200 { | ||||
|     --tw-duration: 200ms; | ||||
|     transition-duration: 200ms; | ||||
|   } | ||||
|   .hover\:scale-105 { | ||||
|     &:hover { | ||||
|       @media (hover: hover) { | ||||
| @@ -595,6 +851,13 @@ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .hover\:border-gray-300 { | ||||
|     &:hover { | ||||
|       @media (hover: hover) { | ||||
|         border-color: var(--color-gray-300); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .hover\:bg-blue-600 { | ||||
|     &:hover { | ||||
|       @media (hover: hover) { | ||||
| @@ -609,6 +872,13 @@ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .hover\:bg-gray-50 { | ||||
|     &:hover { | ||||
|       @media (hover: hover) { | ||||
|         background-color: var(--color-gray-50); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .hover\:bg-gray-100 { | ||||
|     &:hover { | ||||
|       @media (hover: hover) { | ||||
| @@ -676,23 +946,101 @@ | ||||
|       --tw-ring-color: var(--color-red-500); | ||||
|     } | ||||
|   } | ||||
|   .focus\:ring-offset-2 { | ||||
|     &:focus { | ||||
|       --tw-ring-offset-width: 2px; | ||||
|       --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); | ||||
|     } | ||||
|   } | ||||
|   .focus\:outline-none { | ||||
|     &:focus { | ||||
|       --tw-outline-style: none; | ||||
|       outline-style: none; | ||||
|     } | ||||
|   } | ||||
|   .sm\:mx-auto { | ||||
|     @media (width >= 40rem) { | ||||
|       margin-inline: auto; | ||||
|     } | ||||
|   } | ||||
|   .sm\:w-full { | ||||
|     @media (width >= 40rem) { | ||||
|       width: 100%; | ||||
|     } | ||||
|   } | ||||
|   .sm\:max-w-md { | ||||
|     @media (width >= 40rem) { | ||||
|       max-width: var(--container-md); | ||||
|     } | ||||
|   } | ||||
|   .sm\:rounded-lg { | ||||
|     @media (width >= 40rem) { | ||||
|       border-radius: var(--radius-lg); | ||||
|     } | ||||
|   } | ||||
|   .sm\:px-6 { | ||||
|     @media (width >= 40rem) { | ||||
|       padding-inline: calc(var(--spacing) * 6); | ||||
|     } | ||||
|   } | ||||
|   .sm\:px-10 { | ||||
|     @media (width >= 40rem) { | ||||
|       padding-inline: calc(var(--spacing) * 10); | ||||
|     } | ||||
|   } | ||||
|   .sm\:text-6xl { | ||||
|     @media (width >= 40rem) { | ||||
|       font-size: var(--text-6xl); | ||||
|       line-height: var(--tw-leading, var(--text-6xl--line-height)); | ||||
|     } | ||||
|   } | ||||
|   .md\:w-1\/3 { | ||||
|     @media (width >= 48rem) { | ||||
|       width: calc(1/3 * 100%); | ||||
|     } | ||||
|   } | ||||
|   .md\:grid-cols-2 { | ||||
|     @media (width >= 48rem) { | ||||
|       grid-template-columns: repeat(2, minmax(0, 1fr)); | ||||
|     } | ||||
|   } | ||||
|   .md\:flex-row { | ||||
|     @media (width >= 48rem) { | ||||
|       flex-direction: row; | ||||
|     } | ||||
|   } | ||||
|   .lg\:w-1\/4 { | ||||
|     @media (width >= 64rem) { | ||||
|       width: calc(1/4 * 100%); | ||||
|     } | ||||
|   } | ||||
|   .lg\:px-8 { | ||||
|     @media (width >= 64rem) { | ||||
|       padding-inline: calc(var(--spacing) * 8); | ||||
|     } | ||||
|   } | ||||
|   .dark\:divide-gray-700 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       :where(& > :not(:last-child)) { | ||||
|         border-color: var(--color-gray-700); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .dark\:border-gray-600 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       border-color: var(--color-gray-600); | ||||
|     } | ||||
|   } | ||||
|   .dark\:border-gray-700 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       border-color: var(--color-gray-700); | ||||
|     } | ||||
|   } | ||||
|   .dark\:bg-blue-900 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       background-color: var(--color-blue-900); | ||||
|     } | ||||
|   } | ||||
|   .dark\:bg-gray-700 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       background-color: var(--color-gray-700); | ||||
| @@ -767,6 +1115,15 @@ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .dark\:hover\:bg-gray-700 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       &:hover { | ||||
|         @media (hover: hover) { | ||||
|           background-color: var(--color-gray-700); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .dark\:hover\:text-blue-300 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       &:hover { | ||||
| @@ -785,6 +1142,15 @@ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .dark\:hover\:text-gray-200 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       &:hover { | ||||
|         @media (hover: hover) { | ||||
|           color: var(--color-gray-200); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .dark\:hover\:text-gray-300 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       &:hover { | ||||
| @@ -794,6 +1160,13 @@ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .dark\:focus\:ring-offset-gray-800 { | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       &:focus { | ||||
|         --tw-ring-offset-color: var(--color-gray-800); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @layer theme, base, components, utilities; | ||||
| @layer theme; | ||||
| @@ -1041,6 +1414,11 @@ | ||||
|   inherits: false; | ||||
|   initial-value: 0; | ||||
| } | ||||
| @property --tw-divide-y-reverse { | ||||
|   syntax: "*"; | ||||
|   inherits: false; | ||||
|   initial-value: 0; | ||||
| } | ||||
| @property --tw-border-style { | ||||
|   syntax: "*"; | ||||
|   inherits: false; | ||||
| @@ -1181,6 +1559,10 @@ | ||||
|   syntax: "*"; | ||||
|   inherits: false; | ||||
| } | ||||
| @property --tw-duration { | ||||
|   syntax: "*"; | ||||
|   inherits: false; | ||||
| } | ||||
| @property --tw-scale-x { | ||||
|   syntax: "*"; | ||||
|   inherits: false; | ||||
| @@ -1205,6 +1587,7 @@ | ||||
|       --tw-skew-x: initial; | ||||
|       --tw-skew-y: initial; | ||||
|       --tw-space-y-reverse: 0; | ||||
|       --tw-divide-y-reverse: 0; | ||||
|       --tw-border-style: solid; | ||||
|       --tw-leading: initial; | ||||
|       --tw-font-weight: initial; | ||||
| @@ -1237,6 +1620,7 @@ | ||||
|       --tw-drop-shadow-color: initial; | ||||
|       --tw-drop-shadow-alpha: 100%; | ||||
|       --tw-drop-shadow-size: initial; | ||||
|       --tw-duration: initial; | ||||
|       --tw-scale-x: 1; | ||||
|       --tw-scale-y: 1; | ||||
|       --tw-scale-z: 1; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user