Files
promiscuity/microservices/AuthApi/Controllers/AuthController.cs
T
admin 94ffe6e7b7
Deploy Promiscuity Auth API / deploy (push) Successful in 1m29s
k8s smoke test / test (push) Successful in 20s
Add versioned legal consent to authentication
2026-07-20 02:16:02 -05:00

259 lines
10 KiB
C#

using AuthApi.Models;
using AuthApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Bson;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Mail;
using System.Security.Claims;
using System.Text.RegularExpressions;
using System.Net.Http.Json;
namespace AuthApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly UserService _users;
private readonly IConfiguration _cfg;
private readonly BlacklistService _blacklist;
private readonly IHttpClientFactory _httpClients;
private readonly JwtTokenService _tokens;
private readonly LegalPolicy _legal;
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist,
IHttpClientFactory httpClients, JwtTokenService tokens, LegalPolicy legal)
{
_users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens; _legal = legal;
}
[HttpGet("legal")]
[AllowAnonymous]
public IActionResult Legal() => Ok(_legal.Status());
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{
var username = req.Username?.Trim() ?? "";
var password = req.Password ?? "";
var email = req.Email?.Trim() ?? "";
if (!req.AcceptLegal)
return BadRequest("You must agree to the Terms of Service and acknowledge the Privacy Policy");
if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$"))
return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores");
if (password.Length < 8)
return BadRequest("Password must be at least 8 characters");
var emailDomain = email.Split('@').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(email) || !MailAddress.TryCreate(email, out _) || !emailDomain.Contains('.'))
return BadRequest("A valid email address is required");
if (await _users.GetByUsernameAsync(username) != null)
return Conflict("Username already exists");
var hash = BCrypt.Net.BCrypt.HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow };
_legal.Accept(user, "password_registration");
try
{
await _users.CreateAsync(user);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict("Username already exists");
}
return Ok("User created");
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var candidates = await _users.GetByLoginCandidatesAsync(req.Username);
var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(req.Password, candidate.PasswordHash));
if (user == null)
return Unauthorized();
var (accessToken, jti, expUtc) = _tokens.Generate(user);
user.RefreshToken = Guid.NewGuid().ToString("N");
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await _users.UpdateAsync(user);
return Ok(SessionResponse(user, accessToken, expUtc, jti, user.RefreshToken));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
{
var user = await _users.GetByUsernameAsync(req.Username);
if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow)
return Unauthorized("Invalid or expired refresh token");
var (accessToken, _, expUtc) = _tokens.Generate(user);
return Ok(SessionResponse(user, accessToken, expUtc));
}
[HttpPost("logout")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Logout()
{
var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", "");
if (string.IsNullOrWhiteSpace(token)) return BadRequest("Token missing");
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo);
return Ok("Logged out.");
}
[HttpGet("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Me()
{
var user = await CurrentUserAsync();
return user is null ? Unauthorized() : Ok(ProfileResponse(user));
}
[HttpPost("legal/accept")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> AcceptLegal([FromBody] AcceptLegalRequest req)
{
if (!req.Accept) return BadRequest("Legal acceptance is required.");
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
_legal.Accept(user, "existing_user_prompt");
await _users.UpdateAsync(user);
return Ok(_legal.Status(user));
}
[HttpPatch("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest req)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
var displayName = req.DisplayName?.Trim();
var email = req.Email?.Trim().ToLowerInvariant();
if (!string.IsNullOrEmpty(displayName) && displayName.Length > 80)
return BadRequest("Display name must be 80 characters or fewer.");
if (!string.IsNullOrEmpty(email) && !ValidEmail(email))
return BadRequest("A valid email address is required.");
if (!string.IsNullOrEmpty(email))
{
var existing = await _users.GetByEmailAsync(email);
if (existing is not null && existing.Id != user.Id) return Conflict("Email address is already in use.");
}
user.DisplayName = string.IsNullOrEmpty(displayName) ? null : displayName;
user.Email = string.IsNullOrEmpty(email) ? null : email;
user.CreatedUtc ??= CreationDate(user);
await _users.UpdateAsync(user);
return Ok(ProfileResponse(user));
}
[HttpPost("me/change-password")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash)) return BadRequest("Current password is incorrect.");
if (req.NewPassword.Length < 8) return BadRequest("Password must be at least 8 characters.");
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
user.RefreshToken = null;
user.RefreshTokenExpiry = null;
await _users.UpdateAsync(user);
await RevokeCurrentTokenAsync();
return NoContent();
}
[HttpDelete("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> DeleteAccount([FromBody] DeleteAccountRequest req)
{
var user = await CurrentUserAsync();
if (user is null) return Unauthorized();
if (req.Confirmation != "DELETE") return BadRequest("Type DELETE to confirm permanent deletion.");
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return BadRequest("Password is incorrect.");
var baseUrl = _cfg["QuestionsApi:BaseUrl"]?.TrimEnd('/');
var secret = _cfg["InternalPurge:Secret"];
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(secret))
return StatusCode(503, "Account deletion is temporarily unavailable.");
var client = _httpClients.CreateClient();
client.Timeout = TimeSpan.FromSeconds(30);
using var purge = new HttpRequestMessage(HttpMethod.Delete, $"{baseUrl}/api/profile/internal/users/{Uri.EscapeDataString(user.Id)}");
purge.Headers.Add("X-MedMind-Internal-Key", secret);
using var response = await client.SendAsync(purge);
if (!response.IsSuccessStatusCode)
return StatusCode(503, "Study data could not be removed. The account was not deleted; please try again.");
await RevokeCurrentTokenAsync();
await _users.DeleteAsync(user.Id);
return NoContent();
}
[HttpPost("role")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> ChangeUserRole([FromBody] ChangeRoleRequest req)
{
if (req.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'");
var user = await _users.GetByUsernameAsync(req.Username);
if (user is null) return NotFound("User not found");
user.Role = req.NewRole;
await _users.UpdateAsync(user);
return Ok($"{req.Username}'s role updated to {req.NewRole}");
}
[HttpGet("users")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> GetAllUsers() => Ok(await _users.GetAllAsync());
private async Task<User?> CurrentUserAsync()
{
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
return string.IsNullOrWhiteSpace(id) ? null : await _users.GetByIdAsync(id);
}
private static bool ValidEmail(string email)
{
var domain = email.Split('@').LastOrDefault() ?? "";
return MailAddress.TryCreate(email, out _) && domain.Contains('.');
}
private static DateTime CreationDate(User user)
{
return ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow;
}
private object ProfileResponse(User user) => new
{
userId = user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Role,
createdUtc = user.CreatedUtc ?? CreationDate(user),
legal = _legal.Status(user)
};
private object SessionResponse(User user, string accessToken, DateTime expUtc,
string? jti = null, string? refreshToken = null) => new
{
accessToken,
refreshToken,
user.Username,
user.Role,
jti,
exp = expUtc,
legal = _legal.Status(user)
};
private async Task RevokeCurrentTokenAsync()
{
var token = HttpContext.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", "");
if (string.IsNullOrWhiteSpace(token)) return;
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo);
}
}