From 4f8ab5da28317ffff04e5cf9935541f40529dff8 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 30 Aug 2024 13:54:31 +0200 Subject: [PATCH] Add new user sessions endpoint to webapi (#80) --- .../AuthenticatedRequestController.cs | 10 ++- .../{ => Email}/EmailBoxController.cs | 3 +- .../{ => Email}/EmailController.cs | 3 +- .../Controllers/FaviconController.cs | 1 + .../Controllers/IdentityController.cs | 39 --------- .../Security/SecurityController.cs | 86 +++++++++++++++++++ .../{ => Security}/TwoFactorAuthController.cs | 47 +++++----- .../Controllers/{ => Tests}/TestController.cs | 3 +- .../Controllers/VaultController.cs | 1 + .../Services/CredentialService.cs | 15 ---- .../WebApi/Security/RefreshTokenModel.cs | 34 ++++++++ 11 files changed, 160 insertions(+), 82 deletions(-) rename src/AliasVault.Api/Controllers/{ => Abstracts}/AuthenticatedRequestController.cs (77%) rename src/AliasVault.Api/Controllers/{ => Email}/EmailBoxController.cs (98%) rename src/AliasVault.Api/Controllers/{ => Email}/EmailController.cs (97%) delete mode 100644 src/AliasVault.Api/Controllers/IdentityController.cs create mode 100644 src/AliasVault.Api/Controllers/Security/SecurityController.cs rename src/AliasVault.Api/Controllers/{ => Security}/TwoFactorAuthController.cs (69%) rename src/AliasVault.Api/Controllers/{ => Tests}/TestController.cs (95%) create mode 100644 src/AliasVault.Shared/Models/WebApi/Security/RefreshTokenModel.cs diff --git a/src/AliasVault.Api/Controllers/AuthenticatedRequestController.cs b/src/AliasVault.Api/Controllers/Abstracts/AuthenticatedRequestController.cs similarity index 77% rename from src/AliasVault.Api/Controllers/AuthenticatedRequestController.cs rename to src/AliasVault.Api/Controllers/Abstracts/AuthenticatedRequestController.cs index 7e40cadb0..62ec18bb6 100644 --- a/src/AliasVault.Api/Controllers/AuthenticatedRequestController.cs +++ b/src/AliasVault.Api/Controllers/Abstracts/AuthenticatedRequestController.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Api.Controllers; +namespace AliasVault.Api.Controllers.Abstracts; using System.Security.Claims; using AliasServerDb; @@ -20,8 +20,14 @@ using Microsoft.AspNetCore.Mvc; [Route("api/v{version:apiVersion}/[controller]")] [ApiController] [Authorize] -public class AuthenticatedRequestController(UserManager userManager) : ControllerBase +public abstract class AuthenticatedRequestController(UserManager userManager) : ControllerBase { + /// + /// Get the userManager instance. + /// + /// UserManager instance. + protected UserManager GetUserManager() => userManager; + /// /// Get the current authenticated user. /// diff --git a/src/AliasVault.Api/Controllers/EmailBoxController.cs b/src/AliasVault.Api/Controllers/Email/EmailBoxController.cs similarity index 98% rename from src/AliasVault.Api/Controllers/EmailBoxController.cs rename to src/AliasVault.Api/Controllers/Email/EmailBoxController.cs index 1a0944790..28573a7aa 100644 --- a/src/AliasVault.Api/Controllers/EmailBoxController.cs +++ b/src/AliasVault.Api/Controllers/Email/EmailBoxController.cs @@ -5,9 +5,10 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Api.Controllers; +namespace AliasVault.Api.Controllers.Email; using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; using AliasVault.Api.Helpers; using AliasVault.Shared.Models.Spamok; using AliasVault.Shared.Models.WebApi; diff --git a/src/AliasVault.Api/Controllers/EmailController.cs b/src/AliasVault.Api/Controllers/Email/EmailController.cs similarity index 97% rename from src/AliasVault.Api/Controllers/EmailController.cs rename to src/AliasVault.Api/Controllers/Email/EmailController.cs index 8d2c79b7f..a4cdee79e 100644 --- a/src/AliasVault.Api/Controllers/EmailController.cs +++ b/src/AliasVault.Api/Controllers/Email/EmailController.cs @@ -5,9 +5,10 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Api.Controllers; +namespace AliasVault.Api.Controllers.Email; using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; using AliasVault.Api.Helpers; using AliasVault.Shared.Models.Spamok; using Asp.Versioning; diff --git a/src/AliasVault.Api/Controllers/FaviconController.cs b/src/AliasVault.Api/Controllers/FaviconController.cs index 96b138b2f..8bbb68953 100644 --- a/src/AliasVault.Api/Controllers/FaviconController.cs +++ b/src/AliasVault.Api/Controllers/FaviconController.cs @@ -8,6 +8,7 @@ namespace AliasVault.Api.Controllers; using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; using AliasVault.Shared.Models.WebApi.Favicon; using Asp.Versioning; using Microsoft.AspNetCore.Identity; diff --git a/src/AliasVault.Api/Controllers/IdentityController.cs b/src/AliasVault.Api/Controllers/IdentityController.cs deleted file mode 100644 index d99aece27..000000000 --- a/src/AliasVault.Api/Controllers/IdentityController.cs +++ /dev/null @@ -1,39 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) lanedirt. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.Api.Controllers; - -using AliasGenerators.Identity.Implementations; -using AliasServerDb; -using Asp.Versioning; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; - -/// -/// Controller for identity generation. -/// -/// UserManager instance. -[ApiVersion("1")] -public class IdentityController(UserManager userManager) : AuthenticatedRequestController(userManager) -{ - /// - /// Proxies the request to the english identity generator to generate a random identity. - /// - /// Identity model. - [HttpGet("Generate")] - public async Task Generate() - { - var user = await GetCurrentUserAsync(); - if (user == null) - { - return Unauthorized(); - } - - var identityGenerator = new IdentityGeneratorEn(); - return Ok(await identityGenerator.GenerateRandomIdentityAsync()); - } -} diff --git a/src/AliasVault.Api/Controllers/Security/SecurityController.cs b/src/AliasVault.Api/Controllers/Security/SecurityController.cs new file mode 100644 index 000000000..5e5b8785c --- /dev/null +++ b/src/AliasVault.Api/Controllers/Security/SecurityController.cs @@ -0,0 +1,86 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Api.Controllers.Security; + +using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; +using AliasVault.AuthLogging; +using AliasVault.Shared.Models.WebApi.Security; +using Asp.Versioning; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// Security controller for handling security related actions such as showing auth logs or revoking sessions for user. +/// +/// AliasServerDbContext instance. +/// UserManager instance. +[Route("api/v{version:apiVersion}/[controller]")] +[ApiController] +[ApiVersion("1")] +public class SecurityController(IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) +{ + /// + /// Returns list of active sessions (refresh tokens) for the current user. + /// + /// Task with list of active refresh tokens. + [HttpGet("sessions")] + public async Task Sessions() + { + var user = await GetCurrentUserAsync(); + if (user is null) + { + return Unauthorized("Not authenticated."); + } + + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var refreshTokenList = await context.AliasVaultUserRefreshTokens.Where(x => x.UserId == user.Id).Select(x => new RefreshTokenModel() + { + Id = x.Id, + DeviceIdentifier = x.DeviceIdentifier, + ExpireDate = x.ExpireDate, + CreatedAt = x.CreatedAt, + }) + .OrderBy(x => x.CreatedAt) + .ToListAsync(); + + return Ok(refreshTokenList); + } + + /// + /// Revokes a specific user session (refresh token). + /// + /// The ID of the refresh token to revoke. + /// Http200 if success. + [HttpDelete("sessions/{id}")] + public async Task RevokeSession(Guid id) + { + var user = await GetCurrentUserAsync(); + if (user is null) + { + return Unauthorized("Not authenticated."); + } + + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var refreshToken = await context.AliasVaultUserRefreshTokens + .FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id); + + if (refreshToken == null) + { + return NotFound("Session not found or does not belong to the current user."); + } + + context.AliasVaultUserRefreshTokens.Remove(refreshToken); + await context.SaveChangesAsync(); + + return Ok(); + } +} diff --git a/src/AliasVault.Api/Controllers/TwoFactorAuthController.cs b/src/AliasVault.Api/Controllers/Security/TwoFactorAuthController.cs similarity index 69% rename from src/AliasVault.Api/Controllers/TwoFactorAuthController.cs rename to src/AliasVault.Api/Controllers/Security/TwoFactorAuthController.cs index f16b4660b..4ead944e1 100644 --- a/src/AliasVault.Api/Controllers/TwoFactorAuthController.cs +++ b/src/AliasVault.Api/Controllers/Security/TwoFactorAuthController.cs @@ -5,10 +5,11 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Api.Controllers; +namespace AliasVault.Api.Controllers.Security; using System.Text.Encodings.Web; using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; using AliasVault.AuthLogging; using Asp.Versioning; using Microsoft.AspNetCore.Identity; @@ -19,13 +20,13 @@ using Microsoft.EntityFrameworkCore; /// Auth controller for handling authentication. /// /// AliasServerDbContext instance. -/// UserManager instance. /// UrlEncoder instance. /// AuthLoggingService instance. This is used to log auth attempts to the database. +/// UserManager instance. [Route("api/v{version:apiVersion}/[controller]")] [ApiController] [ApiVersion("1")] -public class TwoFactorAuthController(IDbContextFactory dbContextFactory, UserManager userManager, UrlEncoder urlEncoder, AuthLoggingService authLoggingService) : ControllerBase +public class TwoFactorAuthController(IDbContextFactory dbContextFactory, UrlEncoder urlEncoder, AuthLoggingService authLoggingService, UserManager userManager) : AuthenticatedRequestController(userManager) { /// /// Get two-factor authentication enabled status for a user. @@ -34,13 +35,13 @@ public class TwoFactorAuthController(IDbContextFactory dbC [HttpGet("status")] public async Task Status() { - var user = await userManager.GetUserAsync(User); - if (user == null) + var user = await GetCurrentUserAsync(); + if (user is null) { - return NotFound(); + return Unauthorized("Not authenticated."); } - var twoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user); + var twoFactorEnabled = await GetUserManager().GetTwoFactorEnabledAsync(user); return Ok(new { TwoFactorEnabled = twoFactorEnabled }); } @@ -51,17 +52,17 @@ public class TwoFactorAuthController(IDbContextFactory dbC [HttpPost("enable")] public async Task Enable() { - var user = await userManager.GetUserAsync(User); - if (user == null) + var user = await GetCurrentUserAsync(); + if (user is null) { - return NotFound(); + return Unauthorized("Not authenticated."); } - var authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user); + var authenticatorKey = await GetUserManager().GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(authenticatorKey)) { - await userManager.ResetAuthenticatorKeyAsync(user); - authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user); + await GetUserManager().ResetAuthenticatorKeyAsync(user); + authenticatorKey = await GetUserManager().GetAuthenticatorKeyAsync(user); } var encodedKey = urlEncoder.Encode(authenticatorKey!); @@ -78,20 +79,20 @@ public class TwoFactorAuthController(IDbContextFactory dbC [HttpPost("verify")] public async Task Verify([FromBody] string code) { - var user = await userManager.GetUserAsync(User); - if (user == null) + var user = await GetCurrentUserAsync(); + if (user is null) { - return NotFound(); + return Unauthorized("Not authenticated."); } - var isValid = await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, code); + var isValid = await GetUserManager().VerifyTwoFactorTokenAsync(user, GetUserManager().Options.Tokens.AuthenticatorTokenProvider, code); if (isValid) { - await userManager.SetTwoFactorEnabledAsync(user, true); + await GetUserManager().SetTwoFactorEnabledAsync(user, true); // Generate new recovery codes. - var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + var recoveryCodes = await GetUserManager().GenerateNewTwoFactorRecoveryCodesAsync(user, 10); await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.TwoFactorAuthEnable); @@ -108,16 +109,16 @@ public class TwoFactorAuthController(IDbContextFactory dbC [HttpPost("disable")] public async Task Disable() { - var user = await userManager.GetUserAsync(User); - if (user == null) + var user = await GetCurrentUserAsync(); + if (user is null) { - return NotFound(); + return Unauthorized("Not authenticated."); } await using var context = await dbContextFactory.CreateDbContextAsync(); // Disable 2FA and remove any existing authenticator key(s) and recovery codes. - await userManager.SetTwoFactorEnabledAsync(user, false); + await GetUserManager().SetTwoFactorEnabledAsync(user, false); context.UserTokens.RemoveRange( await context.UserTokens.Where( x => x.UserId == user.Id && diff --git a/src/AliasVault.Api/Controllers/TestController.cs b/src/AliasVault.Api/Controllers/Tests/TestController.cs similarity index 95% rename from src/AliasVault.Api/Controllers/TestController.cs rename to src/AliasVault.Api/Controllers/Tests/TestController.cs index 9fdf2a3b6..cc3683c86 100644 --- a/src/AliasVault.Api/Controllers/TestController.cs +++ b/src/AliasVault.Api/Controllers/Tests/TestController.cs @@ -11,9 +11,10 @@ * attack surfaces we don't include this file in the production build. */ -namespace AliasVault.Api.Controllers; +namespace AliasVault.Api.Controllers.Tests; using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index 5e6df936e..bd769a88a 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -9,6 +9,7 @@ namespace AliasVault.Api.Controllers; using System.ComponentModel.DataAnnotations; using AliasServerDb; +using AliasVault.Api.Controllers.Abstracts; using AliasVault.Api.Helpers; using AliasVault.Api.Vault; using AliasVault.Api.Vault.RetentionRules; diff --git a/src/AliasVault.Client/Services/CredentialService.cs b/src/AliasVault.Client/Services/CredentialService.cs index 11a1f3d53..15326c704 100644 --- a/src/AliasVault.Client/Services/CredentialService.cs +++ b/src/AliasVault.Client/Services/CredentialService.cs @@ -97,21 +97,6 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService return domainToUse; } - /// - /// Generate random identity by calling the IdentityGenerator API. - /// - /// Identity object. - public async Task GenerateRandomIdentityAsync() - { - var identity = await httpClient.GetFromJsonAsync("api/v1/Identity/Generate"); - if (identity is null) - { - throw new InvalidOperationException("Failed to generate random identity."); - } - - return identity; - } - /// /// Insert new entry into database. /// diff --git a/src/AliasVault.Shared/Models/WebApi/Security/RefreshTokenModel.cs b/src/AliasVault.Shared/Models/WebApi/Security/RefreshTokenModel.cs new file mode 100644 index 000000000..379828fdb --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/Security/RefreshTokenModel.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Security; + +/// +/// RefreshToken (user session) model. +/// +public class RefreshTokenModel +{ + /// + /// Gets or sets the unique identifier for the refresh token. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the device identifier associated with the refresh token. + /// + public string DeviceIdentifier { get; set; } = null!; + + /// + /// Gets or sets the expiration date of the refresh token. + /// + public DateTime ExpireDate { get; set; } + + /// + /// Gets or sets the creation date of the refresh token. + /// + public DateTime CreatedAt { get; set; } +}