Files
adas-core/adas-core.Authentication/UserService.cs
2026-06-26 10:29:23 +02:00

847 lines
46 KiB
C#

using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using adas_core.Application.Exceptions;
using adas_core.Application.Repositories.Interfaces;
using adas_core.Application.Services.Interfaces;
using adas_core.Authentication.Interfaces;
using adas_core.Authentication.Models;
using adas_core.Domain.Enums;
using adas_core.Domain.Exceptions;
using adas_core.Domain.Models.AppSettings;
using adas_core.Domain.Models.DTO;
using adas_core.Domain.Models.Filter;
using adas_core.Domain.Models.MongoModels;
using adas_core.Domain.Models.Responses;
using adas_core.Domain.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Bson;
using MongoDB.Driver;
using static System.Enum;
namespace adas_core.Authentication;
/// <summary>
/// Provides the concrete implementation of user-related service operations defined by the <see cref="IUserService"/> contract.
/// </summary>
/// <remarks>
/// This class is the default service component responsible for handling user domain logic and delegating persistence or infrastructure concerns as required by the interface.
/// </remarks>
public class UserService : IUserService
{
private readonly ILocalAuditService _auditService;
private readonly IAuthorityService _authorityService;
private readonly IEnumerable<ILoginService> _availableLoginServices;
private readonly Lazy<IDisplayService> _displayService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JwtConfig _jwtConfig;
private readonly ILogger<UserService> _logger;
private readonly IUserRepository _userRepository;
private readonly UsersWhiteListConfig _usersWhiteList;
private readonly ValidGroupsConfig _validGroups;
private readonly List<string> _validLoginMethods;
private readonly ISubscribersService _subscribersService;
private readonly IClientMessageService _clientMessageService;
private readonly Lazy<IPermissionService> _permissionService;
public UserService(
IEnumerable<ILoginService> loginServices,
IOptions<AuthSettings> configuration,
IOptions<ValidGroupsConfig> validGroups,
IOptions<AuthSettings> usersWhiteList,
IOptions<AuthSettings> jwt,
IHttpContextAccessor httpContextAccessor,
IUserRepository userRepository,
IAuthorityService authorityService,
ILocalAuditService auditService,
Lazy<IDisplayService> displayService,
ILogger<UserService> logger,
ISubscribersService subscribersService,
IClientMessageService clientMessageService, Lazy<IPermissionService> permissionService)
{
_availableLoginServices = loginServices;
_httpContextAccessor = httpContextAccessor;
_validGroups = validGroups.Value;
_usersWhiteList = usersWhiteList.Value.UsersWhiteListConfig;
_jwtConfig = jwt.Value.JwtConfig ?? throw new Exception("JWT NOT CONFIGURED");
var loginMethods = configuration.Value.LoginMethods;
_validLoginMethods = loginMethods;
_userRepository = userRepository;
_authorityService = authorityService;
_auditService = auditService;
_displayService = displayService;
_logger = logger;
_subscribersService = subscribersService;
_clientMessageService = clientMessageService;
_permissionService = permissionService;
}
/// <summary>
/// Authenticates a user with the provided credentials and generates a JWT token upon successful authentication.
/// Performs validations for user existence, account status (enabled and not locked), and authorization via whitelist or group membership.
/// Updates the user's last login timestamp and clears the lock expiration if it has expired before issuing the token.
/// </summary>
/// <param name="username">The username used to look up the user account.</param>
/// <param name="password">The password used to verify the user's credentials.</param>
/// <returns>A <see cref="Task{TResult}"/> containing the generated <see cref="TokenResult"/> for the authenticated user.</returns>
/// <exception cref="UnauthorizedException">Thrown when no user matches the provided credentials, or when the user is not in the whitelist or valid groups.</exception>
/// <exception cref="ForbbidenException">Thrown when the user account is disabled or currently locked.</exception>
public async Task<TokenResult> Login(string username, string password)
{
var user = await GetUser(username, password);
_logger.LogInformation("[UserService] Login user {u}", user.ToString());
if (user == null) throw new UnauthorizedException(HttpEnum.ErrorMessage.UnauthorizedInvalidCredentials);
if (!user.IsEnabled || (user.LockExpirationDate != null && user.LockExpirationDate > DateTime.UtcNow))
throw new ForbbidenException(HttpEnum.ErrorMessage.ForbiddenUserBlocked);
if (!_usersWhiteList.IsValid(user) && !_validGroups.IsValid(user))
throw new UnauthorizedException(HttpEnum.ErrorMessage.UnauthorizedNoPermission);
if (user.LockExpirationDate < DateTime.UtcNow) user.LockExpirationDate = null;
user.LastLogin = DateTime.UtcNow;
await UpdateUsersByRequest(user, false);
return await GenerateJwt(user);
}
/// <summary>
/// Validates a JWT token using the configured audience, issuer, and symmetric signing key, and returns the validated security token.
/// The token must be a JWT signed with the HmacSha256 algorithm; otherwise, validation fails and a <see cref="TokenException"/> is thrown.
/// </summary>
/// <param name="token">The JWT token string to validate.</param>
/// <param name="tokenType">The type of the token being validated.</param>
/// <param name="validatedToken">When the method returns, contains the validated <see cref="SecurityToken"/> if validation succeeds.</param>
/// <exception cref="TokenException">Thrown when the token is invalid, is not signed with the HmacSha256 algorithm, or any other validation error occurs.</exception>
public void ValidateToken(string token, string tokenType, out SecurityToken validatedToken)
{
try
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = _jwtConfig.ValidateAudience,
ValidateIssuer = _jwtConfig.ValidateIssuer,
ValidAudience = _jwtConfig.Audience,
ValidIssuer = _jwtConfig.Issuer,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtConfig.Key ?? string.Empty)),
ValidateLifetime = false
};
var tokenHandler = new JwtSecurityTokenHandler();
tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);
if (securityToken is not JwtSecurityToken jwtSecurityToken ||
!jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256,
StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
validatedToken = securityToken;
}
catch (SecurityTokenException a)
{
_logger.LogError("Error validating token, {msg}", a.Message);
throw new TokenException(a.Message);
}
catch (Exception e)
{
_logger.LogError("Error validating token, {msg}", e.Message);
throw new TokenException("Error validating token");
}
}
public async Task<User?> GetUserByToken(JwtSecurityToken? jwtToken)
{
User? user = null;
if (jwtToken == null)
return user;
var userName = jwtToken.Claims.First(x => x.Type == ClaimTypes.Name).Value;
user = await _userRepository.GetByUserName(userName);
if (user == null)
return user;
//Eliminamos la contraseña
user.Password = string.Empty;
if (user.Authorization == null || user.Authorization.Count == 0)
user.Authorization = await _authorityService.GetUserAuthorities(user.Id);
return user;
}
/// <summary>
/// Authenticates a user using a CAS (Central Authentication Service) ticket and returns the associated user if authorization succeeds.
/// </summary>
/// <param name="serviceUrl">The URL of the service requesting CAS authentication.</param>
/// <param name="ticket">The CAS ticket issued by the CAS server to validate.</param>
/// <returns>The authenticated <see cref="User"/> if the ticket is valid and the user passes whitelist and group checks; otherwise, an exception is thrown.</returns>
/// <exception cref="LoginServicesException">Thrown when the CAS login method is not available, or when an unexpected error occurs during authentication.</exception>
/// <exception cref="UnauthorizedAccessException">Thrown when the authenticated user is not in the users whitelist and does not belong to any valid group.</exception>
/// <exception cref="BusinessException">Rethrown when a business-level error occurs during the login process.</exception>
public async Task<User?> GetUserByCasTicket(string serviceUrl, string ticket)
{
var service = _availableLoginServices.FirstOrDefault(s => s.Method == UserEnum.LoginMethod.Cas);
if (service == null) throw new LoginServicesException("CAS login method not available");
try
{
var user = await service.Login(_httpContextAccessor.HttpContext!);
if (!_usersWhiteList.IsValid(user) && !_validGroups.IsValid(user))
throw new UnauthorizedAccessException("You don't have sufficient permissions");
return user;
}
catch (BusinessException)
{
throw;
}
catch (UnauthorizedAccessException)
{
throw;
}
catch (Exception e)
{
var message = e.Message;
if (e.InnerException != null && e.Message != e.InnerException?.Message)
message += $", {e.InnerException?.Message}";
Debug.WriteLine($"Error while login: {message} ");
throw new LoginServicesException(message, e);
}
////////////////////////
/*
if (!_validLoginMethods.Contains(LoginMethod.CAS.ToString()))
{
Log.Warning("CAS login method not enabled");
return null;
}
_casLoginService = new
Log.Information("CAS ticket received: '{CasTicket}'", ticket);
return string.IsNullOrEmpty(ticket) ? null : _casLoginService.GetUser(serviceUrl, ticket, context);
*/
}
/// <summary>
/// Refreshes a JWT token using the provided refresh token. Validates the refresh token, retrieves the associated user, and generates a new JWT; if the user is not found, returns an empty TokenResult.
/// </summary>
/// <param name="refreshToken">The refresh token used to generate a new JWT.</param>
/// <returns>A TokenResult containing the new JWT, or an empty result if the user associated with the token is not found.</returns>
public async Task<TokenResult> RefreshToken(string refreshToken)
{
ValidateToken(refreshToken, IUserService.TokenTypeRefresh, out var validatedToken);
var user = await GetUserByToken((JwtSecurityToken)validatedToken);
if (user == null)
return new TokenResult();
return await GenerateJwt(user);
}
/// <summary>
/// Authenticates a user by iterating through the configured valid login methods, attempting each
/// available password-enabled service until one succeeds. If every login attempt fails, a
/// <see cref="LoginServicesNotFoundException"/> is thrown.
/// </summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate against.</param>
/// <returns>The authenticated <see cref="User"/> returned by the first successful login service.</returns>
/// <exception cref="LoginServicesNotFoundException">Thrown when no configured login service successfully authenticates the user.</exception>
public async Task<User> GetUser(string username, string password)
{
//BusinessException? loginException = null;
foreach (var m in _validLoginMethods)
{
if (!TryParse(m, out UserEnum.LoginMethod method)) continue;
var service = _availableLoginServices.FirstOrDefault(s => s.Method == method && s.AllowPassword);
if (service == null) continue;
try
{
_logger.LogInformation("[UserService] Trying login with method {method}", method);
return await service.Login(username, password);
}
catch (Exception e)
{
_logger.LogError("[UserService] Error while trying method {method}, err {err}", method, e.Message);
}
}
throw new LoginServicesNotFoundException();
}
/// <summary>
/// Retrieves a user by their unique identifier, including the user's assigned authorities when the user is found.
/// Returns <c>null</c> if the user does not exist or if an error occurs while loading the user or its authorities.
/// </summary>
/// <param name="id">The unique identifier of the user to retrieve.</param>
/// <returns>A <see cref="Task{User}"/> containing the user with its authorization populated, or <c>null</c> if the user cannot be found or an exception is thrown during retrieval.</returns>
public async Task<User?> GetUserById(ObjectId id)
{
try
{
var u = await _userRepository.GetById(id);
if (u != null) u.Authorization = await _authorityService.GetUserAuthorities(u.Id);
return u;
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Retrieves a user by their username, including their assigned authorities. Returns null if no user with the specified name exists.
/// </summary>
/// <param name="name">The username to look up.</param>
/// <returns>The matching <see cref="User"/> with its <c>Authorization</c> property populated from the authority service, or <c>null</c> if no user is found.</returns>
public async Task<User?> GetUserByUserName(string name)
{
var u = await _userRepository.GetByUserName(name);
if (u == null) return u;
u.Authorization = await _authorityService.GetUserAuthorities(u.Id);
return u;
}
/// <summary>
/// Retrieves a user by their name, including the user's authorization information if the user is found.
/// Returns <c>null</c> if the user does not exist or if an error occurs while retrieving the data.
/// </summary>
/// <param name="name">The name of the user to look up.</param>
/// <returns>A <see cref="User"/> instance with its <c>Authorization</c> populated when found; otherwise, <c>null</c>.</returns>
public async Task<User?> GetUserByName(string name)
{
try
{
var u = await _userRepository.GetByName(name);
if (u != null) u.Authorization = await _authorityService.GetUserAuthorities(u.Id);
return u;
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Creates a new user from LDAP data after validating that the username and email are not already in use, persists the entry, and records the creation in the audit log.
/// </summary>
/// <param name="userEntryLdap">The user information sourced from LDAP to be created in the system.</param>
/// <returns>The created user retrieved by username, or null if the user cannot be found after insertion.</returns>
public async Task<User?> CreateUser(User userEntryLdap)
{
CheckIfUserNameExists(userEntryLdap.UserName);
CheckIfEmailExists(userEntryLdap.Email);
await _userRepository.InsertOneAsync(userEntryLdap);
var result = await GetUserByUserName(userEntryLdap.UserName);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User, null, result);
return result;
}
/// <summary>
/// Creates a new user after validating email and username uniqueness; for local accounts, it enforces password strength and hashes the password before persistence.
/// </summary>
/// <param name="user">The user to create, including email, username, password, and account type.</param>
/// <returns>The created user retrieved by username after insertion.</returns>
/// <exception cref="UnprocessableEntityException">Thrown when a local user's password is not considered strong.</exception>
/// <exception cref="NotFoundException">Thrown when the user cannot be retrieved by username after insertion.</exception>
public async Task<User?> CreateNewUserByRequest(User user)
{
CheckIfEmailExists(user.Email);
CheckIfUserNameExists(user.UserName);
if (user.Type == UserEnum.Type.Local)
{
if (!CryptoAdas.IsStrongPassword(user.Password))
throw new UnprocessableEntityException(HttpEnum.ErrorMessage.UnprocessableEntityInvalidPassword);
user.Password = CryptoAdas.CreateBCrypt(user.Password);
}
await _userRepository.InsertOneAsync(user);
var result = await GetUserByUserName(user.UserName) ??
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User, null, result);
return result;
}
/// <summary>
/// Creates a new user along with the specified authorizations, associating each authorization with the newly created user.
/// Throws an exception if the user cannot be created.
/// </summary>
/// <param name="createUserWithAuthDto">The data transfer object containing the user details and the list of authorizations to associate with the new user.</param>
/// <returns>The newly created user, or <c>null</c> if creation fails (in which case an exception is thrown instead).</returns>
/// <exception cref="NotFoundException">Thrown when the user cannot be created by the underlying request, indicating a missing resource.</exception>
public async Task<User?> CreateNewUserWithAuthorities(CreateUserWithAuthDto createUserWithAuthDto)
{
var user = createUserWithAuthDto.User;
var newUser = await CreateNewUserByRequest(user) ??
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
var auths = createUserWithAuthDto.Authorizations;
foreach (var auth in auths)
{
auth.UserId = newUser.Id;
await CreateNewAuthority(auth);
}
return newUser;
}
/// <summary>
/// Updates a user, optionally updating their password, and broadcasts relevant notifications when the password changes, the user is disabled, or the account becomes locked.
/// </summary>
/// <param name="user">The user with the updated information to be persisted.</param>
/// <param name="updatePass">Indicates whether the user's password should be updated and re-hashed.</param>
/// <returns>The updated user returned by the repository, or <c>null</c> if the user could not be found or updated.</returns>
/// <exception cref="UnprocessableEntityException">Thrown when <paramref name="updatePass"/> is <c>true</c> and the provided password does not meet the strong password policy.</exception>
public async Task<User?> UpdateUsersByRequest(User user, bool updatePass)
{
var oldUser = await _userRepository.GetByUserName(user.UserName);
var logoutHasBeenSent = false;
if (updatePass && !CryptoAdas.IsStrongPassword(user.Password))
throw new UnprocessableEntityException(HttpEnum.ErrorMessage.UnprocessableEntityInvalidPassword);
if (updatePass)
{
user.Password = CryptoAdas.CreateBCrypt(user.Password);
await SendBroadcastUser(user, user.UserName, OperationType.UpdatePassword);
logoutHasBeenSent = true;
}
var result = await _userRepository.UpdateUser(user, updatePass);
if(!logoutHasBeenSent && oldUser != null && result != null && oldUser.IsEnabled && !result.IsEnabled)
{
await SendBroadcastUser(result, user.UserName, OperationType.DisableUser);
logoutHasBeenSent = true;
}
if(!logoutHasBeenSent && result != null && result.LockExpirationDate != null && result.LockExpirationDate>DateTime.UtcNow) await SendBroadcastUser(result, user.UserName, OperationType.LockUser);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User, oldUser, result);
return result;
}
/// <summary>
/// Sends a broadcast message asynchronously to all subscribers that match the specified user name, notifying them of an update operation performed on the given element.
/// </summary>
/// <param name="elementUpdated">The element that has been updated and should be transmitted to the subscribers.</param>
/// <param name="userName">The user name used to filter the subscribers that will receive the broadcast.</param>
/// <param name="operation">The type of operation (e.g., create, update, delete) associated with the broadcast.</param>
private async Task SendBroadcastUser(object elementUpdated, string userName, OperationType operation)
{
var subscribers = _subscribersService.GetSubscribers().Where(s =>
s.UserName == userName).ToList();
foreach (var subscriber in subscribers)
{
await _clientMessageService.SendAsync(subscriber.Id, operation, elementUpdated);
}
}
/// <summary>
/// Broadcasts an update notification to all subscribers associated with the specified user.
/// Subscribers with admin subscription type receive the user's panel permissions, while other subscribers receive the updated element; if retrieving the admin permissions fails, a null payload is sent as a fallback.
/// </summary>
/// <param name="elementUpdated">The updated element to send as the notification payload to non-admin subscribers.</param>
/// <param name="userName">The username whose subscribers will receive the broadcast.</param>
/// <param name="operation">The operation type that describes the nature of the update.</param>
private async Task SendBroadcastPermissions(object elementUpdated, string userName, OperationType operation)
{
var subscribers = _subscribersService.GetSubscribers().Where(s =>
s.UserName == userName).ToList();
foreach (var subscriber in subscribers)
{
if (subscriber.SubscriptionType == SubscriptionEnum.WsType.Admin)
{
try
{
var permissionsForPanel = await _permissionService.Value.GetPermissionsForPanel(userName);
_ = _clientMessageService.SendAsync(subscriber.Id, operation, permissionsForPanel);
}
catch (Exception)
{
_ = _clientMessageService.SendAsync(subscriber.Id, operation, null);
}
}
else
{
_ = _clientMessageService.SendAsync(subscriber.Id, operation, elementUpdated);
}
}
}
/// <summary>
/// Updates an existing user together with the associated authorities (roles/permissions), validating that the email and user name remain unique. Deletes, updates, and creates authorities according to the provided DTO, and broadcasts the new permission set whenever at least one authority change has been applied.
/// </summary>
/// <param name="updateUserWithAuthDto">DTO containing the user to update along with the lists of authorities to delete, update, and add.</param>
/// <param name="updatePass">Flag indicating whether the user's password should be updated as part of the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
/// <exception cref="ConflictException">Thrown when the underlying user update returns no result.</exception>
public async Task<User?> UpdateUserWithAuthorities(UpdateUserWithAuthDto updateUserWithAuthDto, bool updatePass)
{
var u = updateUserWithAuthDto.User;
CheckIfEmailExists(u.Email, u.Id);
CheckIfUserNameExists(u.UserName, u.Id);
var userUpdated = await UpdateUsersByRequest(u, updatePass) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictUpdateFailed);
foreach (var id in updateUserWithAuthDto.AuthsToDelete) await DeleteAuthority(id);
foreach (var auth in updateUserWithAuthDto.Authorizations) await UpdateAuthority(auth);
foreach (var auth in updateUserWithAuthDto.NewAuthorizations)
{
auth.UserId = userUpdated.Id;
await CreateNewAuthority(auth);
}
if(updateUserWithAuthDto.AuthsToDelete.Any() || updateUserWithAuthDto.NewAuthorizations.Any() || updateUserWithAuthDto.Authorizations.Any())
{
var allPermissionsForDisplay = await _displayService.Value.GetAllByUser(u.UserName);
await SendBroadcastPermissions(allPermissionsForDisplay, u.UserName, OperationType.UpdateAuthorities);
}
return userUpdated;
}
/// <summary>
/// Updates the password of an existing user after validating the current password and the strength of the new one, logging the change and recording an audit entry on success.
/// </summary>
/// <param name="id">The unique identifier of the user whose password will be updated.</param>
/// <param name="oldPassword">The user's current password, used to verify the request before applying the change.</param>
/// <param name="newPassword">The new password to set; must differ from the current password and meet the strength policy.</param>
/// <returns>A task that resolves to <c>true</c> when the password is successfully updated, or <c>false</c> if the repository update returns no result.</returns>
/// <exception cref="BadRequestException">Thrown when <paramref name="newPassword"/> is equal to <paramref name="oldPassword"/>.</exception>
/// <exception cref="NotFoundException">Thrown when no user exists for the supplied <paramref name="id"/>.</exception>
/// <exception cref="UnprocessableEntityException">Thrown when <paramref name="oldPassword"/> does not match the user's current password, or when <paramref name="newPassword"/> does not satisfy the strength policy.</exception>
public async Task<bool> UpdateUserPassword(ObjectId id, string oldPassword, string newPassword)
{
if (oldPassword == newPassword)
throw new BadRequestException("New password must be different");
var user = await _userRepository.GetById(id) ??
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
if (!CryptoAdas.VerifyPassword(oldPassword, user.Password))
throw new UnprocessableEntityException("Invalid current password");
if (!CryptoAdas.IsStrongPassword(newPassword))
throw new UnprocessableEntityException("Weak password");
user.Password = CryptoAdas.CreateBCrypt(newPassword);
var result = await _userRepository.UpdateUser(user, true);
if (result != null)
{
_logger.LogInformation("Password updated for user {UserId}", id);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User, user, result);
return true;
}
return false;
}
/// <summary>
/// Deletes a user by its identifier, records an audit log entry for the deletion, and returns the operation result.
/// Returns <c>false</c> if an error occurs during deletion, in which case the exception is logged.
/// </summary>
/// <param name="id">The identifier of the user to delete.</param>
/// <returns>A task that resolves to <c>true</c> when the user is successfully deleted; otherwise, <c>false</c> if an error is encountered.</returns>
public async Task<bool> DeleteUser(ObjectId id)
{
try
{
var user = await _userRepository.GetById(id);
await _userRepository.DeleteAsync(id);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User, user, null);
return true;
}
catch (Exception e)
{
_logger.LogError("Error deleting user. Exception: {e}", e);
return false;
}
}
/// <summary>
/// Retrieves a paginated list of users based on the provided filter, including the total document count for pagination metadata.
/// </summary>
/// <param name="filter">The pagination filter containing the page number and page size used to skip, limit, and shape the result set.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a <see cref="PaginationResponse{User}"/> with the requested page of users and pagination details.</returns>
public async Task<PaginationResponse<User>> GetPaginatedUsers(PaginationFilter filter)
{
var result = _userRepository.GetPaginatedUsers(filter);
var count = await result.CountDocumentsAsync();
var data = await result.Skip((filter.PageNumber - 1) * filter.PageSize)
.Limit(filter.PageSize)
.ToCursorAsync();
var dataList = await data.ToListAsync();
return new PaginationResponse<User>(dataList, filter.PageNumber, filter.PageSize, count);
}
/// <summary>
/// Generates a JSON Web Token (JWT) pair (access and refresh) for the specified user, including claims derived from the user's authorization data when available.
/// </summary>
/// <param name="user">The user whose identity, email, IP address, and authorization claims will be embedded in the generated tokens.</param>
/// <returns>A <see cref="TokenResult"/> containing the serialized access and refresh tokens, their respective expiration times, and the associated user.</returns>
public async Task<TokenResult> GenerateJwt(User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Email, user.Email),
new("IpAddress", user.IpAddress)
};
// Agregar claims de autorizaciones utilizando ClaimsFromAuthorities
if (user.Authorization != null)
claims.AddRange(await ClaimsFromAuthorities(user.Authorization));
var accessToken = JwtHelper.GetJwtToken(
user.UserName,
_jwtConfig.Key ?? string.Empty,
_jwtConfig.Issuer ?? string.Empty,
_jwtConfig.Audience ?? string.Empty,
_jwtConfig.TokenValidityInMinutes,
claims.ToArray());
var refreshToken = JwtHelper.GetJwtToken(
user.UserName,
_jwtConfig.Key ?? string.Empty,
_jwtConfig.Issuer ?? string.Empty,
_jwtConfig.Audience ?? string.Empty,
_jwtConfig.RefreshTokenValidityInMinutes,
claims.ToArray());
return new TokenResult
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(accessToken),
RefreshToken = new JwtSecurityTokenHandler().WriteToken(refreshToken),
AccessTokenExpiryTime = accessToken.ValidTo,
RefreshTokenExpiryTime = refreshToken.ValidTo,
User = user
};
}
/// <summary>
/// Retrieves the list of all users by attempting each valid login method in order, selecting the first service that supports password authentication.
/// If a service throws a <see cref="BusinessException"/> it is remembered and the next service is tried; other exceptions are logged and wrapped in a <see cref="LoginServicesException"/>.
/// Throws the captured exception from the last attempt, or <see cref="LoginServicesNotFoundException"/> when no suitable login service is found.
/// </summary>
/// <returns>A task that resolves to the list of <see cref="User"/> objects returned by the first successful login service.</returns>
/// <exception cref="LoginServicesException">Thrown when a non-business exception occurs while retrieving users from a service.</exception>
/// <exception cref="LoginServicesNotFoundException">Thrown when no login service matches the valid login methods or all attempts fail without a captured business exception.</exception>
public async Task<List<User>> GetAll()
{
BusinessException? loginException = null;
foreach (var m in _validLoginMethods)
{
if (!TryParse(m, out UserEnum.LoginMethod method)) continue;
var service = _availableLoginServices.FirstOrDefault(s => s.Method == method && s.AllowPassword);
if (service == null) continue;
try
{
return await service.GetAllUsers();
}
catch (BusinessException e)
{
loginException = e;
}
catch (Exception e)
{
var message = e.Message;
if (e.Message != e.InnerException?.Message) message += $", {e.InnerException?.Message}";
Debug.WriteLine($"Error Getting all users: {message} ");
loginException = new LoginServicesException(message);
}
}
if (loginException != null) throw loginException;
throw new LoginServicesNotFoundException();
}
/// <summary>
/// Authenticates a user using a provided access token and returns a new token pair.
/// Validates the token, retrieves the associated user, and either generates a fresh JWT if the original is expired, or issues new access and refresh tokens preserving the remaining lifetime of the supplied token.
/// </summary>
/// <param name="token">The access token to validate and use for user authentication.</param>
/// <returns>A <see cref="TokenResult"/> containing the new access token, refresh token, their expiry times, and the authenticated user.</returns>
/// <exception cref="NotFoundException">Thrown when no user can be resolved from the provided token.</exception>
public async Task<TokenResult> LoginWithGivenAccessToken(string token)
{
ValidateToken(token, IUserService.TokenTypeUser, out var validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var user = await GetUserByToken(jwtToken) ??
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
user.Authorization ??= await _authorityService.GetUserAuthorities(user.Id);
if (jwtToken.ValidTo < DateTime.UtcNow)
return await GenerateJwt(user);
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Email, user.Email),
new("IpAddress", user.IpAddress)
};
// Agregar claims de autorizaciones utilizando ClaimsFromAuthorities
claims.AddRange(await ClaimsFromAuthorities(user.Authorization));
var dif = jwtToken.ValidTo > DateTime.Now ? jwtToken.ValidTo - DateTime.Now : DateTime.Now - jwtToken.ValidTo;
var accessToken = JwtHelper.GetJwtToken(
user.UserName,
_jwtConfig.Key ?? string.Empty,
_jwtConfig.Issuer ?? string.Empty,
_jwtConfig.Audience ?? string.Empty,
(int)dif.TotalMinutes,
claims.ToArray());
var refreshToken = JwtHelper.GetJwtToken(
user.UserName,
_jwtConfig.Key ?? string.Empty,
_jwtConfig.Issuer ?? string.Empty,
_jwtConfig.Audience ?? string.Empty,
(int)dif.TotalMinutes + _jwtConfig.RefreshTokenValidityInMinutes,
claims.ToArray());
return new TokenResult
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(accessToken),
RefreshToken = new JwtSecurityTokenHandler().WriteToken(refreshToken),
AccessTokenExpiryTime = accessToken.ValidTo,
RefreshTokenExpiryTime = refreshToken.ValidTo,
User = user
};
}
/// <summary>
/// Authenticates a user using a previously issued access token and issues a new JWT.
/// Validates the provided token, retrieves the associated user, and loads user authorities on demand before generating the authentication response.
/// </summary>
/// <param name="token">The access token used to identify and authenticate the user.</param>
/// <returns>A <see cref="Task{TokenResult}"/> containing the generated JWT for the authenticated user.</returns>
/// <exception cref="NotFoundException">Thrown when no user is found that matches the validated access token.</exception>
public async Task<TokenResult> LoginWithAccessToken(string token)
{
ValidateToken(token, IUserService.TokenTypeUser, out var validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var user = await GetUserByToken(jwtToken) ??
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
user.Authorization ??= await _authorityService.GetUserAuthorities(user.Id);
return await GenerateJwt(user);
}
/// <summary>
/// Creates a new user authority based on the provided authorization. If the underlying authority service returns a null result, indicating that the creation failed, a conflict exception is raised.
/// </summary>
/// <param name="auth">The authorization used to create the new user authority.</param>
/// <returns>The newly created <see cref="Authorization"/> instance.</returns>
/// <exception cref="ConflictException">Thrown when the authority service fails to create the user authority (returns null).</exception>
public async Task<Authorization> CreateNewAuthority(Authorization auth)
{
var newUserAuthority = await _authorityService.CreateNewUserAuthority(auth) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictCreationFailed);
return newUserAuthority;
}
/// <summary>
/// Deletes a user authority identified by the specified string identifier. Validates the identifier format and throws if the deletion cannot be completed.
/// </summary>
/// <param name="id">The string representation of the authority's ObjectId to delete.</param>
/// <returns>A task that resolves to <c>true</c> when the authority is successfully deleted.</returns>
/// <exception cref="BadRequestException">Thrown when the provided <paramref name="id"/> is not a valid ObjectId format.</exception>
/// <exception cref="ConflictException">Thrown when the underlying delete operation fails.</exception>
public async Task<bool> DeleteAuthority(string id)
{
if (!ObjectId.TryParse(id, out var userAuthorityIdParsed))
throw new BadRequestException(HttpEnum.ErrorMessage.BadRequestInvalidFormat);
var isDeleted = await _authorityService.DeleteUserAuthority(userAuthorityIdParsed);
return !isDeleted ? throw new ConflictException(HttpEnum.ErrorMessage.ConflictDeleteFailed) : isDeleted;
}
/// <summary>
/// Updates the authority for the specified authorization entry. If the underlying authority service returns a null result, a conflict is raised to signal that the update could not be applied.
/// </summary>
/// <param name="authorization">The authorization details used to update the user's authority.</param>
/// <returns>A task that resolves to <c>true</c> when the authority is successfully updated.</returns>
/// <exception cref="ConflictException">Thrown when the authority service returns a null response, indicating the update failed.</exception>
public async Task<bool> UpdateAuthority(Authorization authorization)
{
_ = await _authorityService.EditUserAuthority(authorization) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictUpdateFailed);
return true;
}
/// <summary>
/// Asynchronously resolves a list of <see cref="Claim"/> objects from the provided <see cref="Authorization"/> entries.
/// When an authorization has a <c>UnitId</c>, the associated display is looked up and its identifier is assigned to the authorization before the claim is added; otherwise, the claim is added directly.
/// </summary>
/// <param name="authorities">The list of authorizations to convert into claims.</param>
/// <returns>A task that represents the asynchronous operation, containing the list of claims built from the supplied authorities.</returns>
public async Task<List<Claim>> ClaimsFromAuthorities(List<Authorization> authorities)
{
var claims = new List<Claim>();
foreach (var auth in authorities)
if (auth.UnitId != null)
{
var dis = await
_displayService.Value.GetByUnitId(ObjectId.Parse(auth.UnitId));
foreach (var claim in dis)
{
auth.DisplayId = claim.Id.ToString();
AddClaim(auth, claims);
}
}
else
{
AddClaim(auth, claims);
}
return claims;
}
/// <summary>
/// Adds a role-based claim to the provided claims list, composing its value from the authorization role and, when present, the display identifier. Skips addition when a claim with the same value already exists to avoid duplicates.
/// </summary>
/// <param name="auth">The authorization source whose role and display identifier are used to build the claim value.</param>
/// <param name="claims">The list of claims to which the new role claim is appended when not already present.</param>
private static void AddClaim(Authorization auth, List<Claim> claims)
{
var newClaim = new Claim(ClaimTypes.Role,
auth.Rol + (string.IsNullOrEmpty(auth.DisplayId) ? "" : "_" + auth.DisplayId));
if (claims.Any(c => c.Value == newClaim.Value)) return;
//newClaim.Properties.Add("DisplayId", auth.DisplayId ?? "");
claims.Add(newClaim);
}
/// <summary>
/// Verifies whether the specified <paramref name="userName"/> is already in use, optionally excluding a given user from the check. Throws an exception when a duplicate is found.
/// </summary>
/// <param name="userName">The username to validate against existing users.</param>
/// <param name="userId">Optional identifier of the user being updated; when provided, that user is excluded from the duplicate check.</param>
/// <exception cref="BadRequestException">Thrown when another user with the same <paramref name="userName"/> already exists.</exception>
private void CheckIfUserNameExists(string userName, ObjectId? userId = null)
{
var users = GetAll().Result;
var userNameExists = userId.HasValue
? users.Any(user => user.Id != userId.Value && user.UserName == userName)
: users.Any(user => user.UserName == userName);
if (userNameExists) throw new BadRequestException(HttpEnum.ErrorMessage.BadRequestIncorrectEmailOrUsername);
}
/// <summary>
/// Validates that the specified email is not already in use by another user. When a user ID is provided, the check excludes that user so their existing email remains valid during updates.
/// </summary>
/// <param name="email">The email address to verify for uniqueness in the user collection.</param>
/// <param name="userId">The optional ID of the current user; when set, it is excluded from the duplicate email check.</param>
/// <exception cref="BadRequestException">Thrown when the email is already registered to a different user.</exception>
private void CheckIfEmailExists(string email, ObjectId? userId = null)
{
var users = GetAll().Result;
var emailExists = userId.HasValue
? users.Any(user => user.Id != userId.Value && user.Email == email)
: users.Any(user => user.Email == email);
if (emailExists && !string.IsNullOrEmpty(email))
throw new BadRequestException(HttpEnum.ErrorMessage.BadRequestIncorrectEmailOrUsername);
}
}