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.
278 lines
11 KiB
C#
278 lines
11 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class UserService : IUserService
|
|
{
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly IUserDataService _userDataService;
|
|
private readonly IAccountDataService _accountDataService;
|
|
private readonly IEmailSender _emailSender;
|
|
private readonly ISessionStampService _sessionStamps;
|
|
|
|
public UserService(
|
|
UserManager<ApplicationUser> userManager,
|
|
IUserDataService userDataService,
|
|
IAccountDataService accountDataService,
|
|
IEmailSender emailSender,
|
|
ISessionStampService sessionStamps)
|
|
{
|
|
_userManager = userManager;
|
|
_userDataService = userDataService;
|
|
_accountDataService = accountDataService;
|
|
_emailSender = emailSender;
|
|
_sessionStamps = sessionStamps;
|
|
}
|
|
|
|
public async Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken)
|
|
{
|
|
var users = await _userDataService.GetAllForListAsync(cancellationToken);
|
|
return users.Select(s => new UserListRowDTO
|
|
{
|
|
RoleName = s.RoleName,
|
|
Email = s.Email,
|
|
Name = s.Name,
|
|
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
|
|
Status = s.Status,
|
|
Id = s.Id,
|
|
Phone = s.Phone,
|
|
Color = s.Color,
|
|
PendingRegistration = s.PendingRegistration,
|
|
ServiceAreas = s.ServiceAreas
|
|
});
|
|
}
|
|
|
|
public async Task<AddUserOutcomeDTO> AddUserAsync(
|
|
AddUserRequestDTO dto,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var authFailure = EnsureAdminCaller(caller);
|
|
if (authFailure != null)
|
|
return authFailure;
|
|
|
|
var accountFailure = await EnsureAccountValidAsync(dto.AccountId);
|
|
if (accountFailure != null)
|
|
return accountFailure;
|
|
|
|
var model = new ApplicationUser
|
|
{
|
|
UserName = dto.Email,
|
|
FirstName = dto.Name,
|
|
Email = dto.Email,
|
|
AccountId = dto.AccountId
|
|
};
|
|
|
|
var exist = await _userDataService.GetByIdAsync(model.Id);
|
|
if (exist == null)
|
|
{
|
|
model.EmailConfirmed = true;
|
|
model.UserName = model.Email;
|
|
model.CreatedDate = DateTime.UtcNow;
|
|
model.UniqueName = "Active";
|
|
model.PhoneNumber = dto.Role;
|
|
|
|
var password = GenerateRandomPassword();
|
|
|
|
var result = await _userManager.CreateAsync(model, password);
|
|
if (result.Succeeded)
|
|
{
|
|
await _userManager.AddToRoleAsync(model, dto.Role ?? "");
|
|
}
|
|
else
|
|
{
|
|
return new AddUserOutcomeDTO { Success = false, Error = result.Errors.FirstOrDefault()?.Description ?? "error" };
|
|
}
|
|
var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#";
|
|
string subject = "Login Information";
|
|
string greeting = $"Dear {model.FirstName},\n\n";
|
|
string loginLink = $"Click here to login:<a href='{domain}/Login'>Login</a>\n";
|
|
string passwordInfo = $"Your password is: {password}\n\n";
|
|
string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}";
|
|
await _emailSender.SendEmailAsync(model?.Email ?? "", subject, body);
|
|
return new AddUserOutcomeDTO { Success = true };
|
|
}
|
|
else
|
|
{
|
|
var exist1 = await _userDataService.GetForEditAsync(model.Id, cancellationToken);
|
|
if (exist1 == null)
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
|
|
|
var existingRole = await _userManager.GetRolesAsync(exist1);
|
|
|
|
if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber)
|
|
{
|
|
await _userManager.RemoveFromRolesAsync(exist1, existingRole);
|
|
}
|
|
|
|
exist1.FirstName = model.FirstName;
|
|
exist1.LastName = model.LastName;
|
|
exist1.Contact = model.Contact;
|
|
exist1.PhoneNumber = model.PhoneNumber;
|
|
exist1.AccountId = dto.AccountId;
|
|
|
|
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
|
|
|
|
await _userManager.AddToRoleAsync(exist1, dto.Role ?? "");
|
|
await _userManager.UpdateAsync(exist1);
|
|
return new AddUserOutcomeDTO { Success = true };
|
|
}
|
|
}
|
|
|
|
public async Task<AddUserOutcomeDTO> EditUserAsync(
|
|
EditUserRequestDTO dto,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var authFailure = EnsureAdminCaller(caller);
|
|
if (authFailure != null)
|
|
return authFailure;
|
|
|
|
var accountFailure = await EnsureAccountValidAsync(dto.AccountId);
|
|
if (accountFailure != null)
|
|
return accountFailure;
|
|
|
|
var exist = await _userDataService.GetForEditAsync(dto.Id ?? "", cancellationToken);
|
|
if (exist == null)
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
|
|
|
if (await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
|
|
|
exist.FirstName = dto.Name;
|
|
if (string.IsNullOrWhiteSpace(dto.Email))
|
|
throw new ArgumentException("Email is required.", nameof(dto));
|
|
|
|
exist.EmailConfirmed = true;
|
|
exist.UserName = dto.Email;
|
|
exist.Email = dto.Email;
|
|
exist.NormalizedEmail = dto.Email.ToUpperInvariant();
|
|
exist.NormalizedUserName = dto.Email.ToUpperInvariant();
|
|
exist.CreatedDate = DateTime.UtcNow;
|
|
exist.UniqueName = "Active";
|
|
exist.PhoneNumber = dto.Role;
|
|
exist.AccountId = dto.AccountId;
|
|
|
|
var existingRole = await _userManager.GetRolesAsync(exist);
|
|
if (existingRole != null && existingRole.Any())
|
|
{
|
|
await _userManager.RemoveFromRolesAsync(exist, existingRole);
|
|
}
|
|
await _userManager.AddToRoleAsync(exist, dto.Role ?? "");
|
|
|
|
await _userDataService.UpdateUserAsync(exist, cancellationToken);
|
|
return new AddUserOutcomeDTO { Success = true };
|
|
}
|
|
|
|
public async Task<AddUserOutcomeDTO> DeleteUserAsync(
|
|
string id,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var authFailure = EnsureAdminCaller(caller);
|
|
if (authFailure != null)
|
|
return authFailure;
|
|
|
|
var data = await _userDataService.GetForEditAsync(id, cancellationToken);
|
|
if (data == null)
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
|
|
|
if (await _userDataService.IsAccountOwnerAsync(data.Id, cancellationToken))
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
|
|
|
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
|
|
_sessionStamps.Forget(data.Id);
|
|
return new AddUserOutcomeDTO { Success = true };
|
|
}
|
|
|
|
public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
|
|
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
|
|
{
|
|
// A new stamp keeps this account's earlier sessions ended even if it is restored.
|
|
exist.IsDeleted = true;
|
|
exist.SecurityStamp = Guid.NewGuid().ToString("N");
|
|
await _userDataService.UpdateUserAsync(exist, cancellationToken);
|
|
_sessionStamps.Forget(exist.Id);
|
|
}
|
|
}
|
|
|
|
public async Task<IReadOnlyList<UserProfileRowDTO>> GetUserProfileAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
var user = await _userDataService.GetProfileAsync(userId, cancellationToken);
|
|
if (user == null)
|
|
return Array.Empty<UserProfileRowDTO>();
|
|
|
|
return new List<UserProfileRowDTO>
|
|
{
|
|
new UserProfileRowDTO
|
|
{
|
|
Id = user.Id,
|
|
Name = user.FirstName,
|
|
AddedDate = user.CreatedDate,
|
|
Email = user.Email,
|
|
Contact = user.Contact
|
|
}
|
|
};
|
|
}
|
|
|
|
private static AddUserOutcomeDTO? EnsureAdminCaller(ClaimsPrincipal caller)
|
|
{
|
|
if (caller is null || !caller.IsInRole("Admin"))
|
|
{
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<AddUserOutcomeDTO?> EnsureAccountValidAsync(int? accountId)
|
|
{
|
|
if (!accountId.HasValue)
|
|
return null;
|
|
|
|
if (accountId.Value <= 0 || !await _accountDataService.ExistsAsync(accountId.Value))
|
|
{
|
|
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.AccountNotFound };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string GenerateRandomPassword(int length = 8)
|
|
{
|
|
const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?";
|
|
const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
const string digits = "1234567890";
|
|
const string symbols = "!@#$%^&*()_-+=<>?";
|
|
|
|
var passwordChars = new char[length];
|
|
|
|
passwordChars[0] = capitalLetters[RandomNumberGenerator.GetInt32(capitalLetters.Length)];
|
|
passwordChars[1] = digits[RandomNumberGenerator.GetInt32(digits.Length)];
|
|
passwordChars[2] = symbols[RandomNumberGenerator.GetInt32(symbols.Length)];
|
|
passwordChars[3] = "abcdefghijklmnopqrstuvwxyz"[RandomNumberGenerator.GetInt32(26)];
|
|
|
|
for (int i = 4; i < length; i++)
|
|
{
|
|
passwordChars[i] = validChars[RandomNumberGenerator.GetInt32(validChars.Length)];
|
|
}
|
|
|
|
for (int i = passwordChars.Length - 1; i > 0; i--)
|
|
{
|
|
int j = RandomNumberGenerator.GetInt32(i + 1);
|
|
(passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]);
|
|
}
|
|
|
|
return new string(passwordChars);
|
|
}
|
|
}
|
|
}
|