shoc-backend/SeaHaven.Services/Implementation/AuthenticationService.cs
Alexandre Brandizzi 1277642ede Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2
# Conflicts:
#	SeaHaven.Services/Implementation/AuthenticationService.cs
2026-09-25 16:48:57 -03:00

287 lines
13 KiB
C#

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<ApplicationUser> _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;
/// <summary>Longest address stored for a reset request; Identity caps emails at 256.</summary>
public const int MaxResetEmailLength = 256;
public AuthenticationService(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> 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<LoginResultDTO?> 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<Claim>
{
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<ChangePasswordResultDTO> 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<UserProfileDTO?> 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<bool> 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<bool> 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.RemoveIssuedThroughAsync(pending.Email, pending.Id, 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;
}
/// <summary>
/// Returns the pending code record when <paramref name="code"/> matches the one
/// issued to <paramref name="email"/>. Every check consumes an attempt before
/// comparing; a check that uses the last attempt without matching deletes the code.
/// </summary>
private async Task<ForgetPasswordCode?> 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;
// Deletes below stop at this code's id: a code issued by a concurrent request
// after this one was read belongs to that request and must survive.
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.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash))
{
_resetThrottle.ReleaseCheck(email);
return pending;
}
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
private JwtSecurityToken GetToken(List<Claim> 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;
}
}
}