Basic auth

This commit is contained in:
2025-04-10 01:03:02 +08:00
parent e90357d153
commit 71f05154af
14 changed files with 1040 additions and 37 deletions

View File

@ -0,0 +1,161 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;
using System.IdentityModel.Tokens.Jwt;
namespace DysonNetwork.Sphere.Auth;
[ApiController]
[Route("/auth")]
public class AuthController(AppDatabase db, AccountService accounts, AuthService auth, IHttpContextAccessor httpContext)
{
public class ChallengeRequest
{
[Required] [MaxLength(256)] public string Account { get; set; } = string.Empty;
[MaxLength(512)] public string? DeviceId { get; set; }
public List<string> Claims { get; set; } = new();
public List<string> Audiences { get; set; } = new();
}
[HttpPost("challenge")]
public async Task<ActionResult<Challenge>> StartChallenge([FromBody] ChallengeRequest request)
{
var account = await accounts.LookupAccount(request.Account);
if (account is null) return new NotFoundResult();
var ipAddress = httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString();
var userAgent = httpContext.HttpContext?.Request.Headers.UserAgent.ToString();
var challenge = new Challenge
{
Account = account,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = 1,
Claims = request.Claims,
Audiences = request.Audiences,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceId = request.DeviceId,
}.Normalize();
await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync();
return challenge;
}
public class PerformChallengeRequest
{
[Required] public long FactorId { get; set; }
[Required] public string Password { get; set; } = string.Empty;
}
[HttpPatch("challenge/{id}")]
public async Task<ActionResult<Challenge>> DoChallenge(
[FromRoute] Guid id,
[FromBody] PerformChallengeRequest request
)
{
var challenge = await db.AuthChallenges.FindAsync(id);
if (challenge is null) return new NotFoundResult();
var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
if (factor is null) return new NotFoundResult();
if (challenge.StepRemain == 0) return challenge;
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
return new BadRequestResult();
try
{
if (factor.VerifyPassword(request.Password))
{
challenge.StepRemain--;
challenge.BlacklistFactors.Add(factor.Id);
}
}
catch
{
return new BadRequestResult();
}
await db.SaveChangesAsync();
return challenge;
}
[HttpPost("challenge/{id}/grant")]
public async Task<ActionResult<SignedTokenPair>> GrantChallengeToken([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Where(e => e.Id == id)
.FirstOrDefaultAsync();
if (challenge is null) return new NotFoundResult();
if (challenge.StepRemain != 0) return new BadRequestResult();
var session = await db.AuthSessions
.Where(e => e.Challenge == challenge)
.FirstOrDefaultAsync();
if (session is not null) return new BadRequestResult();
session = new Session
{
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = challenge.Account,
Challenge = challenge,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return auth.CreateToken(session);
}
public class TokenExchangeRequest
{
public string GrantType { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
}
[HttpPost("token")]
public async Task<ActionResult<SignedTokenPair>> ExchangeToken([FromBody] TokenExchangeRequest request)
{
switch (request.GrantType)
{
case "refresh_token":
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(request.RefreshToken);
var sessionIdClaim = token.Claims.FirstOrDefault(c => c.Type == "session_id")?.Value;
if (!Guid.TryParse(sessionIdClaim, out var sessionId))
return new UnauthorizedResult();
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null) return new NotFoundResult();
session.LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
await db.SaveChangesAsync();
return auth.CreateToken(session);
default:
return new BadRequestResult();
}
}
[Authorize]
[HttpGet("test")]
public async Task<ActionResult> Test()
{
var sessionIdClaim = httpContext.HttpContext?.User.FindFirst("session_id")?.Value;
if (!Guid.TryParse(sessionIdClaim, out var sessionId))
return new UnauthorizedResult();
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null) return new NotFoundResult();
return new OkObjectResult(session);
}
}

View File

@ -0,0 +1,61 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
public class SignedTokenPair
{
public string AccessToken { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public Instant ExpiredAt { get; set; }
}
public class AuthService(AppDatabase db, IConfiguration config)
{
public SignedTokenPair CreateToken(Session session)
{
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
var key = new RsaSecurityKey(rsa);
var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var accessTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),
claims: new List<Claim>
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString())
},
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds
);
var refreshTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),
claims: new List<Claim>
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString())
},
expires: DateTime.Now.AddDays(30),
signingCredentials: creds
);
var handler = new JwtSecurityTokenHandler();
var accessToken = handler.WriteToken(accessTokenClaims);
var refreshToken = handler.WriteToken(refreshTokenClaims);
return new SignedTokenPair
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddMinutes(30))
};
}
}

View File

@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
public class Session : BaseModel
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Challenge Challenge { get; set; } = null!;
}
public class Challenge : BaseModel
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? ExpiredAt { get; set; }
public int StepRemain { get; set; }
public int StepTotal { get; set; }
[Column(TypeName = "jsonb")] public List<long> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Claims { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(256)] public string? DeviceId { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Challenge Normalize()
{
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
return this;
}
}