Add self-service profile and account deletion APIs
Deploy Promiscuity Auth API / deploy (push) Successful in 1m28s
Deploy Promiscuity Character API / deploy (push) Successful in 1m24s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m20s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m24s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m25s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m20s
Deploy Promiscuity World API / deploy (push) Successful in 1m25s
k8s smoke test / test (push) Successful in 20s
Deploy Promiscuity Auth API / deploy (push) Successful in 1m28s
Deploy Promiscuity Character API / deploy (push) Successful in 1m24s
Deploy Promiscuity Crafting API / deploy (push) Successful in 1m20s
Deploy Promiscuity Inventory API / deploy (push) Successful in 1m24s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m25s
Deploy Promiscuity Mail API / deploy (push) Successful in 1m20s
Deploy Promiscuity World API / deploy (push) Successful in 1m25s
k8s smoke test / test (push) Successful in 20s
This commit is contained in:
@@ -91,6 +91,16 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
kubectl create namespace promiscuity-auth --dry-run=client -o yaml | kubectl apply -f -
|
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
|
# Apply Kubernetes manifests
|
||||||
# (You create these files in your repo)
|
# (You create these files in your repo)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using MongoDB.Bson;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Net.Mail;
|
using System.Net.Mail;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
namespace AuthApi.Controllers;
|
namespace AuthApi.Controllers;
|
||||||
|
|
||||||
@@ -19,10 +21,11 @@ public class AuthController : ControllerBase
|
|||||||
private readonly UserService _users;
|
private readonly UserService _users;
|
||||||
private readonly IConfiguration _cfg;
|
private readonly IConfiguration _cfg;
|
||||||
private readonly BlacklistService _blacklist;
|
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")]
|
[HttpPost("register")]
|
||||||
@@ -44,7 +47,7 @@ public class AuthController : ControllerBase
|
|||||||
return Conflict("Username already exists");
|
return Conflict("Username already exists");
|
||||||
|
|
||||||
var hash = BCrypt.Net.BCrypt.HashPassword(password);
|
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
|
try
|
||||||
{
|
{
|
||||||
await _users.CreateAsync(user);
|
await _users.CreateAsync(user);
|
||||||
@@ -93,6 +96,80 @@ public class AuthController : ControllerBase
|
|||||||
return Ok("Logged out.");
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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")]
|
[HttpPost("role")]
|
||||||
[Authorize(Roles = "SUPER")]
|
[Authorize(Roles = "SUPER")]
|
||||||
public async Task<IActionResult> ChangeUserRole([FromBody] ChangeRoleRequest req)
|
public async Task<IActionResult> ChangeUserRole([FromBody] ChangeRoleRequest req)
|
||||||
@@ -129,4 +206,39 @@ public class AuthController : ControllerBase
|
|||||||
var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
|
var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
|
||||||
return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
|
return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 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 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; } = ""; }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ public class User
|
|||||||
public string PasswordHash { get; set; } = default!;
|
public string PasswordHash { get; set; } = default!;
|
||||||
public string Role { get; set; } = "USER";
|
public string Role { get; set; } = "USER";
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
public DateTime? CreatedUtc { get; set; }
|
||||||
public string? RefreshToken { get; set; }
|
public string? RefreshToken { get; set; }
|
||||||
public DateTime? RefreshTokenExpiry { get; set; }
|
public DateTime? RefreshTokenExpiry { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ builder.Services.AddControllers();
|
|||||||
// DI
|
// DI
|
||||||
builder.Services.AddSingleton<UserService>();
|
builder.Services.AddSingleton<UserService>();
|
||||||
builder.Services.AddSingleton<BlacklistService>();
|
builder.Services.AddSingleton<BlacklistService>();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// Swagger + JWT auth in Swagger
|
// Swagger + JWT auth in Swagger
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|||||||
@@ -22,11 +22,19 @@ public class UserService
|
|||||||
public async Task<User?> GetByUsernameAsync(string username) =>
|
public async Task<User?> GetByUsernameAsync(string username) =>
|
||||||
await _col.Find(u => u.Username == username).FirstOrDefaultAsync();
|
await _col.Find(u => u.Username == username).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<User?> GetByIdAsync(string id) =>
|
||||||
|
await _col.Find(u => u.Id == id).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<User?> GetByEmailAsync(string email) =>
|
||||||
|
await _col.Find(u => u.Email == email).FirstOrDefaultAsync();
|
||||||
|
|
||||||
public Task CreateAsync(User user) => _col.InsertOneAsync(user);
|
public Task CreateAsync(User user) => _col.InsertOneAsync(user);
|
||||||
|
|
||||||
public Task UpdateAsync(User user) =>
|
public Task UpdateAsync(User user) =>
|
||||||
_col.ReplaceOneAsync(u => u.Id == user.Id, user);
|
_col.ReplaceOneAsync(u => u.Id == user.Id, user);
|
||||||
|
|
||||||
|
public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id);
|
||||||
|
|
||||||
public Task<List<User>> GetAllAsync() =>
|
public Task<List<User>> GetAllAsync() =>
|
||||||
_col.Find(FilterDefinition<User>.Empty).ToListAsync();
|
_col.Find(FilterDefinition<User>.Empty).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ spec:
|
|||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5000
|
- 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:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
|
|||||||
Reference in New Issue
Block a user