using System.Security.Claims; using System.Text; using CareFix.Api.Infrastructure; using CareFix.Api.Options; using Dapper; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using OtpNet; namespace CareFix.Api.Auth; public sealed class UserRow { public int UserId { get; set; } public string Username { get; set; } = ""; public string FullName { get; set; } = ""; public string PasswordHash { get; set; } = ""; public string? TotpSecret { get; set; } public string? TotpPending { get; set; } public string Role { get; set; } = ""; public bool IsActive { get; set; } } public sealed record LoginInput(string Username, string Password, string? Code); public sealed record UserInput(string Username, string FullName, string Role, bool IsActive, string? Password); public sealed class AuthService(ControlDb db, IOptions options, AuditService audit) { public async Task LoginAsync(LoginInput i, CancellationToken ct) { await using var c = await db.OpenAsync(ct); var u = await c.QuerySingleOrDefaultAsync("SELECT * FROM CF_USER WHERE Username = @Username", new { i.Username }); if (u is null || !u.IsActive || !BCrypt.Net.BCrypt.Verify(i.Password ?? "", u.PasswordHash)) { await audit.LogAsync(u?.UserId, null, "LoginFailed", new { i.Username }, ct); throw new AppException(401, "Wrong username or password."); } if (u.TotpSecret is not null) { if (string.IsNullOrWhiteSpace(i.Code)) throw new AppException(401, "Enter the 6-digit code from your authenticator app.", "totp_required"); if (!VerifyCode(u.TotpSecret, i.Code)) throw new AppException(401, "That code is not valid. Check your phone's time and try again.", "totp_required"); } await audit.LogAsync(u.UserId, null, "Login", null, ct); return new { token = CreateToken(u), user = Public(u) }; } public static object Public(UserRow u) => new { u.UserId, u.Username, u.FullName, u.Role, totpEnabled = u.TotpSecret is not null }; public async Task MeAsync(int userId, CancellationToken ct) { await using var c = await db.OpenAsync(ct); var u = await c.QuerySingleOrDefaultAsync("SELECT * FROM CF_USER WHERE UserId = @userId AND IsActive = 1", new { userId }) ?? throw new AppException(401, "Please sign in again."); return Public(u); } private string CreateToken(UserRow u) { var j = options.Value.Jwt; var creds = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(j.SigningKey)), SecurityAlgorithms.HmacSha256); return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { Issuer = j.Issuer, Audience = j.Audience, Subject = new ClaimsIdentity( [ new Claim(ClaimTypes.NameIdentifier, u.UserId.ToString()), new Claim(ClaimTypes.Name, u.FullName), new Claim(ClaimTypes.Role, u.Role), ]), Expires = DateTime.UtcNow.AddHours(j.HoursValid), SigningCredentials = creds, }); } private static bool VerifyCode(string secret, string code) => new Totp(Base32Encoding.ToBytes(secret)).VerifyTotp(code.Trim().Replace(" ", ""), out _, VerificationWindow.RfcSpecifiedNetworkDelay); public async Task StartTotpAsync(int userId, CancellationToken ct) { var secret = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20)); await using var c = await db.OpenAsync(ct); var username = await c.ExecuteScalarAsync("UPDATE CF_USER SET TotpPending=@secret OUTPUT INSERTED.Username WHERE UserId=@userId", new { secret, userId }); return new { secret, uri = $"otpauth://totp/CareFix:{Uri.EscapeDataString(username ?? "user")}?secret={secret}&issuer=CareFix" }; } public async Task ConfirmTotpAsync(int userId, string code, CancellationToken ct) { await using var c = await db.OpenAsync(ct); var pending = await c.ExecuteScalarAsync("SELECT TotpPending FROM CF_USER WHERE UserId=@userId", new { userId }) ?? throw AppException.BadRequest("Start two-step sign-in setup first."); if (!VerifyCode(pending, code)) throw AppException.BadRequest("That code is not valid. Scan the key again and use the newest code."); await c.ExecuteAsync("UPDATE CF_USER SET TotpSecret=TotpPending, TotpPending=NULL WHERE UserId=@userId", new { userId }); await audit.LogAsync(userId, null, "TotpEnabled", null, ct); } public async Task ChangePasswordAsync(int userId, string current, string next, CancellationToken ct) { ValidatePassword(next); await using var c = await db.OpenAsync(ct); var hash = await c.ExecuteScalarAsync("SELECT PasswordHash FROM CF_USER WHERE UserId=@userId", new { userId }); if (hash is null || !BCrypt.Net.BCrypt.Verify(current, hash)) throw AppException.BadRequest("The current password is wrong."); await c.ExecuteAsync("UPDATE CF_USER SET PasswordHash=@h WHERE UserId=@userId", new { h = BCrypt.Net.BCrypt.HashPassword(next, 12), userId }); await audit.LogAsync(userId, null, "PasswordChanged", null, ct); } // ------------------------------------------------------------------ admin: users public async Task> ListUsersAsync(CancellationToken ct) { await using var c = await db.OpenAsync(ct); return await c.QueryAsync(""" SELECT u.UserId, u.Username, u.FullName, u.Role, u.IsActive, CAST(CASE WHEN u.TotpSecret IS NULL THEN 0 ELSE 1 END AS BIT) AS TotpEnabled, (SELECT STRING_AGG(CAST(HospitalId AS VARCHAR(10)), ',') FROM CF_USER_HOSPITAL WHERE UserId = u.UserId) AS HospitalIds FROM CF_USER u ORDER BY u.FullName """); } public async Task SaveUserAsync(int adminId, int? userId, UserInput i, CancellationToken ct) { if (!Roles.All.Contains(i.Role)) throw AppException.BadRequest("Unknown role."); if (string.IsNullOrWhiteSpace(i.Username) || string.IsNullOrWhiteSpace(i.FullName)) throw AppException.BadRequest("Username and full name are required."); if (userId is null || !string.IsNullOrEmpty(i.Password)) ValidatePassword(i.Password ?? ""); await using var c = await db.OpenAsync(ct); try { int id; if (userId is null) id = await c.ExecuteScalarAsync(""" INSERT CF_USER (Username, FullName, PasswordHash, Role, IsActive) OUTPUT INSERTED.UserId VALUES (@Username, @FullName, @h, @Role, @IsActive) """, new { i.Username, i.FullName, h = BCrypt.Net.BCrypt.HashPassword(i.Password, 12), i.Role, i.IsActive }); else { id = userId.Value; await c.ExecuteAsync(""" UPDATE CF_USER SET Username=@Username, FullName=@FullName, Role=@Role, IsActive=@IsActive, PasswordHash = CASE WHEN @h IS NULL THEN PasswordHash ELSE @h END WHERE UserId=@id """, new { i.Username, i.FullName, i.Role, i.IsActive, id, h = string.IsNullOrEmpty(i.Password) ? null : BCrypt.Net.BCrypt.HashPassword(i.Password, 12) }); } await audit.LogAsync(adminId, null, userId is null ? "UserCreated" : "UserUpdated", new { id, i.Username, i.Role, i.IsActive }, ct); return id; } catch (SqlException ex) when (ex.Number is 2627 or 2601) { throw AppException.Conflict("That username is taken."); } } public async Task SetUserHospitalsAsync(int adminId, int userId, int[] hospitalIds, CancellationToken ct) { await using var c = await db.OpenAsync(ct); await using var tx = (SqlTransaction)await c.BeginTransactionAsync(ct); await c.ExecuteAsync("DELETE CF_USER_HOSPITAL WHERE UserId=@userId", new { userId }, tx); foreach (var h in hospitalIds.Distinct()) await c.ExecuteAsync("INSERT CF_USER_HOSPITAL (UserId, HospitalId) VALUES (@userId, @h)", new { userId, h }, tx); await tx.CommitAsync(ct); await audit.LogAsync(adminId, null, "UserHospitalsSet", new { userId, hospitalIds }, ct); } public async Task ResetTotpAsync(int adminId, int userId, CancellationToken ct) { await using var c = await db.OpenAsync(ct); await c.ExecuteAsync("UPDATE CF_USER SET TotpSecret=NULL, TotpPending=NULL WHERE UserId=@userId", new { userId }); await audit.LogAsync(adminId, null, "TotpReset", new { userId }, ct); } public async Task EnsureBootstrapAdminAsync(CancellationToken ct) { var b = options.Value.BootstrapAdmin; await using var c = await db.OpenAsync(ct); if (await c.ExecuteScalarAsync("SELECT COUNT(*) FROM CF_USER") > 0) return; if (b is null || string.IsNullOrWhiteSpace(b.Username) || b.Password.Length < 10 || b.Password.StartsWith("SET-IN-ENV")) throw new InvalidOperationException("No users exist. Set CareFix:BootstrapAdmin:Username and a Password of 10+ characters for the first start."); await c.ExecuteAsync("INSERT CF_USER (Username, FullName, PasswordHash, Role) VALUES (@u, @n, @h, 'Admin')", new { u = b.Username, n = b.FullName, h = BCrypt.Net.BCrypt.HashPassword(b.Password, 12) }); } private static void ValidatePassword(string p) { if (p.Length < 10 || !p.Any(char.IsDigit) || !p.Any(char.IsLetter)) throw AppException.BadRequest("Passwords need at least 10 characters with letters and numbers."); } }