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

@ -355,16 +355,16 @@ public class AccountCurrentController(
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)
@ -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!;
} }