diff --git a/.gitea/workflows/deploy-auth.yml b/.gitea/workflows/deploy-auth.yml index 2d39408..9635ad9 100644 --- a/.gitea/workflows/deploy-auth.yml +++ b/.gitea/workflows/deploy-auth.yml @@ -91,6 +91,16 @@ jobs: run: | kubectl create namespace promiscuity-auth --dry-run=client -o yaml | kubectl apply -f - + - name: Configure internal API secret + env: + KUBECONFIG: /tmp/kube/config + INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }} + run: | + test -n "$INTERNAL_PURGE_SECRET" + kubectl create secret generic medmind-internal-api \ + --from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \ + --dry-run=client -o yaml | kubectl apply -n promiscuity-auth -f - + # ----------------------------- # Apply Kubernetes manifests # (You create these files in your repo) diff --git a/microservices/AuthApi/Controllers/AuthController.cs b/microservices/AuthApi/Controllers/AuthController.cs index 3f94fac..cdb513b 100644 --- a/microservices/AuthApi/Controllers/AuthController.cs +++ b/microservices/AuthApi/Controllers/AuthController.cs @@ -4,11 +4,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using MongoDB.Driver; +using MongoDB.Bson; using System.IdentityModel.Tokens.Jwt; using System.Net.Mail; using System.Security.Claims; using System.Text; using System.Text.RegularExpressions; +using System.Net.Http.Json; namespace AuthApi.Controllers; @@ -19,10 +21,11 @@ public class AuthController : ControllerBase private readonly UserService _users; private readonly IConfiguration _cfg; private readonly BlacklistService _blacklist; + private readonly IHttpClientFactory _httpClients; - public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist) + public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist, IHttpClientFactory httpClients) { - _users = users; _cfg = cfg; _blacklist = blacklist; + _users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; } [HttpPost("register")] @@ -44,7 +47,7 @@ public class AuthController : ControllerBase return Conflict("Username already exists"); var hash = BCrypt.Net.BCrypt.HashPassword(password); - var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email }; + var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow }; try { await _users.CreateAsync(user); @@ -93,6 +96,80 @@ public class AuthController : ControllerBase return Ok("Logged out."); } + [HttpGet("me")] + [Authorize(Roles = "USER,SUPER")] + public async Task Me() + { + var user = await CurrentUserAsync(); + return user is null ? Unauthorized() : Ok(ProfileResponse(user)); + } + + [HttpPatch("me")] + [Authorize(Roles = "USER,SUPER")] + public async Task 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 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 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 ChangeUserRole([FromBody] ChangeRoleRequest req) @@ -129,4 +206,39 @@ public class AuthController : ControllerBase var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds); return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp); } + + private async Task 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 static object ProfileResponse(User user) => new + { + userId = user.Id, + user.Username, + user.DisplayName, + user.Email, + user.Role, + createdUtc = user.CreatedUtc ?? CreationDate(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); + } } diff --git a/microservices/AuthApi/Models/Dto.cs b/microservices/AuthApi/Models/Dto.cs index cd6897c..12f0ccf 100644 --- a/microservices/AuthApi/Models/Dto.cs +++ b/microservices/AuthApi/Models/Dto.cs @@ -4,3 +4,6 @@ public class RegisterRequest { public string Username { get; set; } = ""; public public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; } public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; } public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; } +public class UpdateProfileRequest { public string? DisplayName { get; set; } public string? Email { get; set; } } +public class ChangePasswordRequest { public string CurrentPassword { get; set; } = ""; public string NewPassword { get; set; } = ""; } +public class DeleteAccountRequest { public string Password { get; set; } = ""; public string Confirmation { get; set; } = ""; } diff --git a/microservices/AuthApi/Models/User.cs b/microservices/AuthApi/Models/User.cs index 3ff9d76..57ca812 100644 --- a/microservices/AuthApi/Models/User.cs +++ b/microservices/AuthApi/Models/User.cs @@ -11,6 +11,8 @@ public class User public string PasswordHash { get; set; } = default!; public string Role { get; set; } = "USER"; public string? Email { get; set; } + public string? DisplayName { get; set; } + public DateTime? CreatedUtc { get; set; } public string? RefreshToken { get; set; } public DateTime? RefreshTokenExpiry { get; set; } } diff --git a/microservices/AuthApi/Program.cs b/microservices/AuthApi/Program.cs index 617173b..50b69a9 100644 --- a/microservices/AuthApi/Program.cs +++ b/microservices/AuthApi/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddControllers(); // DI builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); // Swagger + JWT auth in Swagger builder.Services.AddEndpointsApiExplorer(); diff --git a/microservices/AuthApi/Services/UserService.cs b/microservices/AuthApi/Services/UserService.cs index 9bfd504..59c0e0e 100644 --- a/microservices/AuthApi/Services/UserService.cs +++ b/microservices/AuthApi/Services/UserService.cs @@ -22,11 +22,19 @@ public class UserService public async Task GetByUsernameAsync(string username) => await _col.Find(u => u.Username == username).FirstOrDefaultAsync(); + public async Task GetByIdAsync(string id) => + await _col.Find(u => u.Id == id).FirstOrDefaultAsync(); + + public async Task GetByEmailAsync(string email) => + await _col.Find(u => u.Email == email).FirstOrDefaultAsync(); + public Task CreateAsync(User user) => _col.InsertOneAsync(user); public Task UpdateAsync(User user) => _col.ReplaceOneAsync(u => u.Id == user.Id, user); + public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id); + public Task> GetAllAsync() => _col.Find(FilterDefinition.Empty).ToListAsync(); } diff --git a/microservices/AuthApi/k8s/deployment.yaml b/microservices/AuthApi/k8s/deployment.yaml index 8d6ff80..31623db 100644 --- a/microservices/AuthApi/k8s/deployment.yaml +++ b/microservices/AuthApi/k8s/deployment.yaml @@ -20,6 +20,14 @@ spec: imagePullPolicy: IfNotPresent ports: - containerPort: 5000 + env: + - name: QuestionsApi__BaseUrl + value: http://step3-questions.step3-questions.svc.cluster.local + - name: InternalPurge__Secret + valueFrom: + secretKeyRef: + name: medmind-internal-api + key: purge-secret readinessProbe: httpGet: path: /healthz