Compare commits
3 Commits
d4fa08d320
...
0226bf8fa3
Author | SHA1 | Date | |
---|---|---|---|
0226bf8fa3 | |||
217b434cc4 | |||
f8295c6a18 |
@ -1,12 +1,19 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Sphere.Account;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Sphere.Storage;
|
||||||
using DysonNetwork.Sphere.Storage.Handlers;
|
using DysonNetwork.Sphere.Storage.Handlers;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
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;
|
using SystemClock = NodaTime.SystemClock;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Sphere.Auth;
|
||||||
@ -22,6 +29,7 @@ public enum TokenType
|
|||||||
{
|
{
|
||||||
AuthKey,
|
AuthKey,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
|
OidcKey,
|
||||||
Unknown
|
Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +47,7 @@ public class DysonTokenAuthHandler(
|
|||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
UrlEncoder encoder,
|
UrlEncoder encoder,
|
||||||
AppDatabase database,
|
AppDatabase database,
|
||||||
|
OidcProviderService oidc,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
FlushBufferService fbs
|
FlushBufferService fbs
|
||||||
)
|
)
|
||||||
@ -142,35 +151,60 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Split the token
|
|
||||||
var parts = token.Split('.');
|
var parts = token.Split('.');
|
||||||
if (parts.Length != 2)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Decode the payload
|
switch (parts.Length)
|
||||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
{
|
||||||
|
// 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
|
return Guid.TryParse(jti, out sessionId);
|
||||||
sessionId = new Guid(payloadBytes);
|
}
|
||||||
|
// 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
|
// Extract session ID
|
||||||
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
|
sessionId = new Guid(payloadBytes);
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
|
||||||
|
|
||||||
// Verify signature
|
// Load public key for verification
|
||||||
var signature = Base64UrlDecode(parts[1]);
|
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
|
||||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
{
|
{
|
||||||
string padded = base64Url
|
var padded = base64Url
|
||||||
.Replace('-', '+')
|
.Replace('-', '+')
|
||||||
.Replace('_', '/');
|
.Replace('_', '/');
|
||||||
|
|
||||||
@ -195,20 +229,23 @@ public class DysonTokenAuthHandler(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check for token in Authorization header
|
// Check for token in Authorization header
|
||||||
var authHeader = request.Headers.Authorization.ToString();
|
var authHeader = request.Headers.Authorization.ToString();
|
||||||
if (!string.IsNullOrEmpty(authHeader))
|
if (!string.IsNullOrEmpty(authHeader))
|
||||||
{
|
{
|
||||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
var parts = token.Split('.');
|
||||||
|
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
Token = authHeader["Bearer ".Length..].Trim(),
|
Token = token,
|
||||||
Type = TokenType.AuthKey
|
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||||
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
@ -216,8 +253,7 @@ public class DysonTokenAuthHandler(
|
|||||||
Type = TokenType.AuthKey
|
Type = TokenType.AuthKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||||
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
@ -233,10 +269,11 @@ public class DysonTokenAuthHandler(
|
|||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
Token = cookieToken,
|
Token = cookieToken,
|
||||||
Type = TokenType.AuthKey
|
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -73,7 +73,7 @@ public class AuthService(
|
|||||||
return totalRequiredSteps;
|
return totalRequiredSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Session> CreateSessionAsync(Account.Account account, Instant time)
|
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
||||||
{
|
{
|
||||||
var challenge = new Challenge
|
var challenge = new Challenge
|
||||||
{
|
{
|
||||||
@ -82,7 +82,7 @@ public class AuthService(
|
|||||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||||
StepRemain = 1,
|
StepRemain = 1,
|
||||||
StepTotal = 1,
|
StepTotal = 1,
|
||||||
Type = ChallengeType.Oidc
|
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
|
||||||
};
|
};
|
||||||
|
|
||||||
var session = new Session
|
var session = new Session
|
||||||
@ -90,7 +90,8 @@ public class AuthService(
|
|||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
CreatedAt = time,
|
CreatedAt = time,
|
||||||
LastGrantedAt = time,
|
LastGrantedAt = time,
|
||||||
Challenge = challenge
|
Challenge = challenge,
|
||||||
|
AppId = customAppId
|
||||||
};
|
};
|
||||||
|
|
||||||
db.AuthChallenges.Add(challenge);
|
db.AuthChallenges.Add(challenge);
|
||||||
@ -156,7 +157,7 @@ public class AuthService(
|
|||||||
public string CreateToken(Session session)
|
public string CreateToken(Session session)
|
||||||
{
|
{
|
||||||
// Load the private key for signing
|
// 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();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(privateKeyPem);
|
rsa.ImportFromPem(privateKeyPem);
|
||||||
|
|
||||||
@ -263,7 +264,7 @@ public class AuthService(
|
|||||||
sessionId = new Guid(payloadBytes);
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
// Load public key for verification
|
// Load public key for verification
|
||||||
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@ namespace DysonNetwork.Sphere.Auth;
|
|||||||
|
|
||||||
public class CompactTokenService(IConfiguration config)
|
public class CompactTokenService(IConfiguration config)
|
||||||
{
|
{
|
||||||
private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"]
|
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
|
||||||
?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing");
|
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
|
||||||
|
|
||||||
public string CreateToken(Session session)
|
public string CreateToken(Session session)
|
||||||
{
|
{
|
||||||
@ -54,7 +54,7 @@ public class CompactTokenService(IConfiguration config)
|
|||||||
sessionId = new Guid(payloadBytes);
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
// Load public key for verification
|
// Load public key for verification
|
||||||
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DysonNetwork.Sphere.Developer;
|
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
@ -10,230 +7,159 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
||||||
|
|
||||||
[Route("connect")]
|
[Route("/auth/open")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class OidcProviderController(
|
public class OidcProviderController(
|
||||||
|
AppDatabase db,
|
||||||
OidcProviderService oidcService,
|
OidcProviderService oidcService,
|
||||||
|
IConfiguration configuration,
|
||||||
IOptions<OidcProviderOptions> options,
|
IOptions<OidcProviderOptions> options,
|
||||||
ILogger<OidcProviderController> logger
|
ILogger<OidcProviderController> logger
|
||||||
)
|
)
|
||||||
: ControllerBase
|
: 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")]
|
[HttpPost("token")]
|
||||||
[Consumes("application/x-www-form-urlencoded")]
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
{
|
{
|
||||||
if (request.GrantType == "authorization_code")
|
switch (request.GrantType)
|
||||||
{
|
{
|
||||||
// Validate client credentials
|
// Validate client credentials
|
||||||
if (request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret))
|
case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
|
||||||
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client credentials are required" });
|
return BadRequest("Client credentials are required");
|
||||||
|
case "authorization_code" when request.Code == null:
|
||||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
return BadRequest("Authorization code is required");
|
||||||
if (client == null || !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
case "authorization_code":
|
||||||
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);
|
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||||
return BadRequest(new ErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid or expired authorization code" });
|
if (client == null ||
|
||||||
|
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: request.ClientId.Value,
|
||||||
|
authorizationCode: request.Code!,
|
||||||
|
redirectUri: request.RedirectUri,
|
||||||
|
codeVerifier: request.CodeVerifier
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
}
|
}
|
||||||
|
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||||
// Generate tokens
|
return BadRequest(new ErrorResponse
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||||
clientId: request.ClientId.Value,
|
case "refresh_token":
|
||||||
subjectId: authCode.UserId,
|
{
|
||||||
scopes: authCode.Scopes,
|
try
|
||||||
authorizationCode: request.Code);
|
{
|
||||||
|
// Decode the base64 refresh token to get the session ID
|
||||||
|
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||||
|
var sessionId = new Guid(sessionIdBytes);
|
||||||
|
|
||||||
return Ok(tokenResponse);
|
// Find the session and related data
|
||||||
}
|
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||||
else if (request.GrantType == "refresh_token")
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
{
|
if (session?.App is null || session.ExpiredAt < now)
|
||||||
// Handle refresh token request
|
{
|
||||||
// In a real implementation, you would validate the refresh token
|
return BadRequest(new ErrorResponse
|
||||||
// and issue a new access token
|
{
|
||||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
Error = "invalid_grant",
|
||||||
}
|
ErrorDescription = "Invalid or expired refresh token"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
// Get the client
|
||||||
|
var client = session.App;
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_client",
|
||||||
|
ErrorDescription = "Client not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: session.AppId!.Value,
|
||||||
|
sessionId: session.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid refresh token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("userinfo")]
|
[HttpGet("userinfo")]
|
||||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
[Authorize]
|
||||||
public async Task<IActionResult> UserInfo()
|
public async Task<IActionResult> GetUserInfo()
|
||||||
{
|
{
|
||||||
var authHeader = HttpContext.Request.Headers.Authorization.ToString();
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
|
||||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
{
|
|
||||||
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
|
// Get requested scopes from the token
|
||||||
var scopes = jwtToken.Claims
|
var scopes = currentSession.Challenge.Scopes;
|
||||||
.Where(c => c.Type == "scope")
|
|
||||||
.SelectMany(c => c.Value.Split(' '))
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
var userInfo = new Dictionary<string, object>
|
var userInfo = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["sub"] = userId ?? "anonymous"
|
["sub"] = currentUser.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include standard claims based on scopes
|
// Include standard claims based on scopes
|
||||||
if (scopes.Contains("profile") || scopes.Contains("name"))
|
if (scopes.Contains("profile") || scopes.Contains("name"))
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(userName))
|
userInfo["name"] = currentUser.Name;
|
||||||
userInfo["name"] = userName;
|
userInfo["preferred_username"] = currentUser.Nick;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scopes.Contains("email") && !string.IsNullOrEmpty(userEmail))
|
var userEmail = await db.AccountContacts
|
||||||
|
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (scopes.Contains("email") && userEmail is not null)
|
||||||
{
|
{
|
||||||
userInfo["email"] = userEmail;
|
userInfo["email"] = userEmail.Content;
|
||||||
userInfo["email_verified"] = true; // In a real app, check if email is verified
|
userInfo["email_verified"] = userEmail.VerifiedAt is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(userInfo);
|
return Ok(userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet(".well-known/openid-configuration")]
|
[HttpGet("/.well-known/openid-configuration")]
|
||||||
public IActionResult GetConfiguration()
|
public IActionResult GetConfiguration()
|
||||||
{
|
{
|
||||||
var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}".TrimEnd('/');
|
var baseUrl = configuration["BaseUrl"];
|
||||||
var issuer = options.Value.IssuerUri.TrimEnd('/');
|
var issuer = options.Value.IssuerUri.TrimEnd('/');
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
issuer = issuer,
|
issuer = issuer,
|
||||||
authorization_endpoint = $"{baseUrl}/connect/authorize",
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
token_endpoint = $"{baseUrl}/connect/token",
|
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||||
userinfo_endpoint = $"{baseUrl}/connect/userinfo",
|
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" },
|
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" },
|
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" },
|
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||||
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
||||||
id_token_signing_alg_values_supported = new[] { "HS256" },
|
id_token_signing_alg_values_supported = new[] { "HS256" },
|
||||||
@ -247,15 +173,17 @@ public class OidcProviderController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("jwks")]
|
[HttpGet("/.well-known/jwks")]
|
||||||
public IActionResult Jwks()
|
public IActionResult GetJwks()
|
||||||
{
|
{
|
||||||
// In a production environment, you should use asymmetric keys (RSA or EC)
|
using var rsa = options.Value.GetRsaPublicKey();
|
||||||
// and expose only the public key here. This is a simplified example using HMAC.
|
if (rsa == null)
|
||||||
// For production, consider using RSA or EC keys and proper key rotation.
|
{
|
||||||
|
return BadRequest("Public key is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey);
|
var parameters = rsa.ExportParameters(false);
|
||||||
var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8])
|
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
|
||||||
.Replace("+", "-")
|
.Replace("+", "-")
|
||||||
.Replace("/", "_")
|
.Replace("/", "_")
|
||||||
.Replace("=", "");
|
.Replace("=", "");
|
||||||
@ -266,11 +194,12 @@ public class OidcProviderController(
|
|||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
kty = "oct",
|
kty = "RSA",
|
||||||
use = "sig",
|
use = "sig",
|
||||||
kid = keyId,
|
kid = keyId,
|
||||||
k = Convert.ToBase64String(keyBytes),
|
n = Base64UrlEncoder.Encode(parameters.Modulus!),
|
||||||
alg = "HS256"
|
e = Base64UrlEncoder.Encode(parameters.Exponent!),
|
||||||
|
alg = "RS256"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -280,26 +209,34 @@ public class OidcProviderController(
|
|||||||
public class TokenRequest
|
public class TokenRequest
|
||||||
{
|
{
|
||||||
[JsonPropertyName("grant_type")]
|
[JsonPropertyName("grant_type")]
|
||||||
|
[FromForm(Name = "grant_type")]
|
||||||
public string? GrantType { get; set; }
|
public string? GrantType { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("code")]
|
[JsonPropertyName("code")]
|
||||||
|
[FromForm(Name = "code")]
|
||||||
public string? Code { get; set; }
|
public string? Code { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("redirect_uri")]
|
[JsonPropertyName("redirect_uri")]
|
||||||
|
[FromForm(Name = "redirect_uri")]
|
||||||
public string? RedirectUri { get; set; }
|
public string? RedirectUri { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("client_id")]
|
[JsonPropertyName("client_id")]
|
||||||
|
[FromForm(Name = "client_id")]
|
||||||
public Guid? ClientId { get; set; }
|
public Guid? ClientId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("client_secret")]
|
[JsonPropertyName("client_secret")]
|
||||||
|
[FromForm(Name = "client_secret")]
|
||||||
public string? ClientSecret { get; set; }
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("refresh_token")]
|
[JsonPropertyName("refresh_token")]
|
||||||
|
[FromForm(Name = "refresh_token")]
|
||||||
public string? RefreshToken { get; set; }
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("scope")]
|
[JsonPropertyName("scope")]
|
||||||
|
[FromForm(Name = "scope")]
|
||||||
public string? Scope { get; set; }
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("code_verifier")]
|
[JsonPropertyName("code_verifier")]
|
||||||
|
[FromForm(Name = "code_verifier")]
|
||||||
public string? CodeVerifier { get; set; }
|
public string? CodeVerifier { get; set; }
|
||||||
}
|
}
|
@ -7,12 +7,11 @@ namespace DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
|||||||
public class AuthorizationCodeInfo
|
public class AuthorizationCodeInfo
|
||||||
{
|
{
|
||||||
public Guid ClientId { get; set; }
|
public Guid ClientId { get; set; }
|
||||||
public string UserId { get; set; } = string.Empty;
|
public Guid AccountId { get; set; }
|
||||||
public string RedirectUri { get; set; } = string.Empty;
|
public string RedirectUri { get; set; } = string.Empty;
|
||||||
public List<string> Scopes { get; set; } = new();
|
public List<string> Scopes { get; set; } = new();
|
||||||
public string? CodeChallenge { get; set; }
|
public string? CodeChallenge { get; set; }
|
||||||
public string? CodeChallengeMethod { get; set; }
|
public string? CodeChallengeMethod { get; set; }
|
||||||
public string? Nonce { get; set; }
|
public string? Nonce { get; set; }
|
||||||
public Instant Expiration { get; set; }
|
|
||||||
public Instant CreatedAt { get; set; }
|
public Instant CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,36 @@
|
|||||||
using System;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
|
|
||||||
public class OidcProviderOptions
|
public class OidcProviderOptions
|
||||||
{
|
{
|
||||||
public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
|
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 AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
|
||||||
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
|
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
|
||||||
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||||
public bool RequireHttpsMetadata { get; set; } = true;
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -2,13 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
using DysonNetwork.Sphere.Developer;
|
using DysonNetwork.Sphere.Developer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using DysonNetwork.Sphere.Storage;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@ -18,7 +16,8 @@ namespace DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
|||||||
|
|
||||||
public class OidcProviderService(
|
public class OidcProviderService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
IClock clock,
|
AuthService auth,
|
||||||
|
ICacheService cache,
|
||||||
IOptions<OidcProviderOptions> options,
|
IOptions<OidcProviderOptions> options,
|
||||||
ILogger<OidcProviderService> logger
|
ILogger<OidcProviderService> logger
|
||||||
)
|
)
|
||||||
@ -44,6 +43,7 @@ public class OidcProviderService(
|
|||||||
var client = await FindClientByIdAsync(clientId);
|
var client = await FindClientByIdAsync(clientId);
|
||||||
if (client == null) return false;
|
if (client == null) return false;
|
||||||
|
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
var secret = client.Secrets
|
var secret = client.Secrets
|
||||||
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
|
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
|
||||||
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
|
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
|
||||||
@ -53,25 +53,53 @@ public class OidcProviderService(
|
|||||||
|
|
||||||
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
string subjectId,
|
string? authorizationCode = null,
|
||||||
IEnumerable<string>? scopes = null,
|
string? redirectUri = null,
|
||||||
string? authorizationCode = null)
|
string? codeVerifier = null,
|
||||||
|
Guid? sessionId = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var client = await FindClientByIdAsync(clientId);
|
var client = await FindClientByIdAsync(clientId);
|
||||||
if (client == null)
|
if (client == null)
|
||||||
throw new InvalidOperationException("Client not found");
|
throw new InvalidOperationException("Client not found");
|
||||||
|
|
||||||
|
Session session;
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
var now = clock.GetCurrentInstant();
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
List<string>? scopes = null;
|
||||||
|
if (authorizationCode != null)
|
||||||
|
{
|
||||||
|
// Authorization code flow
|
||||||
|
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||||
|
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||||
|
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||||
|
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||||
|
|
||||||
|
session = await auth.CreateSessionForOidcAsync(account, now);
|
||||||
|
scopes = authCode.Scopes;
|
||||||
|
}
|
||||||
|
else if (sessionId.HasValue)
|
||||||
|
{
|
||||||
|
// Refresh token flow
|
||||||
|
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||||
|
throw new InvalidOperationException("Invalid session");
|
||||||
|
|
||||||
|
// Verify the session is still valid
|
||||||
|
if (session.ExpiredAt < now)
|
||||||
|
throw new InvalidOperationException("Session has expired");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Either authorization code or session ID must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||||
|
|
||||||
// Generate access token
|
// Generate an access token
|
||||||
var accessToken = GenerateJwtToken(client, subjectId, expiresAt, scopes);
|
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
|
|
||||||
// as we don't have a dedicated OIDC token table
|
|
||||||
|
|
||||||
return new TokenResponse
|
return new TokenResponse
|
||||||
{
|
{
|
||||||
@ -83,30 +111,39 @@ public class OidcProviderService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GenerateJwtToken(CustomApp client, string subjectId, Instant expiresAt, IEnumerable<string>? scopes = null)
|
private string GenerateJwtToken(
|
||||||
|
CustomApp client,
|
||||||
|
Session session,
|
||||||
|
Instant expiresAt,
|
||||||
|
IEnumerable<string>? scopes = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
var key = Encoding.ASCII.GetBytes(_options.SigningKey);
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity(new[]
|
Subject = new ClaimsIdentity([
|
||||||
{
|
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, subjectId),
|
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||||
new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(),
|
ClaimValueTypes.Integer64),
|
||||||
ClaimValueTypes.Integer64),
|
new Claim("client_id", client.Id.ToString())
|
||||||
new Claim("client_id", client.Id.ToString())
|
]),
|
||||||
}),
|
|
||||||
Expires = expiresAt.ToDateTimeUtc(),
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
Issuer = _options.IssuerUri,
|
Issuer = _options.IssuerUri,
|
||||||
Audience = client.Id.ToString(),
|
Audience = client.Id.ToString()
|
||||||
SigningCredentials = new SigningCredentials(
|
|
||||||
new SymmetricSecurityKey(key),
|
|
||||||
SecurityAlgorithms.HmacSha256Signature)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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>();
|
var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>();
|
||||||
if (effectiveScopes.Any())
|
if (effectiveScopes.Any())
|
||||||
{
|
{
|
||||||
@ -118,15 +155,49 @@ public class OidcProviderService(
|
|||||||
return tokenHandler.WriteToken(token);
|
return tokenHandler.WriteToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateRefreshToken()
|
public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
|
||||||
{
|
{
|
||||||
using var rng = RandomNumberGenerator.Create();
|
try
|
||||||
var bytes = new byte[32];
|
{
|
||||||
rng.GetBytes(bytes);
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
return Convert.ToBase64String(bytes)
|
var validationParameters = new TokenValidationParameters
|
||||||
.Replace("+", "-")
|
{
|
||||||
.Replace("/", "_")
|
ValidateIssuer = true,
|
||||||
.Replace("=", "");
|
ValidIssuer = _options.IssuerUri,
|
||||||
|
ValidateAudience = false,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
return await db.AuthSessions
|
||||||
|
.Include(s => s.Account)
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Include(s => s.App)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRefreshToken(Session session)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||||
@ -136,38 +207,9 @@ public class OidcProviderService(
|
|||||||
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
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(
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
string userId,
|
Guid userId,
|
||||||
string redirectUri,
|
string redirectUri,
|
||||||
IEnumerable<string> scopes,
|
IEnumerable<string> scopes,
|
||||||
string? codeChallenge = null,
|
string? codeChallenge = null,
|
||||||
@ -175,64 +217,63 @@ public class OidcProviderService(
|
|||||||
string? nonce = null)
|
string? nonce = null)
|
||||||
{
|
{
|
||||||
// Generate a random code
|
// Generate a random code
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
var code = GenerateRandomString(32);
|
var code = GenerateRandomString(32);
|
||||||
var now = clock.GetCurrentInstant();
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
// Store the code with its metadata
|
// Create the authorization code info
|
||||||
_authorizationCodes[code] = new AuthorizationCodeInfo
|
var authCodeInfo = new AuthorizationCodeInfo
|
||||||
{
|
{
|
||||||
ClientId = clientId,
|
ClientId = clientId,
|
||||||
UserId = userId,
|
AccountId = userId,
|
||||||
RedirectUri = redirectUri,
|
RedirectUri = redirectUri,
|
||||||
Scopes = scopes.ToList(),
|
Scopes = scopes.ToList(),
|
||||||
CodeChallenge = codeChallenge,
|
CodeChallenge = codeChallenge,
|
||||||
CodeChallengeMethod = codeChallengeMethod,
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
Nonce = nonce,
|
Nonce = nonce,
|
||||||
Expiration = now.Plus(Duration.FromTimeSpan(_options.AuthorizationCodeLifetime)),
|
|
||||||
CreatedAt = now
|
CreatedAt = now
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store the code with its metadata in the cache
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
||||||
string code,
|
string code,
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
string? redirectUri = null,
|
string? redirectUri = null,
|
||||||
string? codeVerifier = null)
|
string? codeVerifier = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
if (!_authorizationCodes.TryGetValue(code, out var authCode) || authCode == null)
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||||
|
|
||||||
|
if (!found || authCode == null)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Authorization code not found: {Code}", code);
|
logger.LogWarning("Authorization code not found: {Code}", code);
|
||||||
return null;
|
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
|
// Verify client ID matches
|
||||||
if (authCode.ClientId != clientId)
|
if (authCode.ClientId != clientId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
|
logger.LogWarning(
|
||||||
|
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
|
||||||
code, authCode.ClientId, clientId);
|
code, authCode.ClientId, clientId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify redirect URI if provided
|
// Verify redirect URI if provided
|
||||||
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
|
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
|
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify PKCE code challenge if one was provided during authorization
|
// Verify PKCE code challenge if one was provided during authorization
|
||||||
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
|
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
|
||||||
{
|
{
|
||||||
@ -241,33 +282,33 @@ public class OidcProviderService(
|
|||||||
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
|
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
|
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
|
||||||
{
|
{
|
||||||
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
|
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
|
||||||
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
|
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
|
||||||
_ => false // Unsupported code challenge method
|
_ => false // Unsupported code challenge method
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isValid)
|
if (!isValid)
|
||||||
{
|
{
|
||||||
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
|
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code is valid, remove it from the store (codes are single-use)
|
// Code is valid, remove it from the cache (codes are single-use)
|
||||||
_authorizationCodes.Remove(code);
|
await cache.RemoveAsync(cacheKey);
|
||||||
|
|
||||||
return authCode;
|
return authCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateRandomString(int length)
|
private static string GenerateRandomString(int length)
|
||||||
{
|
{
|
||||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||||
var random = RandomNumberGenerator.Create();
|
var random = RandomNumberGenerator.Create();
|
||||||
var result = new char[length];
|
var result = new char[length];
|
||||||
|
|
||||||
for (int i = 0; i < length; i++)
|
for (int i = 0; i < length; i++)
|
||||||
{
|
{
|
||||||
var randomNumber = new byte[4];
|
var randomNumber = new byte[4];
|
||||||
@ -275,14 +316,14 @@ public class OidcProviderService(
|
|||||||
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
|
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
|
||||||
result[i] = chars[index];
|
result[i] = chars[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
return new string(result);
|
return new string(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
|
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(codeVerifier)) return false;
|
if (string.IsNullOrEmpty(codeVerifier)) return false;
|
||||||
|
|
||||||
if (method == "S256")
|
if (method == "S256")
|
||||||
{
|
{
|
||||||
using var sha256 = SHA256.Create();
|
using var sha256 = SHA256.Create();
|
||||||
@ -290,12 +331,12 @@ public class OidcProviderService(
|
|||||||
var base64 = Base64UrlEncoder.Encode(hash);
|
var base64 = Base64UrlEncoder.Encode(hash);
|
||||||
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
|
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method == "PLAIN")
|
if (method == "PLAIN")
|
||||||
{
|
{
|
||||||
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
|
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -376,7 +376,7 @@ public class ConnectionController(
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant());
|
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||||
var loginToken = auth.CreateToken(loginSession);
|
var loginToken = auth.CreateToken(loginSession);
|
||||||
return Redirect($"/auth/token?token={loginToken}");
|
return Redirect($"/auth/token?token={loginToken}");
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Sphere.Developer;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
|
|
||||||
@ -17,6 +18,8 @@ public class Session : ModelBase
|
|||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
public Guid ChallengeId { get; set; }
|
public Guid ChallengeId { get; set; }
|
||||||
public Challenge Challenge { get; set; } = null!;
|
public Challenge Challenge { get; set; } = null!;
|
||||||
|
public Guid? AppId { get; set; }
|
||||||
|
public CustomApp? App { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ChallengeType
|
public enum ChallengeType
|
||||||
|
4014
DysonNetwork.Sphere/Data/Migrations/20250629084150_AuthSessionWithApp.Designer.cs
generated
Normal file
4014
DysonNetwork.Sphere/Data/Migrations/20250629084150_AuthSessionWithApp.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AuthSessionWithApp : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "app_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_sessions_app_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "app_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_auth_sessions_custom_apps_app_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "app_id",
|
||||||
|
principalTable: "custom_apps",
|
||||||
|
principalColumn: "id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_auth_sessions_custom_apps_app_id",
|
||||||
|
table: "auth_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_auth_sessions_app_id",
|
||||||
|
table: "auth_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "app_id",
|
||||||
|
table: "auth_sessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -978,6 +978,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("account_id");
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AppId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("app_id");
|
||||||
|
|
||||||
b.Property<Guid>("ChallengeId")
|
b.Property<Guid>("ChallengeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("challenge_id");
|
.HasColumnName("challenge_id");
|
||||||
@ -1013,6 +1017,9 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.HasIndex("AccountId")
|
b.HasIndex("AccountId")
|
||||||
.HasDatabaseName("ix_auth_sessions_account_id");
|
.HasDatabaseName("ix_auth_sessions_account_id");
|
||||||
|
|
||||||
|
b.HasIndex("AppId")
|
||||||
|
.HasDatabaseName("ix_auth_sessions_app_id");
|
||||||
|
|
||||||
b.HasIndex("ChallengeId")
|
b.HasIndex("ChallengeId")
|
||||||
.HasDatabaseName("ix_auth_sessions_challenge_id");
|
.HasDatabaseName("ix_auth_sessions_challenge_id");
|
||||||
|
|
||||||
@ -3331,6 +3338,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_auth_sessions_accounts_account_id");
|
.HasConstraintName("fk_auth_sessions_accounts_account_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AppId")
|
||||||
|
.HasConstraintName("fk_auth_sessions_custom_apps_app_id");
|
||||||
|
|
||||||
b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge")
|
b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("ChallengeId")
|
.HasForeignKey("ChallengeId")
|
||||||
@ -3340,6 +3352,8 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
|
|
||||||
|
b.Navigation("App");
|
||||||
|
|
||||||
b.Navigation("Challenge");
|
b.Navigation("Challenge");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,19 +4,19 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
|||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
|
using DysonNetwork.Sphere.Developer;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Pages.Auth;
|
namespace DysonNetwork.Sphere.Pages.Auth;
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
||||||
{
|
{
|
||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||||
public string? ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true, Name = "client_id")]
|
[BindProperty(SupportsGet = true, Name = "client_id")]
|
||||||
[Required(ErrorMessage = "The client_id parameter is required")]
|
[Required(ErrorMessage = "The client_id parameter is required")]
|
||||||
public string? ClientIdString { get; set; }
|
public string? ClientIdString { get; set; }
|
||||||
|
|
||||||
public Guid ClientId { get; set; }
|
public Guid ClientId { get; set; }
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true, Name = "response_type")]
|
[BindProperty(SupportsGet = true, Name = "response_type")]
|
||||||
@ -25,22 +25,19 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
[BindProperty(SupportsGet = true, Name = "redirect_uri")]
|
[BindProperty(SupportsGet = true, Name = "redirect_uri")]
|
||||||
public string? RedirectUri { get; set; }
|
public string? RedirectUri { get; set; }
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)] public string? Scope { get; set; }
|
||||||
public string? Scope { get; set; }
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)] public string? State { get; set; }
|
||||||
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")]
|
[BindProperty(SupportsGet = true, Name = "code_challenge")]
|
||||||
public string? CodeChallenge { get; set; }
|
public string? CodeChallenge { get; set; }
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true, Name = "code_challenge_method")]
|
[BindProperty(SupportsGet = true, Name = "code_challenge_method")]
|
||||||
public string? CodeChallengeMethod { get; set; }
|
public string? CodeChallengeMethod { get; set; }
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true, Name = "response_mode")]
|
[BindProperty(SupportsGet = true, Name = "response_mode")]
|
||||||
public string? ResponseMode { get; set; }
|
public string? ResponseMode { get; set; }
|
||||||
|
|
||||||
@ -51,12 +48,18 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
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", new { returnUrl });
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("client_id", "Invalid client_id format");
|
ModelState.AddModelError("client_id", "Invalid client_id format");
|
||||||
return BadRequest("Invalid client_id format");
|
return BadRequest("Invalid client_id format");
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientId = clientId;
|
ClientId = clientId;
|
||||||
|
|
||||||
var client = await oidcService.FindClientByIdAsync(ClientId);
|
var client = await oidcService.FindClientByIdAsync(ClientId);
|
||||||
@ -66,6 +69,14 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
return NotFound("Client not found");
|
return NotFound("Client not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
AppName = client.Name;
|
AppName = client.Name;
|
||||||
AppLogo = client.LogoUri;
|
AppLogo = client.LogoUri;
|
||||||
AppUri = client.ClientUri;
|
AppUri = client.ClientUri;
|
||||||
@ -76,16 +87,18 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(bool allow)
|
public async Task<IActionResult> OnPostAsync(bool allow)
|
||||||
{
|
{
|
||||||
// First validate the client ID
|
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))
|
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("client_id", "Invalid client_id format");
|
ModelState.AddModelError("client_id", "Invalid client_id format");
|
||||||
return BadRequest("Invalid client_id format");
|
return BadRequest("Invalid client_id format");
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientId = clientId;
|
ClientId = clientId;
|
||||||
|
|
||||||
// Check if client exists
|
// Check if a client exists
|
||||||
var client = await oidcService.FindClientByIdAsync(ClientId);
|
var client = await oidcService.FindClientByIdAsync(ClientId);
|
||||||
if (client == null)
|
if (client == null)
|
||||||
{
|
{
|
||||||
@ -98,14 +111,14 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
// User denied the authorization request
|
// User denied the authorization request
|
||||||
if (string.IsNullOrEmpty(RedirectUri))
|
if (string.IsNullOrEmpty(RedirectUri))
|
||||||
return BadRequest("No redirect_uri provided");
|
return BadRequest("No redirect_uri provided");
|
||||||
|
|
||||||
var deniedUriBuilder = new UriBuilder(RedirectUri);
|
var deniedUriBuilder = new UriBuilder(RedirectUri);
|
||||||
var deniedQuery = System.Web.HttpUtility.ParseQueryString(deniedUriBuilder.Query);
|
var deniedQuery = System.Web.HttpUtility.ParseQueryString(deniedUriBuilder.Query);
|
||||||
deniedQuery["error"] = "access_denied";
|
deniedQuery["error"] = "access_denied";
|
||||||
deniedQuery["error_description"] = "The user denied the authorization request";
|
deniedQuery["error_description"] = "The user denied the authorization request";
|
||||||
if (!string.IsNullOrEmpty(State)) deniedQuery["state"] = State;
|
if (!string.IsNullOrEmpty(State)) deniedQuery["state"] = State;
|
||||||
deniedUriBuilder.Query = deniedQuery.ToString();
|
deniedUriBuilder.Query = deniedQuery.ToString();
|
||||||
|
|
||||||
return Redirect(deniedUriBuilder.ToString());
|
return Redirect(deniedUriBuilder.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,7 +132,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
// Generate authorization code
|
// Generate authorization code
|
||||||
var authCode = await oidcService.GenerateAuthorizationCodeAsync(
|
var authCode = await oidcService.GenerateAuthorizationCodeAsync(
|
||||||
clientId: ClientId,
|
clientId: ClientId,
|
||||||
userId: User.Identity?.Name ?? string.Empty,
|
userId: currentUser.Id,
|
||||||
redirectUri: RedirectUri,
|
redirectUri: RedirectUri,
|
||||||
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
|
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
|
||||||
codeChallenge: CodeChallenge,
|
codeChallenge: CodeChallenge,
|
||||||
@ -129,20 +142,18 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
// Build the redirect URI with the authorization code
|
// Build the redirect URI with the authorization code
|
||||||
var redirectUri = new UriBuilder(RedirectUri);
|
var redirectUri = new UriBuilder(RedirectUri);
|
||||||
var query = System.Web.HttpUtility.ParseQueryString(redirectUri.Query);
|
var query = System.Web.HttpUtility.ParseQueryString(redirectUri.Query);
|
||||||
|
|
||||||
// Add the authorization code
|
// Add the authorization code
|
||||||
query["code"] = authCode;
|
query["code"] = authCode;
|
||||||
|
|
||||||
// Add state if provided (for CSRF protection)
|
// Add state if provided (for CSRF protection)
|
||||||
if (!string.IsNullOrEmpty(State))
|
if (!string.IsNullOrEmpty(State))
|
||||||
{
|
|
||||||
query["state"] = State;
|
query["state"] = State;
|
||||||
}
|
|
||||||
|
|
||||||
// Set the query string
|
// Set the query string
|
||||||
redirectUri.Query = query.ToString();
|
redirectUri.Query = query.ToString();
|
||||||
|
|
||||||
// Redirect back to the client with the authorization code
|
// Redirect back to the client with the authorization code
|
||||||
return Redirect(redirectUri.ToString());
|
return Redirect(redirectUri.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -26,7 +26,7 @@
|
|||||||
{
|
{
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id">
|
<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)
|
@if (factor.Type == AccountAuthFactorType.EmailCode)
|
||||||
{
|
{
|
||||||
|
@ -39,7 +39,7 @@ public class SelectFactorModel(
|
|||||||
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
|
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
|
||||||
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
|
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
|
||||||
return BadRequest("Invalid authentication method.");
|
return BadRequest("Invalid authentication method.");
|
||||||
|
|
||||||
// Store return URL in TempData to pass to the next step
|
// Store return URL in TempData to pass to the next step
|
||||||
if (!string.IsNullOrEmpty(ReturnUrl))
|
if (!string.IsNullOrEmpty(ReturnUrl))
|
||||||
{
|
{
|
||||||
@ -50,10 +50,14 @@ public class SelectFactorModel(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// For OTP factors that require code delivery
|
// For OTP factors that require code delivery
|
||||||
if (factor.Type == AccountAuthFactorType.EmailCode
|
if (
|
||||||
&& string.IsNullOrWhiteSpace(Hint))
|
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();
|
await LoadChallengeAndFactors();
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
@ -62,31 +66,30 @@ public class SelectFactorModel(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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();
|
await LoadChallengeAndFactors();
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to verify page with return URL if available
|
// Redirect to verify page with return URL if available
|
||||||
if (!string.IsNullOrEmpty(ReturnUrl))
|
return !string.IsNullOrEmpty(ReturnUrl)
|
||||||
{
|
? RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl })
|
||||||
return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl });
|
: RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
|
||||||
}
|
|
||||||
return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadChallengeAndFactors()
|
private async Task LoadChallengeAndFactors()
|
||||||
{
|
{
|
||||||
AuthChallenge = await db.AuthChallenges
|
AuthChallenge = await db.AuthChallenges
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
.ThenInclude(e => e.AuthFactors)
|
|
||||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
if (AuthChallenge != null)
|
if (AuthChallenge != null)
|
||||||
{
|
{
|
||||||
AuthFactors = AuthChallenge.Account.AuthFactors
|
AuthFactors = await db.AccountAuthFactors
|
||||||
.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 })
|
.Where(e => e.AccountId == AuthChallenge.Account.Id)
|
||||||
.ToList();
|
.Where(e => e.EnabledAt != null && e.Trustworthy >= 1)
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,54 +8,35 @@ using NodaTime;
|
|||||||
|
|
||||||
namespace DysonNetwork.Sphere.Pages.Auth
|
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;
|
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||||
private readonly AccountService _accounts;
|
|
||||||
private readonly AuthService _auth;
|
|
||||||
private readonly ActionLogService _als;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)] public Guid FactorId { get; set; }
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||||
public Guid FactorId { get; set; }
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
|
||||||
public string? ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
[BindProperty, Required]
|
[BindProperty, Required] public string Code { get; set; } = string.Empty;
|
||||||
public string Code { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public Challenge? AuthChallenge { get; set; }
|
public Challenge? AuthChallenge { get; set; }
|
||||||
public AccountAuthFactor? Factor { get; set; }
|
public AccountAuthFactor? Factor { get; set; }
|
||||||
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
|
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()
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
await LoadChallengeAndFactor();
|
await LoadChallengeAndFactor();
|
||||||
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
|
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
|
||||||
if (Factor == null) return NotFound("Authentication method not found.");
|
if (Factor == null) return NotFound("Authentication method not found.");
|
||||||
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
|
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
|
||||||
|
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,25 +54,25 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (await _accounts.VerifyFactorCode(Factor, Code))
|
if (await accounts.VerifyFactorCode(Factor, Code))
|
||||||
{
|
{
|
||||||
AuthChallenge.StepRemain -= Factor.Trustworthy;
|
AuthChallenge.StepRemain -= Factor.Trustworthy;
|
||||||
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
|
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
|
||||||
AuthChallenge.BlacklistFactors.Add(Factor.Id);
|
AuthChallenge.BlacklistFactors.Add(Factor.Id);
|
||||||
_db.Update(AuthChallenge);
|
db.Update(AuthChallenge);
|
||||||
|
|
||||||
_als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "challenge_id", AuthChallenge.Id },
|
{ "challenge_id", AuthChallenge.Id },
|
||||||
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
|
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
|
||||||
}, Request, AuthChallenge.Account);
|
}, Request, AuthChallenge.Account);
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
if (AuthChallenge.StepRemain == 0)
|
if (AuthChallenge.StepRemain == 0)
|
||||||
{
|
{
|
||||||
_als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "challenge_id", AuthChallenge.Id },
|
{ "challenge_id", AuthChallenge.Id },
|
||||||
@ -104,7 +85,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If more steps are needed, redirect back to select factor
|
// 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
|
else
|
||||||
@ -117,10 +98,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
if (AuthChallenge != null)
|
if (AuthChallenge != null)
|
||||||
{
|
{
|
||||||
AuthChallenge.FailedAttempts++;
|
AuthChallenge.FailedAttempts++;
|
||||||
_db.Update(AuthChallenge);
|
db.Update(AuthChallenge);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "challenge_id", AuthChallenge.Id },
|
{ "challenge_id", AuthChallenge.Id },
|
||||||
@ -136,30 +117,30 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
|
|
||||||
private async Task LoadChallengeAndFactor()
|
private async Task LoadChallengeAndFactor()
|
||||||
{
|
{
|
||||||
AuthChallenge = await _db.AuthChallenges
|
AuthChallenge = await db.AuthChallenges
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
if (AuthChallenge?.Account != null)
|
if (AuthChallenge?.Account != null)
|
||||||
{
|
{
|
||||||
Factor = await _db.AccountAuthFactors
|
Factor = await db.AccountAuthFactors
|
||||||
.FirstOrDefaultAsync(e => e.Id == FactorId &&
|
.FirstOrDefaultAsync(e => e.Id == FactorId &&
|
||||||
e.AccountId == AuthChallenge.Account.Id &&
|
e.AccountId == AuthChallenge.Account.Id &&
|
||||||
e.EnabledAt != null &&
|
e.EnabledAt != null &&
|
||||||
e.Trustworthy > 0);
|
e.Trustworthy > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> ExchangeTokenAndRedirect()
|
private async Task<IActionResult> ExchangeTokenAndRedirect()
|
||||||
{
|
{
|
||||||
var challenge = await _db.AuthChallenges
|
var challenge = await db.AuthChallenges
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||||
|
|
||||||
if (challenge == null) return BadRequest("Authorization code not found or expired.");
|
if (challenge == null) return BadRequest("Authorization code not found or expired.");
|
||||||
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
|
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);
|
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
|
||||||
|
|
||||||
if (session == null)
|
if (session == null)
|
||||||
@ -171,15 +152,15 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
Account = challenge.Account,
|
Account = challenge.Account,
|
||||||
Challenge = challenge,
|
Challenge = challenge,
|
||||||
};
|
};
|
||||||
_db.AuthSessions.Add(session);
|
db.AuthSessions.Add(session);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = _auth.CreateToken(session);
|
var token = auth.CreateToken(session);
|
||||||
Response.Cookies.Append("access_token", token, new CookieOptions
|
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = !_configuration.GetValue<bool>("Debug"),
|
Secure = !configuration.GetValue<bool>("Debug"),
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
Path = "/"
|
Path = "/"
|
||||||
});
|
});
|
||||||
@ -189,9 +170,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
{
|
{
|
||||||
return Redirect(ReturnUrl);
|
return Redirect(ReturnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check TempData for return URL (in case it was passed through multiple steps)
|
// 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);
|
return Redirect(returnUrl);
|
||||||
}
|
}
|
||||||
@ -199,4 +181,4 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
return RedirectToPage("/Index");
|
return RedirectToPage("/Index");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -17,7 +17,7 @@
|
|||||||
<div class="flex items-center ml-auto">
|
<div class="flex items-center ml-auto">
|
||||||
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
|
@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">
|
<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>
|
<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>
|
</form>
|
||||||
@ -31,20 +31,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@* The header 64px + The footer 56px = 118px *@
|
@* The header 64px *@
|
||||||
<main class="h-full">
|
<main class="h-full">
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
</main>
|
</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)
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson;
|
|||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
using DysonNetwork.Sphere.Connection.WebReader;
|
using DysonNetwork.Sphere.Connection.WebReader;
|
||||||
using DysonNetwork.Sphere.Developer;
|
using DysonNetwork.Sphere.Developer;
|
||||||
@ -235,6 +236,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<SafetyService>();
|
services.AddScoped<SafetyService>();
|
||||||
services.AddScoped<DiscoveryService>();
|
services.AddScoped<DiscoveryService>();
|
||||||
services.AddScoped<CustomAppService>();
|
services.AddScoped<CustomAppService>();
|
||||||
|
|
||||||
|
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
||||||
services.AddScoped<OidcProviderService>();
|
services.AddScoped<OidcProviderService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
@ -23,10 +23,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"AuthToken": {
|
||||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||||
"PrivateKeyPath": "Keys/PrivateKey.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:30:00",
|
||||||
|
"RequireHttpsMetadata": true
|
||||||
|
},
|
||||||
"Tus": {
|
"Tus": {
|
||||||
"StorePath": "Uploads"
|
"StorePath": "Uploads"
|
||||||
},
|
},
|
||||||
@ -117,4 +126,4 @@
|
|||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"::1"
|
"::1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -50,6 +50,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff611a1225a3445458f2ca3f102eed5bdcd10_003F07_003F030df6ba_003FJwtBearerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff611a1225a3445458f2ca3f102eed5bdcd10_003F07_003F030df6ba_003FJwtBearerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F19f907d47c6f4a2ea68238bf22de133a16600_003Fa0_003F66d8c35f_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@ -60,6 +61,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANpgsqlEntityTypeBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fccb1faacaea4420db96b09857fc56178a1600_003Fd9_003F9acf9507_003FNpgsqlEntityTypeBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANpgsqlEntityTypeBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fccb1faacaea4420db96b09857fc56178a1600_003Fd9_003F9acf9507_003FNpgsqlEntityTypeBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F79_003F4ab1c673_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F6a_003Fea17bf26_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F6a_003Fea17bf26_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user