Magic spell for one time code

🗑️ Drop the usage of casbin
♻️ Refactor the permission service
♻️ Refactor the flow of creating an account
🧱 Email infra structure
This commit is contained in:
2025-04-29 20:37:10 +08:00
parent 82288fa52c
commit 0ebeab672b
35 changed files with 1789 additions and 5214 deletions

View File

@ -15,8 +15,7 @@ public class AuthController(
AppDatabase db,
AccountService accounts,
AuthService auth,
IConfiguration configuration,
IHttpClientFactory httpClientFactory
IConfiguration configuration
) : ControllerBase
{
public class ChallengeRequest
@ -218,49 +217,7 @@ public class AuthController(
[HttpPost("captcha")]
public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
{
var provider = configuration.GetSection("Captcha")["Provider"]?.ToLower();
var apiKey = configuration.GetSection("Captcha")["ApiKey"];
var apiSecret = configuration.GetSection("Captcha")["ApiSecret"];
var client = httpClientFactory.CreateClient();
switch (provider)
{
case "cloudflare":
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var cfResult = JsonSerializer.Deserialize<CloudflareVerificationResponse>(json);
if (cfResult?.Success == true)
return Ok(new { success = true });
return BadRequest(new { success = false, errors = cfResult?.ErrorCodes });
case "google":
var secretKey = configuration.GetSection("CaptchaSettings")["GoogleRecaptchaSecretKey"];
if (string.IsNullOrEmpty(secretKey))
{
return StatusCode(500, "Google reCaptcha secret key is not configured.");
}
content = new StringContent($"secret={secretKey}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/api/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
var capResult = JsonSerializer.Deserialize<GoogleVerificationResponse>(json);
if (capResult?.Success == true)
return Ok(new { success = true });
return BadRequest(new { success = false, errors = capResult?.ErrorCodes });
default:
return StatusCode(500, "The server misconfigured for the captcha.");
}
var result = await auth.ValidateCaptcha(token);
return result ? Ok() : BadRequest();
}
}

View File

@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Casbin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
@ -15,68 +16,43 @@ public class SignedTokenPair
public Instant ExpiredAt { get; set; }
}
public class AuthService(AppDatabase db, IConfiguration config, IEnforcer enforcer)
public class AuthService(IConfiguration config, IHttpClientFactory httpClientFactory)
{
public async Task<bool> AssignRoleToUserAsync(string user, string role, string domain = "global")
public async Task<bool> ValidateCaptcha(string token)
{
var added = await enforcer.AddGroupingPolicyAsync(user, role, domain);
if (added) await enforcer.SavePolicyAsync();
return added;
}
var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
var apiSecret = config.GetSection("Captcha")["ApiSecret"];
public async Task<bool> AddPermissionToUserAsync(string user, string domain, string obj, string act)
{
var added = await enforcer.AddPolicyAsync(user, domain, obj, act);
if (added) await enforcer.SavePolicyAsync();
return added;
}
public async Task<bool> RemovePermissionFromUserAsync(string user, string domain, string obj, string act)
{
var removed = await enforcer.RemovePolicyAsync(user, domain, obj, act);
if (removed) await enforcer.SavePolicyAsync();
return removed;
}
var client = httpClientFactory.CreateClient();
public async Task<bool> CreateRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
{
bool anyAdded = false;
foreach (var (obj, act) in permissions)
switch (provider)
{
var added = await enforcer.AddPolicyAsync(role, domain, obj, act);
if (added) anyAdded = true;
}
case "cloudflare":
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
content);
response.EnsureSuccessStatusCode();
if (anyAdded) await enforcer.SavePolicyAsync();
return anyAdded;
var json = await response.Content.ReadAsStringAsync();
var cfResult = JsonSerializer.Deserialize<CloudflareVerificationResponse>(json);
return cfResult?.Success == true;
case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/api/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
var capResult = JsonSerializer.Deserialize<GoogleVerificationResponse>(json);
return capResult?.Success == true;
default:
throw new ArgumentException("The server misconfigured for the captcha.");
}
}
public async Task<bool> AddPermissionsToRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
{
bool anyAdded = false;
foreach (var (obj, act) in permissions)
{
var added = await enforcer.AddPolicyAsync(role, domain, obj, act);
if (added) anyAdded = true;
}
if (anyAdded) await enforcer.SavePolicyAsync();
return anyAdded;
}
public async Task<bool> RemovePermissionsFromRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
{
bool anyRemoved = false;
foreach (var (obj, act) in permissions)
{
var removed = await enforcer.RemovePolicyAsync(role, domain, obj, act);
if (removed) anyRemoved = true;
}
if (anyRemoved) await enforcer.SavePolicyAsync();
return anyRemoved;
}
public SignedTokenPair CreateToken(Session session)
{
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
@ -98,9 +74,9 @@ public class AuthService(AppDatabase db, IConfiguration config, IEnforcer enforc
expires: DateTime.Now.AddDays(30),
signingCredentials: creds
);
session.Challenge.Scopes.ForEach(c => claims.Add(new Claim("scope", c)));
if(session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1"));
if (session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1"));
var accessTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),

View File

@ -1,34 +0,0 @@
using Casbin;
using Microsoft.AspNetCore.Authorization;
namespace DysonNetwork.Sphere.Auth;
public class CasbinRequirement(string domain, string obj, string act) : IAuthorizationRequirement
{
public string Domain { get; } = domain;
public string Object { get; } = obj;
public string Action { get; } = act;
}
public class CasbinAuthorizationHandler(IEnforcer enforcer)
: AuthorizationHandler<CasbinRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CasbinRequirement requirement)
{
var userId = context.User.FindFirst("user_id")?.Value;
if (userId == null) return;
var isSuperuser = context.User.FindFirst("is_superuser")?.Value == "1";
if (isSuperuser) userId = "super:" + userId;
var allowed = await enforcer.EnforceAsync(
userId,
requirement.Domain,
requirement.Object,
requirement.Action
);
if (allowed) context.Succeed(requirement);
}
}

View File

@ -21,6 +21,7 @@ public class Challenge : ModelBase
public Instant? ExpiredAt { get; set; }
public int StepRemain { get; set; }
public int StepTotal { get; set; }
public int FailedAttempts { get; set; }
[Column(TypeName = "jsonb")] public List<long> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();