using System.Security.Claims; using System.Security.Cryptography; using Cleanuparr.Api.Extensions; using Cleanuparr.Api.Features.Auth.Contracts.Requests; using Cleanuparr.Api.Features.Auth.Contracts.Responses; using Cleanuparr.Api.Filters; using Cleanuparr.Infrastructure.Features.Auth; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Auth; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; namespace Cleanuparr.Api.Features.Auth.Controllers; [ApiController] [Route("api/account")] [Authorize] [NoCache] public sealed class AccountController : ControllerBase { private readonly UsersContext _usersContext; private readonly IPasswordService _passwordService; private readonly ITotpService _totpService; private readonly IPlexAuthService _plexAuthService; private readonly IOidcAuthService _oidcAuthService; private readonly ILogger _logger; public AccountController( UsersContext usersContext, IPasswordService passwordService, ITotpService totpService, IPlexAuthService plexAuthService, IOidcAuthService oidcAuthService, ILogger logger) { _usersContext = usersContext; _passwordService = passwordService; _totpService = totpService; _plexAuthService = plexAuthService; _oidcAuthService = oidcAuthService; _logger = logger; } [HttpGet] public async Task GetAccountInfo() { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } return Ok(new AccountInfoResponse { Username = user.Username, PlexLinked = user.PlexAccountId is not null, PlexUsername = user.PlexUsername, TwoFactorEnabled = user.TotpEnabled, ApiKeyPreview = user.ApiKey[..8] + "..." }); } [HttpPut("password")] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { if (await IsOidcExclusiveModeActive()) { return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." }); } var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash)) { return BadRequest(new { error = "Current password is incorrect" }); } DateTime now = DateTime.UtcNow; user.PasswordHash = _passwordService.HashPassword(request.NewPassword); user.UpdatedAt = now; // Revoke all existing refresh tokens so old sessions can't be reused var activeTokens = await _usersContext.RefreshTokens .Where(r => r.UserId == user.Id && r.RevokedAt == null) .ToListAsync(); foreach (var token in activeTokens) { token.RevokedAt = now; } await _usersContext.SaveChangesAsync(); _logger.LogInformation("Password changed for user {Username}", user.Username); return Ok(new { message = "Password changed" }); } [HttpPost("2fa/regenerate")] public async Task Regenerate2fa([FromBody] Regenerate2faRequest request) { var user = await GetCurrentUser(includeRecoveryCodes: true); if (user is null) { return Unauthorized(); } // Verify current credentials if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash)) { return BadRequest(new { error = "Incorrect password" }); } if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode)) { return BadRequest(new { error = "Invalid 2FA code" }); } // Generate new TOTP var secret = _totpService.GenerateSecret(); var qrUri = _totpService.GetQrCodeUri(secret, user.Username); var recoveryCodes = _totpService.GenerateRecoveryCodes(); user.TotpSecret = secret; user.UpdatedAt = DateTime.UtcNow; // Replace recovery codes _usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes); foreach (var code in recoveryCodes) { _usersContext.RecoveryCodes.Add(new RecoveryCode { Id = Guid.NewGuid(), UserId = user.Id, CodeHash = _totpService.HashRecoveryCode(code), IsUsed = false }); } await _usersContext.SaveChangesAsync(); _logger.LogInformation("2FA regenerated for user {Username}", user.Username); return Ok(new TotpSetupResponse { Secret = secret, QrCodeUri = qrUri, RecoveryCodes = recoveryCodes }); } [HttpPost("2fa/enable")] public async Task Enable2fa([FromBody] Enable2faRequest request) { var user = await GetCurrentUser(includeRecoveryCodes: true); if (user is null) { return Unauthorized(); } if (user.TotpEnabled) { return Conflict(new { error = "2FA is already enabled" }); } if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash)) { return BadRequest(new { error = "Incorrect password" }); } // Generate new TOTP var secret = _totpService.GenerateSecret(); var qrUri = _totpService.GetQrCodeUri(secret, user.Username); var recoveryCodes = _totpService.GenerateRecoveryCodes(); user.TotpSecret = secret; user.UpdatedAt = DateTime.UtcNow; // Replace any existing recovery codes _usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes); foreach (var code in recoveryCodes) { _usersContext.RecoveryCodes.Add(new RecoveryCode { Id = Guid.NewGuid(), UserId = user.Id, CodeHash = _totpService.HashRecoveryCode(code), IsUsed = false }); } await _usersContext.SaveChangesAsync(); _logger.LogInformation("2FA setup generated for user {Username}", user.Username); return Ok(new TotpSetupResponse { Secret = secret, QrCodeUri = qrUri, RecoveryCodes = recoveryCodes }); } [HttpPost("2fa/enable/verify")] public async Task VerifyEnable2fa([FromBody] VerifyTotpRequest request) { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } if (user.TotpEnabled) { return Conflict(new { error = "2FA is already enabled" }); } if (string.IsNullOrEmpty(user.TotpSecret)) { return BadRequest(new { error = "Generate 2FA setup first" }); } if (!_totpService.ValidateCode(user.TotpSecret, request.Code)) { return BadRequest(new { error = "Invalid verification code" }); } user.TotpEnabled = true; user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); _logger.LogInformation("2FA enabled for user {Username}", user.Username); return Ok(new { message = "2FA enabled" }); } [HttpPost("2fa/disable")] public async Task Disable2fa([FromBody] Disable2faRequest request) { var user = await GetCurrentUser(includeRecoveryCodes: true); if (user is null) { return Unauthorized(); } if (!user.TotpEnabled) { return BadRequest(new { error = "2FA is not enabled" }); } if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash)) { return BadRequest(new { error = "Incorrect password" }); } if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode)) { return BadRequest(new { error = "Invalid 2FA code" }); } user.TotpEnabled = false; user.TotpSecret = string.Empty; user.UpdatedAt = DateTime.UtcNow; // Remove all recovery codes _usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes); await _usersContext.SaveChangesAsync(); _logger.LogInformation("2FA disabled for user {Username}", user.Username); return Ok(new { message = "2FA disabled" }); } [HttpGet("api-key")] public async Task GetApiKey() { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } return Ok(new { apiKey = user.ApiKey }); } [HttpPost("api-key/regenerate")] public async Task RegenerateApiKey() { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } var bytes = new byte[32]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant(); user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); _logger.LogInformation("API key regenerated for user {Username}", user.Username); return Ok(new { apiKey = user.ApiKey }); } [HttpPost("plex/link")] public async Task StartPlexLink() { if (await IsOidcExclusiveModeActive()) { return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." }); } var pin = await _plexAuthService.RequestPin(); return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl }); } [HttpPost("plex/link/verify")] public async Task VerifyPlexLink([FromBody] PlexPinRequest request) { if (await IsOidcExclusiveModeActive()) { return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." }); } var pinResult = await _plexAuthService.CheckPin(request.PinId); if (!pinResult.Completed || pinResult.AuthToken is null) { return Ok(new { completed = false }); } var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken); var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } user.PlexAccountId = plexAccount.AccountId; user.PlexUsername = plexAccount.Username; user.PlexEmail = plexAccount.Email; user.PlexAuthToken = pinResult.AuthToken; user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); _logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}", user.Username, plexAccount.Username); return Ok(new { completed = true, plexUsername = plexAccount.Username }); } [HttpDelete("plex/link")] public async Task UnlinkPlex() { if (await IsOidcExclusiveModeActive()) { return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." }); } var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } user.PlexAccountId = null; user.PlexUsername = null; user.PlexEmail = null; user.PlexAuthToken = null; user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); _logger.LogInformation("Plex account unlinked for user {Username}", user.Username); return Ok(new { message = "Plex account unlinked" }); } [HttpGet("oidc")] public async Task GetOidcConfig() { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } return Ok(user.Oidc); } [HttpPut("oidc")] public async Task UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request) { try { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } request.ApplyTo(user.Oidc); user.Oidc.Validate(); user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); return Ok(new { message = "OIDC configuration updated" }); } catch (ValidationException ex) { return BadRequest(new { error = ex.Message }); } } [HttpPost("oidc/link")] public async Task StartOidcLink() { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } if (user.Oidc is not { Enabled: true }) { return BadRequest(new { error = "OIDC is not enabled" }); } var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl); _logger.LogDebug("OIDC link start: using redirect URI {RedirectUri}", redirectUri); try { var result = await _oidcAuthService.StartAuthorization(redirectUri, user.Id.ToString()); return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl }); } catch (InvalidOperationException ex) { _logger.LogWarning(ex, "Failed to start OIDC link authorization"); return StatusCode(429, new { error = ex.Message }); } } /// /// This endpoint must be [AllowAnonymous] because the IdP redirects the user's browser here /// without a Bearer token. Security is ensured by validating that the OIDC flow was initiated /// by an authenticated user (InitiatorUserId stored in the flow state during StartOidcLink). /// [AllowAnonymous] [HttpGet("oidc/link/callback")] public async Task OidcLinkCallback( [FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error) { var basePath = HttpContext.Request.GetSafeBasePath(); if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) { return Redirect($"{basePath}/settings/account?oidc_link_error=failed"); } // Fetch any user to get the configured redirect URL for the OIDC callback var oidcConfig = (await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync())?.Oidc; var redirectUri = GetOidcLinkCallbackUrl(oidcConfig?.RedirectUrl); _logger.LogDebug("OIDC link callback: using redirect URI {RedirectUri}", redirectUri); var result = await _oidcAuthService.HandleCallback(code, state, redirectUri); if (!result.Success || string.IsNullOrEmpty(result.Subject)) { _logger.LogWarning("OIDC link callback failed: {Error}", result.Error); return Redirect($"{basePath}/settings/account?oidc_link_error=failed"); } // Verify the flow was initiated by an authenticated user if (string.IsNullOrEmpty(result.InitiatorUserId) || !Guid.TryParse(result.InitiatorUserId, out var initiatorId)) { _logger.LogWarning("OIDC link callback missing initiator user ID"); return Redirect($"{basePath}/settings/account?oidc_link_error=failed"); } // Save the authorized subject to the user's OIDC config var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.Id == initiatorId); if (user is null) { _logger.LogWarning("OIDC link callback initiator user not found: {UserId}", result.InitiatorUserId); return Redirect($"{basePath}/settings/account?oidc_link_error=failed"); } user.Oidc.AuthorizedSubject = result.Subject; user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); _logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}", result.Subject, user.Username); return Redirect($"{basePath}/settings/account?oidc_link=success"); } [HttpDelete("oidc/link")] public async Task UnlinkOidc() { await UsersContext.Lock.WaitAsync(); try { var user = await GetCurrentUser(); if (user is null) { return Unauthorized(); } user.Oidc.AuthorizedSubject = string.Empty; user.Oidc.ExclusiveMode = false; user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); _logger.LogInformation("OIDC account unlinked for user {Username}", user.Username); return Ok(new { message = "OIDC account unlinked" }); } finally { UsersContext.Lock.Release(); } } private string GetOidcLinkCallbackUrl(string? redirectUrl = null) { var baseUrl = string.IsNullOrEmpty(redirectUrl) ? HttpContext.GetExternalBaseUrl() : redirectUrl.TrimEnd('/'); return $"{baseUrl}/api/account/oidc/link/callback"; } private async Task IsOidcExclusiveModeActive() { var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync(); if (user is not { SetupCompleted: true }) { return false; } var oidc = user.Oidc; return oidc is { Enabled: true, ExclusiveMode: true }; } private async Task GetCurrentUser(bool includeRecoveryCodes = false) { var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (userIdClaim is null || !Guid.TryParse(userIdClaim, out var userId)) { return null; } var query = _usersContext.Users.AsQueryable(); if (includeRecoveryCodes) { query = query.Include(u => u.RecoveryCodes); } return await query.FirstOrDefaultAsync(u => u.Id == userId); } }