diff --git a/DysonNetwork.Sphere/Auth/Auth.cs b/DysonNetwork.Sphere/Auth/Auth.cs index 683522a..135f6bd 100644 --- a/DysonNetwork.Sphere/Auth/Auth.cs +++ b/DysonNetwork.Sphere/Auth/Auth.cs @@ -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; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs index 31ee36d..4bba69d 100644 --- a/DysonNetwork.Sphere/Auth/AuthService.cs +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -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); diff --git a/DysonNetwork.Sphere/Auth/CompactTokenService.cs b/DysonNetwork.Sphere/Auth/CompactTokenService.cs index 8e3d735..53c3e8b 100644 --- a/DysonNetwork.Sphere/Auth/CompactTokenService.cs +++ b/DysonNetwork.Sphere/Auth/CompactTokenService.cs @@ -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); diff --git a/DysonNetwork.Sphere/Auth/OidcProvider/Controllers/OidcProviderController.cs b/DysonNetwork.Sphere/Auth/OidcProvider/Controllers/OidcProviderController.cs index 2b9abdf..58d8c55 100644 --- a/DysonNetwork.Sphere/Auth/OidcProvider/Controllers/OidcProviderController.cs +++ b/DysonNetwork.Sphere/Auth/OidcProvider/Controllers/OidcProviderController.cs @@ -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" } } }); diff --git a/DysonNetwork.Sphere/Auth/OidcProvider/Options/OidcProviderOptions.cs b/DysonNetwork.Sphere/Auth/OidcProvider/Options/OidcProviderOptions.cs index c886d9d..6d57cb3 100644 --- a/DysonNetwork.Sphere/Auth/OidcProvider/Options/OidcProviderOptions.cs +++ b/DysonNetwork.Sphere/Auth/OidcProvider/Options/OidcProviderOptions.cs @@ -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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OidcProvider/Services/OidcProviderService.cs b/DysonNetwork.Sphere/Auth/OidcProvider/Services/OidcProviderService.cs index 99293f9..1033090 100644 --- a/DysonNetwork.Sphere/Auth/OidcProvider/Services/OidcProviderService.cs +++ b/DysonNetwork.Sphere/Auth/OidcProvider/Services/OidcProviderService.cs @@ -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? scopes = null + CustomApp client, + Session session, + Instant expiresAt, + IEnumerable? 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(); 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 GenerateAuthorizationCodeAsync( Guid clientId, Guid userId, diff --git a/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs index 4b7b3ea..46cca61 100644 --- a/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs @@ -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 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 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()); } -} +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml index b7f9634..d8b188e 100644 --- a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml +++ b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml @@ -26,7 +26,7 @@ {
- + @if (factor.Type == AccountAuthFactorType.EmailCode) { diff --git a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs index e16a053..aed783d 100644 --- a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs @@ -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(); } } diff --git a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs index 82ab219..318a488 100644 --- a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs @@ -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 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 { { "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 { { "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 { { "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 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("Debug"), + Secure = !configuration.GetValue("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"); } } -} +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml b/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml index 7092162..3ba1725 100644 --- a/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml +++ b/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml @@ -17,7 +17,7 @@
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _)) { - Profile + Profile @@ -31,20 +31,11 @@ -@* The header 64px + The footer 56px = 118px *@ +@* The header 64px *@
@RenderBody()
- - @await RenderSectionAsync("Scripts", required: false) \ No newline at end of file diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index 950c199..0048a8c 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -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" ] -} \ No newline at end of file +}