mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows.
227 lines
9.8 KiB
C#
227 lines
9.8 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.RemoveAsync(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.RemoveAsync(pending.Id, 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.RemoveAsync(pending.Id, cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
|
|
return pending;
|
|
|
|
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
|
await _forgetPasswordDataService.RemoveAsync(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;
|
|
}
|
|
}
|
|
}
|