mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Tokens now carry a keyed hash of the account's security stamp, and every authenticated request compares it with the stored stamp (cached for 60 s, evicted in-process on change). A password reset or change, a deactivation and a deletion all rotate or remove the stamp, so tokens issued before them get 401. Tokens without the claim get 401 too.
213 lines
9.2 KiB
C#
213 lines
9.2 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.Security.Cryptography;
|
|
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 ISessionStampService _sessionStamps;
|
|
public AuthenticationService(
|
|
UserManager<ApplicationUser> userManager,
|
|
IOptions<JwtOptions> jwtOptions,
|
|
IUserDataService userDataService,
|
|
IForgetPasswordDataService forgetPasswordDataService,
|
|
IEmailSender emailSender,
|
|
ISessionStampService sessionStamps)
|
|
{
|
|
_userManager = userManager;
|
|
_jwtOptions = jwtOptions.Value;
|
|
_userDataService = userDataService;
|
|
_forgetPasswordDataService = forgetPasswordDataService;
|
|
_emailSender = emailSender;
|
|
_sessionStamps = sessionStamps;
|
|
}
|
|
|
|
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<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken)
|
|
{
|
|
var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken);
|
|
if (user == null) return false;
|
|
|
|
var code = GenerateRandomNo();
|
|
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken);
|
|
var body = $"Your Password Reset Code is: " + code;
|
|
await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body);
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken)
|
|
{
|
|
return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken);
|
|
}
|
|
|
|
public async Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password))
|
|
return false;
|
|
|
|
var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken);
|
|
if (!matched) return false;
|
|
|
|
var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
|
if (record == null)
|
|
return false;
|
|
|
|
var user = await _userManager.FindByIdAsync(record.UserId);
|
|
if (user == null)
|
|
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)
|
|
return false;
|
|
|
|
_sessionStamps.Forget(user.Id);
|
|
await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static string GenerateRandomNo()
|
|
{
|
|
return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6");
|
|
}
|
|
}
|
|
}
|