🐛 Dozen of bugs fixes
This commit is contained in:
parent
f8295c6a18
commit
217b434cc4
@ -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,11 +151,25 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Split the token
|
|
||||||
var parts = token.Split('.');
|
var parts = token.Split('.');
|
||||||
if (parts.Length != 2)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
|
switch (parts.Length)
|
||||||
|
{
|
||||||
|
// Handle JWT tokens (3 parts)
|
||||||
|
case 3:
|
||||||
|
{
|
||||||
|
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||||
|
if (!isValid) return false;
|
||||||
|
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||||
|
if (jti is null) return false;
|
||||||
|
|
||||||
|
return Guid.TryParse(jti, out sessionId);
|
||||||
|
}
|
||||||
|
// Handle compact tokens (2 parts)
|
||||||
|
case 2:
|
||||||
|
// Original compact token validation logic
|
||||||
|
try
|
||||||
|
{
|
||||||
// Decode the payload
|
// Decode the payload
|
||||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||||
|
|
||||||
@ -154,7 +177,7 @@ public class DysonTokenAuthHandler(
|
|||||||
sessionId = new Guid(payloadBytes);
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
// Load public key for verification
|
// Load public key for verification
|
||||||
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
|
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
|
||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
@ -166,11 +189,22 @@ public class DysonTokenAuthHandler(
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Token validation failed");
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -156,7 +156,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 +263,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);
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ using Microsoft.Extensions.Options;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Sphere.Account;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
||||||
|
|
||||||
@ -110,7 +111,7 @@ public class OidcProviderController(
|
|||||||
return Ok(userInfo);
|
return Ok(userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet(".well-known/openid-configuration")]
|
[HttpGet("/.well-known/openid-configuration")]
|
||||||
public IActionResult GetConfiguration()
|
public IActionResult GetConfiguration()
|
||||||
{
|
{
|
||||||
var baseUrl = configuration["BaseUrl"];
|
var baseUrl = configuration["BaseUrl"];
|
||||||
@ -119,10 +120,10 @@ public class OidcProviderController(
|
|||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
issuer = issuer,
|
issuer = issuer,
|
||||||
authorization_endpoint = $"{baseUrl}/connect/authorize",
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||||
userinfo_endpoint = $"{baseUrl}/auth/open/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[]
|
response_types_supported = new[]
|
||||||
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||||
@ -139,11 +140,17 @@ public class OidcProviderController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("jwks")]
|
[HttpGet("/.well-known/jwks")]
|
||||||
public IActionResult GetJwks()
|
public IActionResult GetJwks()
|
||||||
{
|
{
|
||||||
var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey);
|
using var rsa = options.Value.GetRsaPublicKey();
|
||||||
var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8])
|
if (rsa == null)
|
||||||
|
{
|
||||||
|
return BadRequest("Public key is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = rsa.ExportParameters(false);
|
||||||
|
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
|
||||||
.Replace("+", "-")
|
.Replace("+", "-")
|
||||||
.Replace("/", "_")
|
.Replace("/", "_")
|
||||||
.Replace("=", "");
|
.Replace("=", "");
|
||||||
@ -154,11 +161,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
@ -75,7 +75,7 @@ public class OidcProviderService(
|
|||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
var accessToken = GenerateJwtToken(client, session, 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
|
// In a real implementation, you would store the token in the database
|
||||||
// For this example, we'll just return the token without storing it
|
// For this example, we'll just return the token without storing it
|
||||||
@ -99,28 +99,31 @@ public class OidcProviderService(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
var key = Encoding.ASCII.GetBytes(_options.SigningKey);
|
|
||||||
|
|
||||||
var clock = SystemClock.Instance;
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity([
|
Subject = new ClaimsIdentity([
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, session.Id.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, session.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(),
|
new Claim(JwtRegisteredClaimNames.Iat, now.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())
|
||||||
{
|
{
|
||||||
@ -132,15 +135,40 @@ 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 = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to use RSA validation if public key is available
|
||||||
|
var rsaPublicKey = _options.GetRsaPublicKey();
|
||||||
|
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
|
||||||
|
validationParameters.ValidateIssuerSigningKey = true;
|
||||||
|
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
|
||||||
|
|
||||||
|
|
||||||
|
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||||
|
return (true, (JwtSecurityToken)validatedToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Token validation failed");
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRefreshToken(Session session)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(Encoding.UTF8.GetBytes(session.Id.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||||
@ -150,35 +178,6 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authorization codes are now managed through ICacheService
|
|
||||||
|
|
||||||
public async Task<string> GenerateAuthorizationCodeAsync(
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
Guid userId,
|
Guid userId,
|
||||||
|
@ -9,11 +9,9 @@ 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")]
|
||||||
@ -27,14 +25,11 @@ 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)]
|
[BindProperty(SupportsGet = true)] public string? Nonce { get; set; }
|
||||||
public string? Nonce { get; set; }
|
|
||||||
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true, Name = "code_challenge")]
|
[BindProperty(SupportsGet = true, Name = "code_challenge")]
|
||||||
@ -56,7 +51,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
|||||||
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account)
|
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account)
|
||||||
{
|
{
|
||||||
var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}");
|
var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}");
|
||||||
return RedirectToPage($"/Auth/Login?returnUrl={returnUrl}");
|
return RedirectToPage("/Auth/Login", new { returnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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,46 +8,27 @@ using NodaTime;
|
|||||||
|
|
||||||
namespace DysonNetwork.Sphere.Pages.Auth
|
namespace DysonNetwork.Sphere.Pages.Auth
|
||||||
{
|
{
|
||||||
public class VerifyFactorModel : PageModel
|
public class VerifyFactorModel(
|
||||||
{
|
|
||||||
private readonly AppDatabase _db;
|
|
||||||
private readonly AccountService _accounts;
|
|
||||||
private readonly AuthService _auth;
|
|
||||||
private readonly ActionLogService _als;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
|
||||||
public Guid FactorId { get; set; }
|
|
||||||
|
|
||||||
[BindProperty(SupportsGet = true)]
|
|
||||||
public string? ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
[BindProperty, Required]
|
|
||||||
public string Code { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public Challenge? AuthChallenge { get; set; }
|
|
||||||
public AccountAuthFactor? Factor { get; set; }
|
|
||||||
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
|
|
||||||
|
|
||||||
public VerifyFactorModel(
|
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
AuthService auth,
|
AuthService auth,
|
||||||
ActionLogService als,
|
ActionLogService als,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHttpClientFactory httpClientFactory)
|
IHttpClientFactory httpClientFactory
|
||||||
|
)
|
||||||
|
: PageModel
|
||||||
{
|
{
|
||||||
_db = db;
|
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||||
_accounts = accounts;
|
|
||||||
_auth = auth;
|
[BindProperty(SupportsGet = true)] public Guid FactorId { get; set; }
|
||||||
_als = als;
|
|
||||||
_configuration = configuration;
|
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||||
_httpClientFactory = httpClientFactory;
|
|
||||||
}
|
[BindProperty, Required] public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Challenge? AuthChallenge { get; set; }
|
||||||
|
public AccountAuthFactor? Factor { get; set; }
|
||||||
|
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
@ -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,13 +117,13 @@ 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 &&
|
||||||
@ -152,14 +133,14 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
|
|
||||||
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 = "/"
|
||||||
});
|
});
|
||||||
@ -191,7 +172,8 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
@ -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:05:00",
|
||||||
|
"RequireHttpsMetadata": true
|
||||||
|
},
|
||||||
"Tus": {
|
"Tus": {
|
||||||
"StorePath": "Uploads"
|
"StorePath": "Uploads"
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user