shoc-backend/SeaHaven.Services/Implementation/AuthenticationService.cs
Alexandre Brandizzi bcc9b6d7a2 fix(auth): invalidate every pending reset code for an email together
Two concurrent first requests can leave two pending codes for one email.
Exhausting or using one now deletes all of them, so a sibling code cannot
become live afterwards.
2026-09-25 12:31:17 -03:00

227 lines
9.9 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 IEmailSender _emailSender;
private readonly TimeProvider _timeProvider;
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
public const int MaxCodeAttempts = 5;
public AuthenticationService(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions,
IUserDataService userDataService,
IForgetPasswordDataService forgetPasswordDataService,
IEmailSender emailSender,
TimeProvider timeProvider)
{
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
_userDataService = userDataService;
_forgetPasswordDataService = forgetPasswordDataService;
_emailSender = emailSender;
_timeProvider = timeProvider;
}
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<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken)
{
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
return false;
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? "");
return result.Succeeded;
}
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 take the same path up to the
// email send: one user lookup, one code generated and hashed, one write.
var requested = email?.Trim() ?? string.Empty;
var user = requested.Length == 0
? null
: await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
var code = PasswordResetCodeSecrets.NewCode();
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, code);
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
{
await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken);
return;
}
var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime);
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken);
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body);
}
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.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;
}
/// <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;
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (pending == null)
return null;
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
{
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
return null;
}
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
return pending;
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, 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;
}
}
}