Merge branch 'feature/auth-2fa-security' into main
Auth/registration/account-security overhaul: per-account lockout, TOTP 2FA (RFC 6238) with recovery codes, trusted devices (30-day 2FA skip), configurable email verification enforcement, and server-tracked sessions (view/revoke/sign-out-others). Full security-settings UI and login/OAuth 2FA challenge step. # Conflicts: # JobTrackerApi/Services/StartupInitializationExtensions.cs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -21,8 +22,10 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly IGoogleTokenValidator _googleTokens;
|
||||
private readonly IMicrosoftTokenValidator _microsoftTokens;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
private readonly ITwoFactorPendingTokenService _twoFactorPending;
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger)
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
@@ -31,6 +34,8 @@ public sealed class AuthController : ControllerBase
|
||||
_googleTokens = googleTokens;
|
||||
_microsoftTokens = microsoftTokens;
|
||||
_logger = logger;
|
||||
_twoFactorPending = twoFactorPending;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
@@ -41,6 +46,7 @@ public sealed class AuthController : ControllerBase
|
||||
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
|
||||
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -49,12 +55,14 @@ public sealed class AuthController : ControllerBase
|
||||
microsoftEnabled,
|
||||
localEnabled = true,
|
||||
allowRegistration,
|
||||
requireEmailVerification,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
||||
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MeResult(
|
||||
@@ -83,7 +91,7 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
var password = request.Password ?? string.Empty;
|
||||
@@ -94,17 +102,34 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var ok = await _users.CheckPasswordAsync(user, password);
|
||||
if (!ok) return Unauthorized();
|
||||
// Same generic 401 whether the account doesn't exist, is locked out, or the password is
|
||||
// wrong -- don't let a client distinguish "locked" from "wrong password" (enumeration).
|
||||
if (await _users.IsLockedOutAsync(user)) return Unauthorized();
|
||||
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "local"));
|
||||
var ok = await _users.CheckPasswordAsync(user, password);
|
||||
if (!ok)
|
||||
{
|
||||
await _users.AccessFailedAsync(user);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
await _users.ResetAccessFailedCountAsync(user);
|
||||
|
||||
// Same enumeration-avoidance discipline as the password-check branch above: this only
|
||||
// runs once the password is already confirmed correct, so it can never be used to probe
|
||||
// whether an email is registered.
|
||||
if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
|
||||
}
|
||||
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
if (!allow) return StatusCode(403, "Registration is disabled.");
|
||||
@@ -118,21 +143,35 @@ public sealed class AuthController : ControllerBase
|
||||
var existing = await _users.FindByEmailAsync(email);
|
||||
if (existing is not null) return BadRequest("User already exists.");
|
||||
|
||||
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
|
||||
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification };
|
||||
var res = await _users.CreateAsync(user, password);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "local"));
|
||||
if (requireEmailVerification)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendVerificationEmailAsync(user, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ponytail: don't fail registration over a flaky email send -- the account is
|
||||
// created either way, the user can request a fresh link via resend-verification-email.
|
||||
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
|
||||
}
|
||||
}
|
||||
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("google/exchange")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Google token is required.");
|
||||
@@ -193,14 +232,13 @@ public sealed class AuthController : ControllerBase
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "google"));
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("microsoft/exchange")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Microsoft token is required.");
|
||||
@@ -261,8 +299,7 @@ public sealed class AuthController : ControllerBase
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "microsoft"));
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
@@ -650,17 +687,112 @@ public sealed class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record VerifyEmailRequest(string UserId, string Token);
|
||||
|
||||
[HttpPost("verify-email")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
|
||||
{
|
||||
var userId = (request.UserId ?? string.Empty).Trim();
|
||||
var token = request.Token ?? string.Empty;
|
||||
|
||||
if (userId.Length == 0) return BadRequest("UserId is required.");
|
||||
if (token.Length == 0) return BadRequest("Token is required.");
|
||||
|
||||
var user = await _users.FindByIdAsync(userId);
|
||||
if (user is null) return BadRequest("Invalid or expired link.");
|
||||
|
||||
var res = await _users.ConfirmEmailAsync(user, token);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
return BadRequest("Invalid or expired link.");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record ResendVerificationEmailRequest(string Email);
|
||||
|
||||
[HttpPost("resend-verification-email")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
if (email.Length == 0) return NoContent();
|
||||
|
||||
// Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually
|
||||
// send when there's a matching local account that still needs verifying.
|
||||
var user = await _users.FindByEmailAsync(email);
|
||||
if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user))
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendVerificationEmailAsync(user, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
|
||||
return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later.");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
|
||||
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}";
|
||||
|
||||
await _email.SendAsync(
|
||||
user.Email!,
|
||||
"Verify your email",
|
||||
$"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
private IActionResult EmailDeliveryUnavailable(string detail)
|
||||
{
|
||||
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
|
||||
}
|
||||
|
||||
private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
|
||||
// Shared by local/Google/Microsoft sign-in. If the account has TOTP 2FA enabled, this does
|
||||
// NOT issue the real session -- it hands back a short-lived opaque pending token that only
|
||||
// POST /api/auth/2fa/challenge can redeem, after the caller proves they hold the TOTP device
|
||||
// (or a recovery code). This is the gate that makes 2FA actually mandatory rather than
|
||||
// decorative: skipping straight to AppSessionIssuer here would defeat the whole feature.
|
||||
private async Task<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
|
||||
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
|
||||
EnsureCsrfCookie(rememberMe, secure);
|
||||
// "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
|
||||
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
|
||||
if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
|
||||
{
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, provider));
|
||||
}
|
||||
|
||||
if (user.TwoFactorEnabled)
|
||||
{
|
||||
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
|
||||
return Ok(new TwoFactorRequiredResult(true, pendingToken));
|
||||
}
|
||||
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, provider));
|
||||
}
|
||||
|
||||
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// List/revoke the server-tracked UserSession rows behind the JWTs AppSessionIssuer hands out.
|
||||
// Not 2FA-specific (any local-auth user has sessions, 2FA or not), hence its own small controller
|
||||
// rather than folding into TwoFactorController.
|
||||
[ApiController]
|
||||
[Route("api/auth/sessions")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class SessionsController : ControllerBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db)
|
||||
{
|
||||
_users = users;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public sealed record SessionDto(string Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentSession);
|
||||
|
||||
private string? CurrentSid => User.FindFirst("sid")?.Value;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var currentSid = CurrentSid;
|
||||
// IgnoreQueryFilters + an explicit UserId filter, same convention as
|
||||
// TrustedDeviceService/TwoFactorController's device-list queries.
|
||||
// Equality-only in the DB query, then filter/sort DateTimeOffset client-side: SQLite's EF
|
||||
// Core provider cannot translate ">" or ORDER BY over DateTimeOffset to SQL ("SQLite does
|
||||
// not support expressions of type 'DateTimeOffset' in ORDER BY clauses"), so ExpiresAtUtc
|
||||
// comparison and the LastSeenAtUtc sort have to happen after materializing the (small,
|
||||
// per-user) row set.
|
||||
var candidates = await _db.UserSessions.IgnoreQueryFilters()
|
||||
.Where(x => x.UserId == user.Id && x.RevokedAtUtc == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var sessions = candidates
|
||||
.Where(x => x.ExpiresAtUtc > now)
|
||||
.OrderByDescending(x => x.LastSeenAtUtc)
|
||||
.Select(x => new SessionDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, x.Id == currentSid))
|
||||
.ToList();
|
||||
|
||||
return Ok(sessions);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Revoke(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var session = await _db.UserSessions.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
|
||||
session.RevokedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (string.Equals(id, CurrentSid, StringComparison.Ordinal))
|
||||
{
|
||||
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("revoke-others")]
|
||||
public async Task<IActionResult> RevokeOthers(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var currentSid = CurrentSid;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var others = await _db.UserSessions.IgnoreQueryFilters()
|
||||
.Where(x => x.UserId == user.Id && x.RevokedAtUtc == null && x.Id != currentSid)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var session in others)
|
||||
{
|
||||
session.RevokedAtUtc = now;
|
||||
}
|
||||
if (others.Count > 0)
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OtpNet;
|
||||
using QRCoder;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// TOTP 2FA (RFC 6238) + recovery codes. Split out from AuthController (already 700+ lines)
|
||||
// rather than growing it further; shares the session cookie logic via AppSessionIssuer and the
|
||||
// pending-token handoff via ITwoFactorPendingTokenService.
|
||||
[ApiController]
|
||||
[Route("api/auth/2fa")]
|
||||
public sealed class TwoFactorController : ControllerBase
|
||||
{
|
||||
private const int RecoveryCodeCount = 10;
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ITokenService _tokens;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly ITwoFactorPendingTokenService _pending;
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg)
|
||||
{
|
||||
_users = users;
|
||||
_tokens = tokens;
|
||||
_db = db;
|
||||
_pending = pending;
|
||||
_protector = protectionProvider.CreateProtector("totp-secret-v1");
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
public sealed record PasswordConfirmRequest(string CurrentPassword);
|
||||
public sealed record SetupResult(string ManualEntryKey, string QrCodeDataUrl);
|
||||
public sealed record VerifySetupRequest(string Code);
|
||||
public sealed record VerifySetupResult(bool Enabled, IReadOnlyList<string> RecoveryCodes);
|
||||
public sealed record StatusResult(bool Enabled, DateTimeOffset? EnabledAtUtc);
|
||||
public sealed record RecoveryCodesResult(IReadOnlyList<string> RecoveryCodes);
|
||||
public sealed record ChallengeRequest(string PendingToken, string Code, bool TrustDevice = false);
|
||||
public sealed record TrustedDeviceDto(int Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentDevice);
|
||||
|
||||
[HttpPost("setup")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<IActionResult> Setup([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
{
|
||||
return BadRequest("Current password is incorrect.");
|
||||
}
|
||||
|
||||
var secretBytes = KeyGeneration.GenerateRandomKey(20);
|
||||
var base32Secret = Base32Encoding.ToString(secretBytes);
|
||||
|
||||
user.TotpPendingSecretEncrypted = _protector.Protect(base32Secret);
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
var issuer = "JobTracker";
|
||||
var label = Uri.EscapeDataString($"{issuer}:{user.Email}");
|
||||
var otpauthUri = $"otpauth://totp/{label}?secret={base32Secret}&issuer={Uri.EscapeDataString(issuer)}&digits=6&period=30";
|
||||
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
using var qrData = qrGenerator.CreateQrCode(otpauthUri, QRCodeGenerator.ECCLevel.Q);
|
||||
var pngQr = new PngByteQRCode(qrData);
|
||||
var qrPngBytes = pngQr.GetGraphic(10);
|
||||
var qrDataUrl = $"data:image/png;base64,{Convert.ToBase64String(qrPngBytes)}";
|
||||
|
||||
return Ok(new SetupResult(base32Secret, qrDataUrl));
|
||||
}
|
||||
|
||||
[HttpPost("verify-setup")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<IActionResult> VerifySetup([FromBody] VerifySetupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.TotpPendingSecretEncrypted))
|
||||
{
|
||||
return BadRequest("No pending 2FA setup. Call setup first.");
|
||||
}
|
||||
|
||||
var base32Secret = _protector.Unprotect(user.TotpPendingSecretEncrypted);
|
||||
if (!VerifyCode(base32Secret, request.Code))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.TotpSecretEncrypted = user.TotpPendingSecretEncrypted;
|
||||
user.TotpPendingSecretEncrypted = null;
|
||||
user.TwoFactorEnabled = true;
|
||||
user.TotpEnabledAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
var codes = await RegenerateRecoveryCodesAsync(user.Id, cancellationToken);
|
||||
return Ok(new VerifySetupResult(true, codes));
|
||||
}
|
||||
|
||||
[HttpPost("disable")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<IActionResult> Disable([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
{
|
||||
return BadRequest("Current password is incorrect.");
|
||||
}
|
||||
|
||||
user.TotpSecretEncrypted = null;
|
||||
user.TotpPendingSecretEncrypted = null;
|
||||
user.TwoFactorEnabled = false;
|
||||
user.TotpEnabledAtUtc = null;
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
await RemoveAllRecoveryCodesAsync(user.Id, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> Status()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new StatusResult(user.TwoFactorEnabled, user.TotpEnabledAtUtc));
|
||||
}
|
||||
|
||||
[HttpPost("recovery-codes/regenerate")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<IActionResult> RegenerateRecoveryCodes([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!user.TwoFactorEnabled)
|
||||
{
|
||||
return BadRequest("Two-factor authentication is not enabled.");
|
||||
}
|
||||
|
||||
if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
|
||||
{
|
||||
return BadRequest("Current password is incorrect.");
|
||||
}
|
||||
|
||||
var codes = await RegenerateRecoveryCodesAsync(user.Id, cancellationToken);
|
||||
return Ok(new RecoveryCodesResult(codes));
|
||||
}
|
||||
|
||||
[HttpPost("challenge")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-2fa-challenge")]
|
||||
public async Task<IActionResult> Challenge([FromBody] ChallengeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var pendingToken = (request.PendingToken ?? string.Empty).Trim();
|
||||
var code = (request.Code ?? string.Empty).Trim();
|
||||
if (pendingToken.Length == 0 || code.Length == 0) return Unauthorized();
|
||||
|
||||
// Peek without consuming: only burn the pending token once the code actually checks out,
|
||||
// so a mistyped code doesn't force the user back through password login.
|
||||
var session = _pending.Resolve(pendingToken, consume: false);
|
||||
if (session is null) return Unauthorized();
|
||||
|
||||
var user = await _users.FindByIdAsync(session.UserId);
|
||||
if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var base32Secret = _protector.Unprotect(user.TotpSecretEncrypted);
|
||||
var verified = VerifyCode(base32Secret, code) || await TryConsumeRecoveryCodeAsync(user.Id, code, cancellationToken);
|
||||
if (!verified) return Unauthorized();
|
||||
|
||||
_pending.Resolve(pendingToken, consume: true);
|
||||
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, cancellationToken);
|
||||
|
||||
if (request.TrustDevice)
|
||||
{
|
||||
await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, cancellationToken);
|
||||
}
|
||||
|
||||
return Ok(new AuthController.AuthSessionResult(true, "local"));
|
||||
}
|
||||
|
||||
[HttpGet("trusted-devices")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> ListTrustedDevices(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request);
|
||||
// SQLite/Pomelo cannot translate DateTimeOffset ORDER BY to SQL (same issue as the
|
||||
// expiry check in TrustedDeviceService), so sort after materializing.
|
||||
var devices = await _db.TrustedDevices
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.Select(x => new TrustedDeviceDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, currentHash != null && x.TokenHash == currentHash))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(devices.OrderByDescending(x => x.LastSeenAtUtc).ToList());
|
||||
}
|
||||
|
||||
[HttpDelete("trusted-devices/{id:int}")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> RevokeTrustedDevice(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var device = await _db.TrustedDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken);
|
||||
if (device is null) return NotFound();
|
||||
|
||||
var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request);
|
||||
var isCurrentDevice = currentHash != null && string.Equals(device.TokenHash, currentHash, StringComparison.Ordinal);
|
||||
|
||||
_db.TrustedDevices.Remove(device);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (isCurrentDevice)
|
||||
{
|
||||
TrustedDeviceService.ClearCookie(Request, Response);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("trusted-devices/revoke-all")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> RevokeAllTrustedDevices(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var devices = await _db.TrustedDevices.Where(x => x.UserId == user.Id).ToListAsync(cancellationToken);
|
||||
if (devices.Count > 0)
|
||||
{
|
||||
_db.TrustedDevices.RemoveRange(devices);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
TrustedDeviceService.ClearCookie(Request, Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static bool VerifyCode(string base32Secret, string? code)
|
||||
{
|
||||
code = (code ?? string.Empty).Trim();
|
||||
if (code.Length == 0) return false;
|
||||
|
||||
var totp = new Totp(Base32Encoding.ToBytes(base32Secret));
|
||||
// +-1 step (30s) of drift, the RFC 6238 standard tolerance for clock skew between the
|
||||
// authenticator app and the server.
|
||||
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
|
||||
}
|
||||
|
||||
private async Task<bool> TryConsumeRecoveryCodeAsync(string userId, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var hash = HashRecoveryCode(code);
|
||||
var match = await _db.TwoFactorRecoveryCodes
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId && x.CodeHash == hash && x.UsedAtUtc == null, cancellationToken);
|
||||
if (match is null) return false;
|
||||
|
||||
match.UsedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
await RemoveAllRecoveryCodesAsync(userId, cancellationToken);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var plainCodes = new List<string>(RecoveryCodeCount);
|
||||
var rows = new List<TwoFactorRecoveryCode>(RecoveryCodeCount);
|
||||
for (var i = 0; i < RecoveryCodeCount; i++)
|
||||
{
|
||||
var plain = GenerateRecoveryCode();
|
||||
plainCodes.Add(plain);
|
||||
rows.Add(new TwoFactorRecoveryCode { UserId = userId, CodeHash = HashRecoveryCode(plain), CreatedAtUtc = now });
|
||||
}
|
||||
|
||||
_db.TwoFactorRecoveryCodes.AddRange(rows);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return plainCodes;
|
||||
}
|
||||
|
||||
private async Task RemoveAllRecoveryCodesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(x => x.UserId == userId).ToListAsync(cancellationToken);
|
||||
if (existing.Count == 0) return;
|
||||
_db.TwoFactorRecoveryCodes.RemoveRange(existing);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string GenerateRecoveryCode()
|
||||
{
|
||||
var hex = Convert.ToHexString(RandomNumberGenerator.GetBytes(5)).ToLowerInvariant(); // 10 hex chars, 40 bits
|
||||
return $"{hex[..5]}-{hex[5..]}";
|
||||
}
|
||||
|
||||
// ponytail: recovery codes are already random high-entropy tokens (not user-chosen
|
||||
// passwords), so a plain SHA-256 hash is sufficient -- no per-code salt or PBKDF2 needed.
|
||||
private static string HashRecoveryCode(string code)
|
||||
{
|
||||
var normalized = code.Trim().ToLowerInvariant();
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user