diff --git a/DysonNetwork.Sphere/Account/AccountCurrentController.cs b/DysonNetwork.Sphere/Account/AccountCurrentController.cs index 5144aa9..28103fb 100644 --- a/DysonNetwork.Sphere/Account/AccountCurrentController.cs +++ b/DysonNetwork.Sphere/Account/AccountCurrentController.cs @@ -350,21 +350,21 @@ public class AccountCurrentController( if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) return BadRequest($"Auth factor with type {request.Type} is already exists."); - + var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); return Ok(factor); } - [HttpPost("factors/{id:guid}")] + [HttpPost("factors/{id:guid}/enable")] [Authorize] - public async Task> CreateAuthFactor(Guid id, [FromBody] string code) + public async Task> EnableAuthFactor(Guid id, [FromBody] string code) { 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(); + if (factor is null) return NotFound(); try { @@ -377,7 +377,52 @@ public class AccountCurrentController( } } + [HttpPost("factors/{id:guid}/disable")] + [Authorize] + public async Task> 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> 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")] + [Authorize] public async Task>> GetSessions( [FromQuery] int take = 20, [FromQuery] int offset = 0 @@ -401,4 +446,39 @@ public class AccountCurrentController( return Ok(sessions); } + + [HttpDelete("sessions/{id:guid}")] + [Authorize] + public async Task> 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> 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); + } + } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index 2a5156c..fab8bf1 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; @@ -9,6 +10,7 @@ namespace DysonNetwork.Sphere.Account; public class AccountService( AppDatabase db, MagicSpellService spells, + NotificationService nty, ICacheService cache ) { @@ -28,9 +30,7 @@ public class AccountService( .Where(c => c.Content == probe) .Include(c => c.Account) .FirstOrDefaultAsync(); - if (contact is not null) return contact.Account; - - return null; + return contact?.Account; } public async Task GetAccountLevel(Guid accountId) @@ -142,7 +142,7 @@ public class AccountService( throw new InvalidOperationException( "Invalid code, you need to enter the correct code to enable the factor." ); - + factor.EnabledAt = SystemClock.Instance.GetCurrentInstant(); db.Update(factor); await db.SaveChangesAsync(); @@ -150,6 +150,57 @@ public class AccountService( return factor; } + public async Task 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 public async Task EnsureAccountProfileCreated() { diff --git a/DysonNetwork.Sphere/Account/NotificationService.cs b/DysonNetwork.Sphere/Account/NotificationService.cs index 66a4b93..5b76518 100644 --- a/DysonNetwork.Sphere/Account/NotificationService.cs +++ b/DysonNetwork.Sphere/Account/NotificationService.cs @@ -17,7 +17,12 @@ public class NotificationService( private readonly string _notifyTopic = config["Notifications:Topic"]!; 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 SubscribePushNotification( Account account, diff --git a/DysonNetwork.Sphere/Auth/Auth.cs b/DysonNetwork.Sphere/Auth/Auth.cs index 064378e..3919b07 100644 --- a/DysonNetwork.Sphere/Auth/Auth.cs +++ b/DysonNetwork.Sphere/Auth/Auth.cs @@ -41,7 +41,7 @@ public class DysonTokenAuthHandler( ) : AuthenticationHandler(options, logger, encoder) { - private const string AuthCachePrefix = "auth:"; + public const string AuthCachePrefix = "auth:"; protected override async Task HandleAuthenticateAsync() { diff --git a/DysonNetwork.Sphere/Auth/Session.cs b/DysonNetwork.Sphere/Auth/Session.cs index 60d2b4f..d33a95f 100644 --- a/DysonNetwork.Sphere/Auth/Session.cs +++ b/DysonNetwork.Sphere/Auth/Session.cs @@ -13,7 +13,9 @@ public class Session : ModelBase public Instant? LastGrantedAt { get; set; } public Instant? ExpiredAt { get; set; } + public Guid AccountId { get; set; } [JsonIgnore] public Account.Account Account { get; set; } = null!; + public Guid ChallengeId { get; set; } [JsonIgnore] public Challenge Challenge { get; set; } = null!; }