feat(account): add deletion lifecycle
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/account-lifecycle")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class AccountLifecycleController(
|
||||
JobTrackerContext db,
|
||||
AccountDeletionService deletions,
|
||||
TimeProvider timeProvider) : ControllerBase
|
||||
{
|
||||
public sealed record DeleteAccountRequest(string Confirmation);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken);
|
||||
if (user is null) return NotFound();
|
||||
var request = await db.AccountDeletionRequests.AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderByDescending(item => item.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
deletionEnabled = deletions.CanAcceptRequests,
|
||||
deletionStatus = user.DeletionStatus,
|
||||
requiredConfirmation = $"DELETE {user.Email}",
|
||||
request = request is null ? null : ToDto(request),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("delete")]
|
||||
[EnableRateLimiting("account-data")]
|
||||
public async Task<IActionResult> Delete([FromBody] DeleteAccountRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!deletions.CanAcceptRequests)
|
||||
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved.");
|
||||
var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken);
|
||||
if (user is null) return NotFound();
|
||||
if (!string.Equals(request.Confirmation?.Trim(), $"DELETE {user.Email}", StringComparison.Ordinal))
|
||||
return BadRequest("Type the exact confirmation shown before deleting the account.");
|
||||
if (!await IsRecentlyAuthenticated(ownerUserId, cancellationToken))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails { Title = "Recent sign-in required", Detail = "Sign in again before deleting the account." });
|
||||
if (await IsFinalAdministrator(ownerUserId, cancellationToken))
|
||||
return Conflict(new ProblemDetails { Title = "Last administrator protected", Detail = "Assign the Admin role to another user before deleting the final administrator." });
|
||||
|
||||
var result = await deletions.RequestAsync(ownerUserId, ownerUserId, cancellationToken);
|
||||
if (result is null) return StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
ExpireCookies();
|
||||
return Accepted(new { requestId = result.RequestId, status = result.Status, stage = result.Stage });
|
||||
}
|
||||
|
||||
[HttpGet("admin/requests/{id:guid}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> AdminStatus(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = await db.AccountDeletionRequests.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
return request is null ? NotFound() : Ok(ToDto(request));
|
||||
}
|
||||
|
||||
private async Task<bool> IsRecentlyAuthenticated(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var sid = User.FindFirstValue("sid");
|
||||
if (string.IsNullOrWhiteSpace(sid)) return false;
|
||||
var session = await db.UserSessions.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.Id == sid && item.UserId == ownerUserId, cancellationToken);
|
||||
return session is { RevokedAtUtc: null } && session.CreatedAtUtc >= timeProvider.GetUtcNow().AddMinutes(-15);
|
||||
}
|
||||
|
||||
private async Task<bool> IsFinalAdministrator(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var adminRoleId = await db.Roles.Where(item => item.NormalizedName == "ADMIN").Select(item => item.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
if (adminRoleId is null) return false;
|
||||
var isAdmin = await db.UserRoles.AnyAsync(item => item.UserId == ownerUserId && item.RoleId == adminRoleId, cancellationToken);
|
||||
return isAdmin && await db.UserRoles.CountAsync(item => item.RoleId == adminRoleId, cancellationToken) <= 1;
|
||||
}
|
||||
|
||||
private void ExpireCookies()
|
||||
{
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(Request.IsHttps));
|
||||
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(Request.IsHttps));
|
||||
TrustedDeviceService.ClearCookie(Response, Request.IsHttps);
|
||||
}
|
||||
|
||||
private static object ToDto(AccountDeletionRequest request) => new
|
||||
{
|
||||
request.Id,
|
||||
request.Status,
|
||||
request.Stage,
|
||||
request.AttemptCount,
|
||||
request.DatabaseRowCount,
|
||||
request.FileCount,
|
||||
request.WarningJson,
|
||||
request.LastErrorCategory,
|
||||
request.LastErrorMessage,
|
||||
request.RequestedAtUtc,
|
||||
request.StartedAtUtc,
|
||||
request.CompletedAtUtc,
|
||||
};
|
||||
}
|
||||
@@ -1087,6 +1087,8 @@ public sealed class AuthController : ControllerBase
|
||||
// decorative: skipping straight to AppSessionIssuer here would defeat the whole feature.
|
||||
private async Task<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
|
||||
{
|
||||
if (user.DeletionStatus != AccountDeletionStatuses.Active)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "account_deletion_pending" });
|
||||
// "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a
|
||||
// non-expired row for this exact user, skip straight to a real session, same as if 2FA
|
||||
// weren't required at all. Falls through to the normal gate for any other outcome
|
||||
|
||||
@@ -196,7 +196,7 @@ public sealed class TwoFactorController : ControllerBase
|
||||
if (session is null) return Unauthorized();
|
||||
|
||||
var user = await _users.FindByIdAsync(session.UserId);
|
||||
if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
|
||||
if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@ public sealed class UsersController : ControllerBase
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly ExternalOrigin _externalOrigin;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null)
|
||||
private readonly AccountDeletionService? _deletions;
|
||||
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_email = email;
|
||||
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
||||
_logger = logger;
|
||||
_deletions = deletions;
|
||||
}
|
||||
|
||||
public sealed record UserDto(
|
||||
@@ -163,11 +165,16 @@ public sealed class UsersController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
var res = await _users.DeleteAsync(u);
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
return NoContent();
|
||||
if (_deletions is null || !_deletions.CanAcceptRequests)
|
||||
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved.");
|
||||
if (!string.Equals(Request.Headers["X-Confirm-Account-Deletion"].ToString(), u.Email, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest("Confirm the exact account email before deletion.");
|
||||
var requestedBy = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrWhiteSpace(requestedBy)) return Unauthorized();
|
||||
var request = await _deletions.RequestAsync(u.Id, requestedBy, cancellationToken);
|
||||
return request is null
|
||||
? Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable")
|
||||
: Accepted(new { requestId = request.RequestId, status = request.Status, stage = request.Stage });
|
||||
}
|
||||
|
||||
[HttpPost("{id}/send-password-reset")]
|
||||
|
||||
Reference in New Issue
Block a user