More auth factors, sessions api

This commit is contained in:
LittleSheep 2025-06-04 01:11:50 +08:00
parent db9b04ef47
commit 2f9df8009b
5 changed files with 149 additions and 11 deletions

View File

@ -350,21 +350,21 @@ public class AccountCurrentController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
return BadRequest($"Auth factor with type {request.Type} is already exists."); return BadRequest($"Auth factor with type {request.Type} is already exists.");
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
return Ok(factor); return Ok(factor);
} }
[HttpPost("factors/{id:guid}")] [HttpPost("factors/{id:guid}/enable")]
[Authorize] [Authorize]
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor(Guid id, [FromBody] string code) public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string code)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == id && f.Id == id) .Where(f => f.AccountId == id && f.Id == id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if(factor is null) return NotFound(); if (factor is null) return NotFound();
try try
{ {
@ -377,7 +377,52 @@ public class AccountCurrentController(
} }
} }
[HttpPost("factors/{id:guid}/disable")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
factor = await accounts.DisableAuthFactor(factor);
return Ok(factor);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("factors/{id:guid}")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
await accounts.DeleteAuthFactor(factor);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("sessions")] [HttpGet("sessions")]
[Authorize]
public async Task<ActionResult<List<Session>>> GetSessions( public async Task<ActionResult<List<Session>>> GetSessions(
[FromQuery] int take = 20, [FromQuery] int take = 20,
[FromQuery] int offset = 0 [FromQuery] int offset = 0
@ -401,4 +446,39 @@ public class AccountCurrentController(
return Ok(sessions); return Ok(sessions);
} }
[HttpDelete("sessions/{id:guid}")]
[Authorize]
public async Task<ActionResult<Session>> DeleteSession(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.DeleteSession(currentUser, id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("sessions/current")]
[Authorize]
public async Task<ActionResult<Session>> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try
{
await accounts.DeleteSession(currentUser, currentSession.Id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
} }

View File

@ -1,3 +1,4 @@
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -9,6 +10,7 @@ namespace DysonNetwork.Sphere.Account;
public class AccountService( public class AccountService(
AppDatabase db, AppDatabase db,
MagicSpellService spells, MagicSpellService spells,
NotificationService nty,
ICacheService cache ICacheService cache
) )
{ {
@ -28,9 +30,7 @@ public class AccountService(
.Where(c => c.Content == probe) .Where(c => c.Content == probe)
.Include(c => c.Account) .Include(c => c.Account)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (contact is not null) return contact.Account; return contact?.Account;
return null;
} }
public async Task<int?> GetAccountLevel(Guid accountId) public async Task<int?> GetAccountLevel(Guid accountId)
@ -142,7 +142,7 @@ public class AccountService(
throw new InvalidOperationException( throw new InvalidOperationException(
"Invalid code, you need to enter the correct code to enable the factor." "Invalid code, you need to enter the correct code to enable the factor."
); );
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant(); factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
db.Update(factor); db.Update(factor);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -150,6 +150,57 @@ public class AccountService(
return factor; return factor;
} }
public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor)
{
if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled.");
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.Id && f.EnabledAt != null)
.CountAsync();
if (count <= 1)
throw new InvalidOperationException(
"Disabling this auth factor will cause you have no active auth factors.");
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
return factor;
}
public async Task DeleteAuthFactor(AccountAuthFactor factor)
{
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.Id)
.CountAsync();
if (count <= 1)
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
db.AccountAuthFactors.Remove(factor);
await db.SaveChangesAsync();
}
public async Task DeleteSession(Account account, Guid sessionId)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
if(session.Challenge.DeviceId is not null)
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
// The current session should be included in the sessions' list
db.AuthSessions.RemoveRange(sessions);
await db.SaveChangesAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
}
/// Maintenance methods for server administrator /// Maintenance methods for server administrator
public async Task EnsureAccountProfileCreated() public async Task EnsureAccountProfileCreated()
{ {

View File

@ -17,7 +17,12 @@ public class NotificationService(
private readonly string _notifyTopic = config["Notifications:Topic"]!; private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
// TODO remove all push notification with this device id when this device is logged out public async Task UnsubscribePushNotifications(string deviceId)
{
await db.NotificationPushSubscriptions
.Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync();
}
public async Task<NotificationPushSubscription> SubscribePushNotification( public async Task<NotificationPushSubscription> SubscribePushNotification(
Account account, Account account,

View File

@ -41,7 +41,7 @@ public class DysonTokenAuthHandler(
) )
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder) : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{ {
private const string AuthCachePrefix = "auth:"; public const string AuthCachePrefix = "auth:";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {

View File

@ -13,7 +13,9 @@ public class Session : ModelBase
public Instant? LastGrantedAt { get; set; } public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; }
[JsonIgnore] public Challenge Challenge { get; set; } = null!; [JsonIgnore] public Challenge Challenge { get; set; } = null!;
} }