mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 21:13:12 +00:00
# Conflicts: # Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs # Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs # SeaHaven.Services/Implementation/AuthenticationService.cs
327 lines
15 KiB
C#
327 lines
15 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 readonly ISessionStampService _sessionStamps;
|
|
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,
|
|
ISessionStampService sessionStamps)
|
|
{
|
|
_userManager = userManager;
|
|
_jwtOptions = jwtOptions.Value;
|
|
_userDataService = userDataService;
|
|
_forgetPasswordDataService = forgetPasswordDataService;
|
|
_resetEmails = resetEmails;
|
|
_resetThrottle = resetThrottle;
|
|
_timeProvider = timeProvider;
|
|
_sessionStamps = sessionStamps;
|
|
}
|
|
|
|
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 ?? ""))
|
|
return await CreateSessionAsync(user, cancellationToken);
|
|
|
|
return null;
|
|
}
|
|
|
|
public async Task<LoginResultDTO> CreateSessionAsync(ApplicationUser user, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(user);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Every request checks the token's stamp against the account's, so an account
|
|
// without one would get a token that never works.
|
|
if (string.IsNullOrEmpty(user.SecurityStamp))
|
|
{
|
|
var stamped = await _userManager.UpdateSecurityStampAsync(user);
|
|
if (!stamped.Succeeded)
|
|
throw new InvalidOperationException("The account's security stamp could not be set.");
|
|
}
|
|
|
|
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()),
|
|
new Claim(SeaHavenClaimTypes.SessionStamp, _sessionStamps.ClaimValueFor(user.SecurityStamp!))
|
|
};
|
|
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}".Trim(),
|
|
Id = user.Id
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
// A changed password rotates the security stamp, which ends every earlier session.
|
|
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? "");
|
|
if (result.Succeeded)
|
|
{
|
|
_sessionStamps.Forget(user.Id);
|
|
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);
|
|
|
|
// The email is queued only after its code is stored, so a failed or cancelled
|
|
// write never sends a code that cannot be used. The request stays counted only
|
|
// when the row is stored and, for an account, its email was queued; when the
|
|
// queue is full the stored code is left unsent and the user can ask again.
|
|
var counted = false;
|
|
try
|
|
{
|
|
await _forgetPasswordDataService.ReplaceCodeAsync(
|
|
active ? user!.Email! : requested,
|
|
active ? user!.Id : string.Empty,
|
|
active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
|
|
salt,
|
|
nowUtc.Add(ResetCodeLifetime),
|
|
nowUtc,
|
|
cancellationToken);
|
|
|
|
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);
|
|
}
|
|
|
|
counted = queued || !active;
|
|
}
|
|
finally
|
|
{
|
|
if (!counted)
|
|
_resetThrottle.ReleaseCodeRequest(requested);
|
|
}
|
|
}
|
|
|
|
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);
|
|
// A reset rotates the security stamp, which ends every earlier session.
|
|
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;
|
|
}
|
|
|
|
_sessionStamps.Forget(user.Id);
|
|
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;
|
|
|
|
// Only a wrong code actually compared keeps the slot. No live code, an expired or
|
|
// used-up code, a match, and a failed or cancelled call all give it back.
|
|
var wrongGuess = false;
|
|
try
|
|
{
|
|
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
|
if (pending == null)
|
|
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))
|
|
{
|
|
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash))
|
|
return pending;
|
|
|
|
wrongGuess = true;
|
|
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
|
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
if (!wrongGuess)
|
|
_resetThrottle.ReleaseCheck(email);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|