shoc-backend/SeaHaven.Services/Implementation/UserService.cs
Alexandre Brandizzi 498f49a2d8 fix(auth): end earlier sessions when a user's role or account changes
A token carries the user's roles and account, so a demoted admin kept admin
claims until the token expired. The team member update and the admin user
edit now rotate the security stamp and evict the cached value when the role,
account or user name changes. Permission overrides are read per request and
are not in the token.
2026-09-25 19:26:25 -03:00

298 lines
12 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);
var claimsChanged = exist1.AccountId != dto.AccountId
|| existingRole == null
|| existingRole.Count != 1
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
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;
if (claimsChanged)
exist1.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
await _userManager.AddToRoleAsync(exist1, dto.Role ?? "");
await _userManager.UpdateAsync(exist1);
if (claimsChanged)
_sessionStamps.Forget(exist1.Id);
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));
// The token carries the user name, roles and account, so a change to any of
// them needs a fresh sign-in.
var existingRole = await _userManager.GetRolesAsync(exist);
var claimsChanged = exist.AccountId != dto.AccountId
|| !string.Equals(exist.UserName, dto.Email, StringComparison.OrdinalIgnoreCase)
|| existingRole == null
|| existingRole.Count != 1
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
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;
if (existingRole != null && existingRole.Any())
{
await _userManager.RemoveFromRolesAsync(exist, existingRole);
}
await _userManager.AddToRoleAsync(exist, dto.Role ?? "");
if (claimsChanged)
exist.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist, cancellationToken);
if (claimsChanged)
_sessionStamps.Forget(exist.Id);
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);
}
}
}