using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; namespace SeaHaven.Services.Implementation { public class AuthenticationService : IAuthenticationService { private readonly UserManager _userManager; private readonly JwtOptions _jwtOptions; private readonly IUserDataService _userDataService; private readonly IForgetPasswordDataService _forgetPasswordDataService; private readonly IPasswordResetEmailQueue _resetEmails; private readonly IPasswordResetThrottle _resetThrottle; private readonly TimeProvider _timeProvider; private byte[]? _resetCodeKey; public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15); public const int MaxCodeAttempts = 5; /// Longest address stored for a reset request; Identity caps emails at 256. public const int MaxResetEmailLength = 256; public AuthenticationService( UserManager userManager, IOptions jwtOptions, IUserDataService userDataService, IForgetPasswordDataService forgetPasswordDataService, IPasswordResetEmailQueue resetEmails, IPasswordResetThrottle resetThrottle, TimeProvider timeProvider) { _userManager = userManager; _jwtOptions = jwtOptions.Value; _userDataService = userDataService; _forgetPasswordDataService = forgetPasswordDataService; _resetEmails = resetEmails; _resetThrottle = resetThrottle; _timeProvider = timeProvider; } private byte[] ResetCodeKey => _resetCodeKey ??= PasswordResetCodeSecrets.DeriveKey(_jwtOptions.Secret); public async Task LoginAsync(string? username, string? password, CancellationToken cancellationToken) { var user = await _userManager.FindByNameAsync(username ?? ""); if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? "")) { var userRoles = await _userManager.GetRolesAsync(user); var authClaims = new List { new Claim(ClaimTypes.Name, user.UserName ?? ""), new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; foreach (var userRole in userRoles) { authClaims.Add(new Claim(ClaimTypes.Role, userRole)); } if (user.AccountId.HasValue) { authClaims.Add(new Claim( SeaHavenClaimTypes.AccountId, user.AccountId.Value.ToString())); } else if (userRoles.Contains("Admin")) { // Explicit signed org-wide elevation — never elevate via absence of account_id. authClaims.Add(new Claim( SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)); } var token = GetToken(authClaims); return new LoginResultDTO { Token = new JwtSecurityTokenHandler().WriteToken(token), Expiration = token.ValidTo, Email = user.Email, UserRole = userRoles.FirstOrDefault(), PhoneNumber = user.PhoneNumber, Fullname = user.FirstName + " " + user.LastName, Id = user.Id }; } return null; } public async Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var user = await _userManager.FindByIdAsync(userId); if (user == null || user.IsDeleted == true) return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); // The current password is verified before the new one is evaluated, so a // caller without it learns nothing about the policy outcome. if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? "")) return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect); var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? ""); if (result.Succeeded) return ChangePasswordResult(ChangePasswordStatus.Succeeded); return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result) ? ChangePasswordStatus.PasswordRejected : ChangePasswordStatus.Failed); } private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) => new() { Status = status }; public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) { var exists = await _userDataService.UpdateProfileAsync( userId, dto.Name, dto.Email, dto.Contact, cancellationToken); if (!exists) return null; var updated = await _userDataService.GetProfileAsync(userId, cancellationToken); if (updated == null) return null; return new UserProfileDTO { FirstName = updated.FirstName, Email = updated.Email, Contact = updated.Contact }; } public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken) { // Registered and unregistered addresses do the same work: one user lookup, // one code generated and hashed, and the same replace in the database. An // unregistered address gets a row no code can match. The email itself is // queued, so the response never waits on the mail provider. var requested = email?.Trim() ?? string.Empty; if (requested.Length == 0 || requested.Length > MaxResetEmailLength) return; var user = await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken); var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; if (!_resetThrottle.TryAcceptCodeRequest(requested)) { // Over the per-email limit: keep the current code and send nothing. await _forgetPasswordDataService.PurgeExpiredAsync(nowUtc, cancellationToken); return; } var code = PasswordResetCodeSecrets.NewCode(); var salt = PasswordResetCodeSecrets.NewSalt(); var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code); var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email); var queued = false; if (active) { var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes."; queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body); // A code that cannot be emailed is stored unmatchable and the request is not counted. if (!queued) _resetThrottle.ReleaseCodeRequest(requested); } await _forgetPasswordDataService.ReplaceCodeAsync( active ? user!.Email! : requested, active ? user!.Id : string.Empty, queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(), salt, nowUtc.Add(ResetCodeLifetime), nowUtc, cancellationToken); } public async Task VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken) { var pending = await CheckCodeAsync(email, code, cancellationToken); if (pending == null) return false; // Verifying is a preview step; a correct code keeps all of its attempts. await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken); return true; } public async Task ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(password)) return false; var pending = await CheckCodeAsync(email, code, cancellationToken); if (pending == null) return false; var user = await _userManager.FindByIdAsync(pending.UserId); if (user == null || user.IsDeleted == true) { await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return false; } var token = await _userManager.GeneratePasswordResetTokenAsync(user); var result = await _userManager.ResetPasswordAsync(user, token, password); if (!result.Succeeded) { // The code was right and the new password was rejected: the user can // try another password without spending an attempt. await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken); return false; } await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return true; } /// /// Returns the pending code record when matches the one /// issued to . Every check consumes an attempt before /// comparing; a check that uses the last attempt without matching deletes the code. /// private async Task CheckCodeAsync(string? email, string? code, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code)) return null; // The per-account budget spans every code the account is sent, so asking for // new codes does not buy more guesses. A slot is reserved before comparing and // given back when the code matches or there is no live code to guess at. if (!_resetThrottle.TryReserveCheck(email)) return null; var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); if (pending == null) { _resetThrottle.ReleaseCheck(email); return null; } var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken)) { // Expired or out of attempts: nothing was compared, so no guess is counted. _resetThrottle.ReleaseCheck(email); await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return null; } if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash)) { _resetThrottle.ReleaseCheck(email); return pending; } if (pending.FailedAttempts + 1 >= MaxCodeAttempts) await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken); return null; } private JwtSecurityToken GetToken(List authClaims) { var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); var token = new JwtSecurityToken( issuer: _jwtOptions.ValidIssuer, audience: _jwtOptions.ValidAudience, expires: DateTime.Now.AddDays(10), claims: authClaims, signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) ); return token; } } }