🐛 Dozen of bugs fixes
This commit is contained in:
		| @@ -1,12 +1,19 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Encodings.Web; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Sphere.Storage; | ||||
| using DysonNetwork.Sphere.Storage.Handlers; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
| using System.Text; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Controllers; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
| using SystemClock = NodaTime.SystemClock; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth; | ||||
| @@ -22,6 +29,7 @@ public enum TokenType | ||||
| { | ||||
|     AuthKey, | ||||
|     ApiKey, | ||||
|     OidcKey, | ||||
|     Unknown | ||||
| } | ||||
|  | ||||
| @@ -39,6 +47,7 @@ public class DysonTokenAuthHandler( | ||||
|     ILoggerFactory logger, | ||||
|     UrlEncoder encoder, | ||||
|     AppDatabase database, | ||||
|     OidcProviderService oidc, | ||||
|     ICacheService cache, | ||||
|     FlushBufferService fbs | ||||
| ) | ||||
| @@ -142,35 +151,60 @@ public class DysonTokenAuthHandler( | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             // Split the token | ||||
|             var parts = token.Split('.'); | ||||
|             if (parts.Length != 2) | ||||
|                 return false; | ||||
|  | ||||
|             // Decode the payload | ||||
|             var payloadBytes = Base64UrlDecode(parts[0]); | ||||
|             switch (parts.Length) | ||||
|             { | ||||
|                 // Handle JWT tokens (3 parts) | ||||
|                 case 3: | ||||
|                 { | ||||
|                     var (isValid, jwtResult) = oidc.ValidateToken(token); | ||||
|                     if (!isValid) return false; | ||||
|                     var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; | ||||
|                     if (jti is null) return false; | ||||
|  | ||||
|             // Extract session ID | ||||
|             sessionId = new Guid(payloadBytes); | ||||
|                     return Guid.TryParse(jti, out sessionId); | ||||
|                 } | ||||
|                 // Handle compact tokens (2 parts) | ||||
|                 case 2: | ||||
|                     // Original compact token validation logic | ||||
|                     try | ||||
|                     { | ||||
|                         // Decode the payload | ||||
|                         var payloadBytes = Base64UrlDecode(parts[0]); | ||||
|  | ||||
|             // Load public key for verification | ||||
|             var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!); | ||||
|             using var rsa = RSA.Create(); | ||||
|             rsa.ImportFromPem(publicKeyPem); | ||||
|                         // Extract session ID | ||||
|                         sessionId = new Guid(payloadBytes); | ||||
|  | ||||
|             // Verify signature | ||||
|             var signature = Base64UrlDecode(parts[1]); | ||||
|             return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); | ||||
|                         // Load public key for verification | ||||
|                         var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!); | ||||
|                         using var rsa = RSA.Create(); | ||||
|                         rsa.ImportFromPem(publicKeyPem); | ||||
|  | ||||
|                         // Verify signature | ||||
|                         var signature = Base64UrlDecode(parts[1]); | ||||
|                         return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); | ||||
|                     } | ||||
|                     catch | ||||
|                     { | ||||
|                         return false; | ||||
|                     } | ||||
|  | ||||
|                     break; | ||||
|                 default: | ||||
|                     return false; | ||||
|             } | ||||
|         } | ||||
|         catch | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             Logger.LogWarning(ex, "Token validation failed"); | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static byte[] Base64UrlDecode(string base64Url) | ||||
|     { | ||||
|         string padded = base64Url | ||||
|         var padded = base64Url | ||||
|             .Replace('-', '+') | ||||
|             .Replace('_', '/'); | ||||
|  | ||||
| @@ -195,20 +229,23 @@ public class DysonTokenAuthHandler( | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         // Check for token in Authorization header | ||||
|         var authHeader = request.Headers.Authorization.ToString(); | ||||
|         if (!string.IsNullOrEmpty(authHeader)) | ||||
|         { | ||||
|             if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 var token = authHeader["Bearer ".Length..].Trim(); | ||||
|                 var parts = token.Split('.'); | ||||
|                  | ||||
|                 return new TokenInfo | ||||
|                 { | ||||
|                     Token = authHeader["Bearer ".Length..].Trim(), | ||||
|                     Type = TokenType.AuthKey | ||||
|                     Token = token, | ||||
|                     Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase)) | ||||
|             else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return new TokenInfo | ||||
|                 { | ||||
| @@ -216,8 +253,7 @@ public class DysonTokenAuthHandler( | ||||
|                     Type = TokenType.AuthKey | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase)) | ||||
|             else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return new TokenInfo | ||||
|                 { | ||||
| @@ -233,10 +269,11 @@ public class DysonTokenAuthHandler( | ||||
|             return new TokenInfo | ||||
|             { | ||||
|                 Token = cookieToken, | ||||
|                 Type = TokenType.AuthKey | ||||
|                 Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
| @@ -156,7 +156,7 @@ public class AuthService( | ||||
|     public string CreateToken(Session session) | ||||
|     { | ||||
|         // Load the private key for signing | ||||
|         var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!); | ||||
|         var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!); | ||||
|         using var rsa = RSA.Create(); | ||||
|         rsa.ImportFromPem(privateKeyPem); | ||||
|  | ||||
| @@ -263,7 +263,7 @@ public class AuthService( | ||||
|             sessionId = new Guid(payloadBytes); | ||||
|  | ||||
|             // Load public key for verification | ||||
|             var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!); | ||||
|             var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); | ||||
|             using var rsa = RSA.Create(); | ||||
|             rsa.ImportFromPem(publicKeyPem); | ||||
|  | ||||
|   | ||||
| @@ -4,8 +4,8 @@ namespace DysonNetwork.Sphere.Auth; | ||||
|  | ||||
| public class CompactTokenService(IConfiguration config) | ||||
| { | ||||
|     private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"]  | ||||
|         ?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing"); | ||||
|     private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]  | ||||
|         ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing"); | ||||
|      | ||||
|     public string CreateToken(Session session) | ||||
|     { | ||||
| @@ -54,7 +54,7 @@ public class CompactTokenService(IConfiguration config) | ||||
|             sessionId = new Guid(payloadBytes); | ||||
|              | ||||
|             // Load public key for verification | ||||
|             var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!); | ||||
|             var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); | ||||
|             using var rsa = RSA.Create(); | ||||
|             rsa.ImportFromPem(publicKeyPem); | ||||
|              | ||||
|   | ||||
| @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; | ||||
|  | ||||
| @@ -110,7 +111,7 @@ public class OidcProviderController( | ||||
|         return Ok(userInfo); | ||||
|     } | ||||
|  | ||||
|     [HttpGet(".well-known/openid-configuration")] | ||||
|     [HttpGet("/.well-known/openid-configuration")] | ||||
|     public IActionResult GetConfiguration() | ||||
|     { | ||||
|         var baseUrl = configuration["BaseUrl"]; | ||||
| @@ -119,10 +120,10 @@ public class OidcProviderController( | ||||
|         return Ok(new | ||||
|         { | ||||
|             issuer = issuer, | ||||
|             authorization_endpoint = $"{baseUrl}/connect/authorize", | ||||
|             authorization_endpoint = $"{baseUrl}/auth/authorize", | ||||
|             token_endpoint = $"{baseUrl}/auth/open/token", | ||||
|             userinfo_endpoint = $"{baseUrl}/auth/open/userinfo", | ||||
|             jwks_uri = $"{baseUrl}/.well-known/openid-configuration/jwks", | ||||
|             jwks_uri = $"{baseUrl}/.well-known/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" }, | ||||
| @@ -139,11 +140,17 @@ public class OidcProviderController( | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("jwks")] | ||||
|     [HttpGet("/.well-known/jwks")] | ||||
|     public IActionResult GetJwks() | ||||
|     { | ||||
|         var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey); | ||||
|         var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8]) | ||||
|         using var rsa = options.Value.GetRsaPublicKey(); | ||||
|         if (rsa == null) | ||||
|         { | ||||
|             return BadRequest("Public key is not configured"); | ||||
|         } | ||||
|  | ||||
|         var parameters = rsa.ExportParameters(false); | ||||
|         var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8]) | ||||
|             .Replace("+", "-") | ||||
|             .Replace("/", "_") | ||||
|             .Replace("=", ""); | ||||
| @@ -154,11 +161,12 @@ public class OidcProviderController( | ||||
|             { | ||||
|                 new | ||||
|                 { | ||||
|                     kty = "oct", | ||||
|                     kty = "RSA", | ||||
|                     use = "sig", | ||||
|                     kid = keyId, | ||||
|                     k = Convert.ToBase64String(keyBytes), | ||||
|                     alg = "HS256" | ||||
|                     n = Base64UrlEncoder.Encode(parameters.Modulus!), | ||||
|                     e = Base64UrlEncoder.Encode(parameters.Exponent!), | ||||
|                     alg = "RS256" | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|   | ||||
| @@ -1,13 +1,36 @@ | ||||
| using System; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| 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 string? PublicKeyPath { get; set; } | ||||
|     public string? PrivateKeyPath { get; set; } | ||||
|     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; | ||||
| } | ||||
|  | ||||
|     public RSA? GetRsaPrivateKey() | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath)) | ||||
|             return null; | ||||
|  | ||||
|         var privateKey = File.ReadAllText(PrivateKeyPath); | ||||
|         var rsa = RSA.Create(); | ||||
|         rsa.ImportFromPem(privateKey.AsSpan()); | ||||
|         return rsa; | ||||
|     } | ||||
|  | ||||
|     public RSA? GetRsaPublicKey() | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath)) | ||||
|             return null; | ||||
|  | ||||
|         var publicKey = File.ReadAllText(PublicKeyPath); | ||||
|         var rsa = RSA.Create(); | ||||
|         rsa.ImportFromPem(publicKey.AsSpan()); | ||||
|         return rsa; | ||||
|     } | ||||
| } | ||||
| @@ -75,7 +75,7 @@ public class OidcProviderService( | ||||
|  | ||||
|         // Generate access token | ||||
|         var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); | ||||
|         var refreshToken = GenerateRefreshToken(); | ||||
|         var refreshToken = GenerateRefreshToken(session); | ||||
|  | ||||
|         // In a real implementation, you would store the token in the database | ||||
|         // For this example, we'll just return the token without storing it | ||||
| @@ -92,35 +92,38 @@ public class OidcProviderService( | ||||
|     } | ||||
|  | ||||
|     private string GenerateJwtToken( | ||||
|         CustomApp client, | ||||
|         Session session, | ||||
|         Instant expiresAt, | ||||
|         IEnumerable<string>? scopes = null | ||||
|     CustomApp client, | ||||
|     Session session, | ||||
|     Instant expiresAt, | ||||
|     IEnumerable<string>? scopes = null | ||||
|     ) | ||||
|     { | ||||
|         var tokenHandler = new JwtSecurityTokenHandler(); | ||||
|         var key = Encoding.ASCII.GetBytes(_options.SigningKey); | ||||
|  | ||||
|         var clock = SystemClock.Instance; | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|  | ||||
|         var tokenDescriptor = new SecurityTokenDescriptor | ||||
|         { | ||||
|             Subject = new ClaimsIdentity([ | ||||
|                 new Claim(JwtRegisteredClaimNames.Sub, session.Id.ToString()), | ||||
|                 new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), | ||||
|                 new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(), | ||||
|                     ClaimValueTypes.Integer64), | ||||
|                 new Claim("client_id", client.Id.ToString()) | ||||
|             new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), | ||||
|             new Claim(JwtRegisteredClaimNames.Iat, now.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 | ||||
|             ) | ||||
|             Audience = client.Id.ToString() | ||||
|         }; | ||||
|  | ||||
|         // Add scopes as claims if provided, otherwise use client's default scopes | ||||
|         // Try to use RSA signing if keys are available, fall back to HMAC | ||||
|         var rsaPrivateKey = _options.GetRsaPrivateKey(); | ||||
|         tokenDescriptor.SigningCredentials = new SigningCredentials( | ||||
|             new RsaSecurityKey(rsaPrivateKey), | ||||
|             SecurityAlgorithms.RsaSha256 | ||||
|         ); | ||||
|  | ||||
|         // Add scopes as claims if provided | ||||
|         var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>(); | ||||
|         if (effectiveScopes.Any()) | ||||
|         { | ||||
| @@ -132,15 +135,40 @@ public class OidcProviderService( | ||||
|         return tokenHandler.WriteToken(token); | ||||
|     } | ||||
|  | ||||
|     private static string GenerateRefreshToken() | ||||
|     public (bool isValid, JwtSecurityToken? token) ValidateToken(string token) | ||||
|     { | ||||
|         using var rng = RandomNumberGenerator.Create(); | ||||
|         var bytes = new byte[32]; | ||||
|         rng.GetBytes(bytes); | ||||
|         return Convert.ToBase64String(bytes) | ||||
|             .Replace("+", "-") | ||||
|             .Replace("/", "_") | ||||
|             .Replace("=", ""); | ||||
|         try | ||||
|         { | ||||
|             var tokenHandler = new JwtSecurityTokenHandler(); | ||||
|             var validationParameters = new TokenValidationParameters | ||||
|             { | ||||
|                 ValidateIssuer = true, | ||||
|                 ValidIssuer = _options.IssuerUri, | ||||
|                 ValidateAudience = true, | ||||
|                 ValidateLifetime = true, | ||||
|                 ClockSkew = TimeSpan.Zero | ||||
|             }; | ||||
|  | ||||
|             // Try to use RSA validation if public key is available | ||||
|             var rsaPublicKey = _options.GetRsaPublicKey(); | ||||
|             validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey); | ||||
|             validationParameters.ValidateIssuerSigningKey = true; | ||||
|             validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }; | ||||
|  | ||||
|  | ||||
|             tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); | ||||
|             return (true, (JwtSecurityToken)validatedToken); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Token validation failed"); | ||||
|             return (false, null); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string GenerateRefreshToken(Session session) | ||||
|     { | ||||
|         return Convert.ToBase64String(Encoding.UTF8.GetBytes(session.Id.ToString())); | ||||
|     } | ||||
|  | ||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) | ||||
| @@ -150,35 +178,6 @@ public class OidcProviderService( | ||||
|         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; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Authorization codes are now managed through ICacheService | ||||
|  | ||||
|     public async Task<string> GenerateAuthorizationCodeAsync( | ||||
|         Guid clientId, | ||||
|         Guid userId, | ||||
|   | ||||
| @@ -9,16 +9,14 @@ using DysonNetwork.Sphere.Developer; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Pages.Auth; | ||||
|  | ||||
| [Authorize] | ||||
| public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
| { | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? ReturnUrl { get; set; } | ||||
|     [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")] | ||||
| @@ -27,22 +25,19 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
|     [BindProperty(SupportsGet = true, Name = "redirect_uri")] | ||||
|     public string? RedirectUri { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? Scope { get; set; } | ||||
|     [BindProperty(SupportsGet = true)] public string? Scope { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true)] | ||||
|     public string? State { get; set; } | ||||
|     [BindProperty(SupportsGet = true)] public string? State { get; set; } | ||||
|  | ||||
|     [BindProperty(SupportsGet = true)] public string? Nonce { 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; } | ||||
|  | ||||
| @@ -50,21 +45,21 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
|     public string? AppLogo { get; set; } | ||||
|     public string? AppUri { get; set; } | ||||
|     public string[]? RequestedScopes { get; set; } | ||||
|      | ||||
|  | ||||
|     public async Task<IActionResult> OnGetAsync() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account) | ||||
|         { | ||||
|             var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}"); | ||||
|             return RedirectToPage($"/Auth/Login?returnUrl={returnUrl}"); | ||||
|             return RedirectToPage("/Auth/Login", new { returnUrl }); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         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); | ||||
| @@ -73,7 +68,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
|             ModelState.AddModelError("client_id", "Client not found"); | ||||
|             return NotFound("Client not found"); | ||||
|         } | ||||
|          | ||||
|  | ||||
|         if (client.Status != CustomAppStatus.Developing) | ||||
|         { | ||||
|             // Validate redirect URI for non-Developing apps | ||||
| @@ -93,14 +88,14 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
|     public async Task<IActionResult> OnPostAsync(bool allow) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser) return Unauthorized(); | ||||
|          | ||||
|  | ||||
|         // 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 a client exists | ||||
| @@ -116,14 +111,14 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
|             // 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()); | ||||
|         } | ||||
|  | ||||
| @@ -147,20 +142,20 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel | ||||
|         // 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()); | ||||
|     } | ||||
| } | ||||
| } | ||||
| @@ -26,7 +26,7 @@ | ||||
|                 { | ||||
|                     <div class="mb-4"> | ||||
|                         <form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id"> | ||||
|                             <input type="hidden" name="factorId" value="@factor.Id"/> | ||||
|                             <input type="hidden" name="SelectedFactorId" value="@factor.Id"/> | ||||
|                              | ||||
|                             @if (factor.Type == AccountAuthFactorType.EmailCode) | ||||
|                             { | ||||
|   | ||||
| @@ -39,7 +39,7 @@ public class SelectFactorModel( | ||||
|         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)) | ||||
|         { | ||||
| @@ -50,10 +50,14 @@ public class SelectFactorModel( | ||||
|         try | ||||
|         { | ||||
|             // For OTP factors that require code delivery | ||||
|             if (factor.Type == AccountAuthFactorType.EmailCode  | ||||
|                 && string.IsNullOrWhiteSpace(Hint)) | ||||
|             if ( | ||||
|                 factor.Type == AccountAuthFactorType.EmailCode | ||||
|                 && string.IsNullOrWhiteSpace(Hint) | ||||
|             ) | ||||
|             { | ||||
|                 ModelState.AddModelError(string.Empty, $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to."); | ||||
|                 ModelState.AddModelError(string.Empty, | ||||
|                     $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to." | ||||
|                 ); | ||||
|                 await LoadChallengeAndFactors(); | ||||
|                 return Page(); | ||||
|             } | ||||
| @@ -62,31 +66,30 @@ public class SelectFactorModel( | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             ModelState.AddModelError(string.Empty, $"An error occurred while sending the verification code: {ex.Message}"); | ||||
|             ModelState.AddModelError(string.Empty, | ||||
|                 $"An error occurred while sending the verification code: {ex.Message}"); | ||||
|             await LoadChallengeAndFactors(); | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
|         // 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 }); | ||||
|         return !string.IsNullOrEmpty(ReturnUrl) | ||||
|             ? RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl }) | ||||
|             : RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id }); | ||||
|     } | ||||
|  | ||||
|     private async Task LoadChallengeAndFactors() | ||||
|     { | ||||
|         AuthChallenge = await db.AuthChallenges | ||||
|             .Include(e => e.Account) | ||||
|             .ThenInclude(e => e.AuthFactors) | ||||
|             .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|         if (AuthChallenge != null) | ||||
|         { | ||||
|             AuthFactors = AuthChallenge.Account.AuthFactors | ||||
|                 .Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }) | ||||
|                 .ToList(); | ||||
|             AuthFactors = await db.AccountAuthFactors | ||||
|                 .Where(e => e.AccountId == AuthChallenge.Account.Id) | ||||
|                 .Where(e => e.EnabledAt != null && e.Trustworthy >= 1) | ||||
|                 .ToListAsync(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -8,54 +8,35 @@ using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Pages.Auth | ||||
| { | ||||
|     public class VerifyFactorModel : PageModel | ||||
|     public class VerifyFactorModel( | ||||
|         AppDatabase db, | ||||
|         AccountService accounts, | ||||
|         AuthService auth, | ||||
|         ActionLogService als, | ||||
|         IConfiguration configuration, | ||||
|         IHttpClientFactory httpClientFactory | ||||
|     ) | ||||
|         : PageModel | ||||
|     { | ||||
|         private readonly AppDatabase _db; | ||||
|         private readonly AccountService _accounts; | ||||
|         private readonly AuthService _auth; | ||||
|         private readonly ActionLogService _als; | ||||
|         private readonly IConfiguration _configuration; | ||||
|         private readonly IHttpClientFactory _httpClientFactory; | ||||
|         [BindProperty(SupportsGet = true)] public Guid Id { get; set; } | ||||
|  | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid Id { get; set; } | ||||
|         [BindProperty(SupportsGet = true)] public Guid FactorId { get; set; } | ||||
|  | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public Guid FactorId { get; set; } | ||||
|          | ||||
|         [BindProperty(SupportsGet = true)] | ||||
|         public string? ReturnUrl { get; set; } | ||||
|         [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } | ||||
|  | ||||
|         [BindProperty, Required] | ||||
|         public string Code { get; set; } = string.Empty; | ||||
|         [BindProperty, Required] public string Code { get; set; } = string.Empty; | ||||
|  | ||||
|         public Challenge? AuthChallenge { get; set; } | ||||
|         public AccountAuthFactor? Factor { get; set; } | ||||
|         public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode; | ||||
|  | ||||
|         public VerifyFactorModel( | ||||
|             AppDatabase db, | ||||
|             AccountService accounts, | ||||
|             AuthService auth, | ||||
|             ActionLogService als, | ||||
|             IConfiguration configuration, | ||||
|             IHttpClientFactory httpClientFactory) | ||||
|         { | ||||
|             _db = db; | ||||
|             _accounts = accounts; | ||||
|             _auth = auth; | ||||
|             _als = als; | ||||
|             _configuration = configuration; | ||||
|             _httpClientFactory = httpClientFactory; | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnGetAsync() | ||||
|         { | ||||
|             await LoadChallengeAndFactor(); | ||||
|             if (AuthChallenge == null) return NotFound("Challenge not found or expired."); | ||||
|             if (Factor == null) return NotFound("Authentication method not found."); | ||||
|             if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); | ||||
|              | ||||
|  | ||||
|             return Page(); | ||||
|         } | ||||
|  | ||||
| @@ -73,25 +54,25 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 if (await _accounts.VerifyFactorCode(Factor, Code)) | ||||
|                 if (await accounts.VerifyFactorCode(Factor, Code)) | ||||
|                 { | ||||
|                     AuthChallenge.StepRemain -= Factor.Trustworthy; | ||||
|                     AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain); | ||||
|                     AuthChallenge.BlacklistFactors.Add(Factor.Id); | ||||
|                     _db.Update(AuthChallenge); | ||||
|                     db.Update(AuthChallenge); | ||||
|  | ||||
|                     _als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, | ||||
|                     als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, | ||||
|                         new Dictionary<string, object> | ||||
|                         { | ||||
|                             { "challenge_id", AuthChallenge.Id }, | ||||
|                             { "factor_id", Factor?.Id.ToString() ?? string.Empty } | ||||
|                         }, Request, AuthChallenge.Account); | ||||
|  | ||||
|                     await _db.SaveChangesAsync(); | ||||
|                     await db.SaveChangesAsync(); | ||||
|  | ||||
|                     if (AuthChallenge.StepRemain == 0) | ||||
|                     { | ||||
|                         _als.CreateActionLogFromRequest(ActionLogType.NewLogin, | ||||
|                         als.CreateActionLogFromRequest(ActionLogType.NewLogin, | ||||
|                             new Dictionary<string, object> | ||||
|                             { | ||||
|                                 { "challenge_id", AuthChallenge.Id }, | ||||
| @@ -104,7 +85,7 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|                     else | ||||
|                     { | ||||
|                         // If more steps are needed, redirect back to select factor | ||||
|                         return RedirectToPage("SelectFactor", new { id = Id }); | ||||
|                         return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl }); | ||||
|                     } | ||||
|                 } | ||||
|                 else | ||||
| @@ -117,10 +98,10 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|                 if (AuthChallenge != null) | ||||
|                 { | ||||
|                     AuthChallenge.FailedAttempts++; | ||||
|                     _db.Update(AuthChallenge); | ||||
|                     await _db.SaveChangesAsync(); | ||||
|                     db.Update(AuthChallenge); | ||||
|                     await db.SaveChangesAsync(); | ||||
|  | ||||
|                     _als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, | ||||
|                     als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, | ||||
|                         new Dictionary<string, object> | ||||
|                         { | ||||
|                             { "challenge_id", AuthChallenge.Id }, | ||||
| @@ -136,30 +117,30 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|  | ||||
|         private async Task LoadChallengeAndFactor() | ||||
|         { | ||||
|             AuthChallenge = await _db.AuthChallenges | ||||
|             AuthChallenge = await db.AuthChallenges | ||||
|                 .Include(e => e.Account) | ||||
|                 .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|             if (AuthChallenge?.Account != null) | ||||
|             { | ||||
|                 Factor = await _db.AccountAuthFactors | ||||
|                     .FirstOrDefaultAsync(e => e.Id == FactorId &&  | ||||
|                                            e.AccountId == AuthChallenge.Account.Id && | ||||
|                                            e.EnabledAt != null &&  | ||||
|                                            e.Trustworthy > 0); | ||||
|                 Factor = await db.AccountAuthFactors | ||||
|                     .FirstOrDefaultAsync(e => e.Id == FactorId && | ||||
|                                               e.AccountId == AuthChallenge.Account.Id && | ||||
|                                               e.EnabledAt != null && | ||||
|                                               e.Trustworthy > 0); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<IActionResult> ExchangeTokenAndRedirect() | ||||
|         { | ||||
|             var challenge = await _db.AuthChallenges | ||||
|             var challenge = await db.AuthChallenges | ||||
|                 .Include(e => e.Account) | ||||
|                 .FirstOrDefaultAsync(e => e.Id == Id); | ||||
|  | ||||
|             if (challenge == null) return BadRequest("Authorization code not found or expired."); | ||||
|             if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); | ||||
|  | ||||
|             var session = await _db.AuthSessions | ||||
|             var session = await db.AuthSessions | ||||
|                 .FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id); | ||||
|  | ||||
|             if (session == null) | ||||
| @@ -171,15 +152,15 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|                     Account = challenge.Account, | ||||
|                     Challenge = challenge, | ||||
|                 }; | ||||
|                 _db.AuthSessions.Add(session); | ||||
|                 await _db.SaveChangesAsync(); | ||||
|                 db.AuthSessions.Add(session); | ||||
|                 await db.SaveChangesAsync(); | ||||
|             } | ||||
|  | ||||
|             var token = _auth.CreateToken(session); | ||||
|             Response.Cookies.Append("access_token", token, new CookieOptions | ||||
|             var token = auth.CreateToken(session); | ||||
|             Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions | ||||
|             { | ||||
|                 HttpOnly = true, | ||||
|                 Secure = !_configuration.GetValue<bool>("Debug"), | ||||
|                 Secure = !configuration.GetValue<bool>("Debug"), | ||||
|                 SameSite = SameSiteMode.Strict, | ||||
|                 Path = "/" | ||||
|             }); | ||||
| @@ -189,9 +170,10 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|             { | ||||
|                 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)) | ||||
|             if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl && | ||||
|                 !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) | ||||
|             { | ||||
|                 return Redirect(returnUrl); | ||||
|             } | ||||
| @@ -199,4 +181,4 @@ namespace DysonNetwork.Sphere.Pages.Auth | ||||
|             return RedirectToPage("/Index"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
| @@ -17,7 +17,7 @@ | ||||
|             <div class="flex items-center ml-auto"> | ||||
|                 @if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _)) | ||||
|                 { | ||||
|                     <a href="/Account/Profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a> | ||||
|                     <a href="/web/account/profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a> | ||||
|                     <form method="post" asp-page="/Account/Profile" asp-page-handler="Logout" class="inline"> | ||||
|                         <button type="submit" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Logout</button> | ||||
|                     </form> | ||||
| @@ -31,20 +31,11 @@ | ||||
|     </nav> | ||||
| </header> | ||||
|  | ||||
| @* The header 64px + The footer 56px = 118px *@ | ||||
| @* The header 64px *@ | ||||
| <main class="h-full"> | ||||
|     @RenderBody() | ||||
| </main> | ||||
|  | ||||
| <footer class="bg-white dark:bg-gray-800 fixed bottom-0 left-0 right-0 shadow-[0_-1px_3px_0_rgba(0,0,0,0.1)]"> | ||||
|     <div class="container-default" style="padding: 1rem 0;"> | ||||
|         <p class="text-center text-gray-500 dark:text-gray-400"> | ||||
|             © @DateTime.Now.Year Solsynth LLC. All | ||||
|             rights reserved. | ||||
|         </p> | ||||
|     </div> | ||||
| </footer> | ||||
|  | ||||
| @await RenderSectionAsync("Scripts", required: false) | ||||
| </body> | ||||
| </html>  | ||||
| @@ -23,10 +23,19 @@ | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "Jwt": { | ||||
|   "AuthToken": { | ||||
|     "PublicKeyPath": "Keys/PublicKey.pem", | ||||
|     "PrivateKeyPath": "Keys/PrivateKey.pem" | ||||
|   }, | ||||
|   "OidcProvider": { | ||||
|     "IssuerUri": "https://nt.solian.app", | ||||
|     "PublicKeyPath": "Keys/PublicKey.pem", | ||||
|     "PrivateKeyPath": "Keys/PrivateKey.pem", | ||||
|     "AccessTokenLifetime": "01:00:00", | ||||
|     "RefreshTokenLifetime": "30.00:00:00", | ||||
|     "AuthorizationCodeLifetime": "00:05:00", | ||||
|     "RequireHttpsMetadata": true | ||||
|   }, | ||||
|   "Tus": { | ||||
|     "StorePath": "Uploads" | ||||
|   }, | ||||
| @@ -117,4 +126,4 @@ | ||||
|     "127.0.0.1", | ||||
|     "::1" | ||||
|   ] | ||||
| } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user