From 39bed12f30077ded23b537ea347fd7d471793631 Mon Sep 17 00:00:00 2001 From: shchoholiev Date: Mon, 16 Oct 2023 03:22:49 +0000 Subject: [PATCH] SA-14 Updated User model structure and Services --- .devcontainer/devcontainer.json | 6 + .../Mutations/AccessMutation.cs | 14 +- .../Mutations/RolesMutation.cs | 12 +- .../Mutations/UsersMutation.cs | 20 +- .../IServices/Identity/ITokensService.cs | 5 +- .../IServices/Identity/IUsersManager.cs | 8 +- ShoppingAssistantApi.Domain/Entities/User.cs | 4 - .../Services/Identity/TokensService.cs | 66 +--- .../Services/Identity/UserManager.cs | 299 +++++++++++------- .../Services/RolesService.cs | 4 +- .../Services/ServiceBase.cs | 15 + .../Repositories/UsersRepository.cs | 4 - 12 files changed, 249 insertions(+), 208 deletions(-) create mode 100644 ShoppingAssistantApi.Infrastructure/Services/ServiceBase.cs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8c8e5e0..5ad2e4e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,6 +14,12 @@ // "protocol": "https" // } // } + + // Container is not working on M1 Mac + // "runArgs": [ + // "--platform=linux/amd64" + // ], + "customizations": { "vscode": { "extensions": [ diff --git a/ShoppingAssistantApi.Api/Mutations/AccessMutation.cs b/ShoppingAssistantApi.Api/Mutations/AccessMutation.cs index 7abc641..df920c0 100644 --- a/ShoppingAssistantApi.Api/Mutations/AccessMutation.cs +++ b/ShoppingAssistantApi.Api/Mutations/AccessMutation.cs @@ -7,14 +7,14 @@ namespace ShoppingAssistantApi.Api.Mutations; public class AccessMutation { public Task LoginAsync(AccessUserModel login, CancellationToken cancellationToken, - [Service] IUserManager userManager) - => userManager.LoginAsync(login, cancellationToken); + [Service] IUserManager userManager) + => userManager.LoginAsync(login, cancellationToken); public Task AccessGuestAsync(AccessGuestModel guest, CancellationToken cancellationToken, - [Service] IUserManager userManager) - => userManager.AccessGuestAsync(guest, cancellationToken); + [Service] IUserManager userManager) + => userManager.AccessGuestAsync(guest, cancellationToken); - public Task RefreshUserTokenAsync(TokensModel model, CancellationToken cancellationToken, - [Service] ITokensService tokensService) - => tokensService.RefreshUserAsync(model, cancellationToken); + public Task RefreshAccessTokenAsync(TokensModel model, CancellationToken cancellationToken, + [Service] IUserManager userManager) + => userManager.RefreshAccessTokenAsync(model, cancellationToken); } \ No newline at end of file diff --git a/ShoppingAssistantApi.Api/Mutations/RolesMutation.cs b/ShoppingAssistantApi.Api/Mutations/RolesMutation.cs index a4c98bb..e8e2138 100644 --- a/ShoppingAssistantApi.Api/Mutations/RolesMutation.cs +++ b/ShoppingAssistantApi.Api/Mutations/RolesMutation.cs @@ -1,22 +1,14 @@ -using ShoppingAssistantApi.Application.IServices.Identity; +using HotChocolate.Authorization; using ShoppingAssistantApi.Application.IServices; using ShoppingAssistantApi.Application.Models.CreateDtos; using ShoppingAssistantApi.Application.Models.Dtos; -using ShoppingAssistantApi.Application.Models.Identity; namespace ShoppingAssistantApi.Api.Mutations; [ExtendObjectType(OperationTypeNames.Mutation)] public class RolesMutation { - public Task AddToRoleAsync(string roleName, string id, CancellationToken cancellationToken, - [Service] IUserManager userManager) - => userManager.AddToRoleAsync(roleName, id, cancellationToken); - - public Task RemoveFromRoleAsync(string roleName, string id, CancellationToken cancellationToken, - [Service] IUserManager userManager) - => userManager.RemoveFromRoleAsync(roleName, id, cancellationToken); - + [Authorize] public Task AddRole(RoleCreateDto roleDto, CancellationToken cancellationToken, [Service] IRolesService rolesService) => rolesService.AddRoleAsync(roleDto, cancellationToken); diff --git a/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs b/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs index 1185f97..6beaccf 100644 --- a/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs +++ b/ShoppingAssistantApi.Api/Mutations/UsersMutation.cs @@ -10,11 +10,21 @@ public class UsersMutation { [Authorize] public Task UpdateUserAsync(UserDto userDto, CancellationToken cancellationToken, - [Service] IUserManager userManager) - => userManager.UpdateAsync(userDto, cancellationToken); + [Service] IUserManager userManager) + => userManager.UpdateAsync(userDto, cancellationToken); [Authorize] - public Task UpdateUserByAdminAsync(string id, UserDto userDto, CancellationToken cancellationToken, - [Service] IUserManager userManager) - => userManager.UpdateUserByAdminAsync(id, userDto, cancellationToken); + public Task UpdateUserByAdminAsync(string id, UserDto userDto, CancellationToken cancellationToken, + [Service] IUserManager userManager) + => userManager.UpdateUserByAdminAsync(id, userDto, cancellationToken); + + [Authorize] + public Task AddToRoleAsync(string roleName, string userId, CancellationToken cancellationToken, + [Service] IUserManager userManager) + => userManager.AddToRoleAsync(roleName, userId, cancellationToken); + + [Authorize] + public Task RemoveFromRoleAsync(string roleName, string userId, CancellationToken cancellationToken, + [Service] IUserManager userManager) + => userManager.RemoveFromRoleAsync(roleName, userId, cancellationToken); } \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/IServices/Identity/ITokensService.cs b/ShoppingAssistantApi.Application/IServices/Identity/ITokensService.cs index 2dda569..a6f14d7 100644 --- a/ShoppingAssistantApi.Application/IServices/Identity/ITokensService.cs +++ b/ShoppingAssistantApi.Application/IServices/Identity/ITokensService.cs @@ -1,5 +1,4 @@ -using ShoppingAssistantApi.Application.Models.Identity; -using System.Security.Claims; +using System.Security.Claims; namespace ShoppingAssistantApi.Application.IServices.Identity; @@ -9,5 +8,5 @@ public interface ITokensService string GenerateRefreshToken(); - Task RefreshUserAsync(TokensModel tokensModel, CancellationToken cancellationToken); + ClaimsPrincipal GetPrincipalFromExpiredToken(string token); } \ No newline at end of file diff --git a/ShoppingAssistantApi.Application/IServices/Identity/IUsersManager.cs b/ShoppingAssistantApi.Application/IServices/Identity/IUsersManager.cs index 72c84ae..4a07f20 100644 --- a/ShoppingAssistantApi.Application/IServices/Identity/IUsersManager.cs +++ b/ShoppingAssistantApi.Application/IServices/Identity/IUsersManager.cs @@ -10,11 +10,13 @@ public interface IUserManager Task LoginAsync(AccessUserModel login, CancellationToken cancellationToken); - Task AddToRoleAsync(string roleName, string id, CancellationToken cancellationToken); + Task AddToRoleAsync(string roleName, string userId, CancellationToken cancellationToken); - Task RemoveFromRoleAsync(string roleName, string id, CancellationToken cancellationToken); + Task RemoveFromRoleAsync(string roleName, string userId, CancellationToken cancellationToken); Task UpdateAsync(UserDto userDto, CancellationToken cancellationToken); - Task UpdateUserByAdminAsync(string id, UserDto userDto, CancellationToken cancellationToken); + Task UpdateUserByAdminAsync(string id, UserDto userDto, CancellationToken cancellationToken); + + Task RefreshAccessTokenAsync(TokensModel tokensModel, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/ShoppingAssistantApi.Domain/Entities/User.cs b/ShoppingAssistantApi.Domain/Entities/User.cs index 7f5a0b1..27e928b 100644 --- a/ShoppingAssistantApi.Domain/Entities/User.cs +++ b/ShoppingAssistantApi.Domain/Entities/User.cs @@ -13,8 +13,4 @@ public class User : EntityBase public string? Email { get; set; } public string? PasswordHash { get; set; } - - public string RefreshToken { get; set; } - - public DateTime RefreshTokenExpiryDate { get; set; } } diff --git a/ShoppingAssistantApi.Infrastructure/Services/Identity/TokensService.cs b/ShoppingAssistantApi.Infrastructure/Services/Identity/TokensService.cs index 2ea9728..b50f302 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/Identity/TokensService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/Identity/TokensService.cs @@ -5,10 +5,7 @@ using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; -using MongoDB.Bson; -using ShoppingAssistantApi.Application.IRepositories; using ShoppingAssistantApi.Application.IServices.Identity; -using ShoppingAssistantApi.Application.Models.Identity; namespace ShoppingAssistantApi.Infrastructure.Services.Identity; @@ -16,50 +13,16 @@ public class TokensService : ITokensService { private readonly IConfiguration _configuration; - private readonly IUsersRepository _usersRepository; - private readonly ILogger _logger; - public TokensService(IConfiguration configuration, IUsersRepository usersRepository, - ILogger logger) + public TokensService( + IConfiguration configuration, + ILogger logger) { this._configuration = configuration; - this._usersRepository = usersRepository; this._logger = logger; } - public async Task RefreshUserAsync(TokensModel tokensModel, CancellationToken cancellationToken) - { - var principal = this.GetPrincipalFromExpiredToken(tokensModel.AccessToken); - - var userId = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value; - if (!ObjectId.TryParse(userId, out var objectId)) - { - throw new InvalidDataException("Provided id is invalid."); - } - - var user = await this._usersRepository.GetUserAsync(objectId, cancellationToken); - if (user == null || user?.RefreshToken != tokensModel.RefreshToken - || user?.RefreshTokenExpiryDate <= DateTime.UtcNow) - { - throw new SecurityTokenExpiredException(); - } - - var newAccessToken = this.GenerateAccessToken(principal.Claims); - var newRefreshToken = this.GenerateRefreshToken(); - user.RefreshToken = newRefreshToken; - user.RefreshTokenExpiryDate = DateTime.UtcNow.AddDays(30); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); - - this._logger.LogInformation($"Refreshed user tokens."); - - return new TokensModel - { - AccessToken = newAccessToken, - RefreshToken = newRefreshToken - }; - } - public string GenerateAccessToken(IEnumerable claims) { var tokenOptions = GetTokenOptions(claims); @@ -73,18 +36,16 @@ public class TokensService : ITokensService public string GenerateRefreshToken() { var randomNumber = new byte[32]; - using (var rng = RandomNumberGenerator.Create()) - { - rng.GetBytes(randomNumber); - var refreshToken = Convert.ToBase64String(randomNumber); + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + var refreshToken = Convert.ToBase64String(randomNumber); - this._logger.LogInformation($"Generated new refresh token."); + this._logger.LogInformation($"Generated new refresh token."); - return refreshToken; - } + return refreshToken; } - private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) + public ClaimsPrincipal GetPrincipalFromExpiredToken(string token) { var tokenValidationParameters = new TokenValidationParameters { @@ -96,11 +57,10 @@ public class TokensService : ITokensService ValidateLifetime = false }; var tokenHandler = new JwtSecurityTokenHandler(); - SecurityToken securityToken; - var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out securityToken); + var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); var jwtSecurityToken = securityToken as JwtSecurityToken; - if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, - StringComparison.InvariantCultureIgnoreCase)) + if (jwtSecurityToken == null + || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) throw new SecurityTokenException("Invalid token"); this._logger.LogInformation($"Returned data from expired access token."); @@ -117,7 +77,7 @@ public class TokensService : ITokensService var tokenOptions = new JwtSecurityToken( issuer: _configuration.GetValue("JsonWebTokenKeys:ValidIssuer"), audience: _configuration.GetValue("JsonWebTokenKeys:ValidAudience"), - expires: DateTime.UtcNow.AddMinutes(5), + expires: DateTime.UtcNow.AddMinutes(15), claims: claims, signingCredentials: signinCredentials ); diff --git a/ShoppingAssistantApi.Infrastructure/Services/Identity/UserManager.cs b/ShoppingAssistantApi.Infrastructure/Services/Identity/UserManager.cs index 571ad9c..7720477 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/Identity/UserManager.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/Identity/UserManager.cs @@ -1,7 +1,6 @@ using AutoMapper; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; -using Microsoft.Win32; using MongoDB.Bson; using ShoppingAssistantApi.Application.Exceptions; using ShoppingAssistantApi.Application.GlobalInstances; @@ -14,23 +13,32 @@ using ShoppingAssistantApi.Domain.Entities; using System.Security.Claims; using System.Text.RegularExpressions; - namespace ShoppingAssistantApi.Infrastructure.Services.Identity; -public class UserManager : IUserManager + +public class UserManager : ServiceBase, IUserManager { private readonly IUsersRepository _usersRepository; - private readonly ILogger _logger; - private readonly IPasswordHasher _passwordHasher; private readonly ITokensService _tokensService; + private readonly IRolesRepository _rolesRepository; + + private readonly IRefreshTokensRepository _refreshTokensRepository; + private readonly IMapper _mapper; - private readonly IRolesRepository _rolesRepository; + private readonly ILogger _logger; - public UserManager(IUsersRepository usersRepository, ILogger logger, IPasswordHasher passwordHasher, ITokensService tokensService, IMapper mapper, IRolesRepository rolesRepository) + public UserManager( + IUsersRepository usersRepository, + IPasswordHasher passwordHasher, + ITokensService tokensService, + IRolesRepository rolesRepository, + IRefreshTokensRepository refreshTokensRepository, + IMapper mapper, + ILogger logger) { this._usersRepository = usersRepository; this._logger = logger; @@ -38,15 +46,16 @@ public class UserManager : IUserManager this._tokensService = tokensService; this._mapper = mapper; this._rolesRepository = rolesRepository; - + this._refreshTokensRepository = refreshTokensRepository; } public async Task LoginAsync(AccessUserModel login, CancellationToken cancellationToken) { - var user = login.Email != null - ? await this._usersRepository.GetUserAsync(x => x.Email == login.Email, cancellationToken) - : await this._usersRepository.GetUserAsync(x => x.Phone == login.Phone, cancellationToken); + _logger.LogInformation($"Logging in user with email: {login.Email} and phone: {login.Phone}."); + var user = string.IsNullOrEmpty(login.Phone) + ? await this._usersRepository.GetUserAsync(u => u.Email == login.Email, cancellationToken) + : await this._usersRepository.GetUserAsync(u => u.Phone == login.Phone, cancellationToken); if (user == null) { throw new EntityNotFoundException(); @@ -57,197 +66,216 @@ public class UserManager : IUserManager throw new InvalidDataException("Invalid password!"); } - user.RefreshToken = this.GetRefreshToken(); - user.RefreshTokenExpiryDate = DateTime.UtcNow.AddDays(30); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); - var tokens = this.GetUserTokens(user); + var refreshToken = await AddRefreshToken(user.Id, cancellationToken); - this._logger.LogInformation($"Logged in user with email: {login.Email}."); + var tokens = this.GetUserTokens(user, refreshToken); + + this._logger.LogInformation($"Logged in user with email: {login.Email} and phone: {login.Phone}."); return tokens; } public async Task AccessGuestAsync(AccessGuestModel guest, CancellationToken cancellationToken) { + _logger.LogInformation($"Logging in / Registering guest with guest id: {guest.GuestId}."); + var user = await this._usersRepository.GetUserAsync(x => x.GuestId == guest.GuestId, cancellationToken); - if (user != null) + if (user == null) { - user.RefreshToken = this.GetRefreshToken(); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); - var userTokens = this.GetUserTokens(user); + var role = await this._rolesRepository.GetRoleAsync(r => r.Name == "Guest", cancellationToken); + user = new User + { + GuestId = guest.GuestId, + Roles = new List { role }, + CreatedDateUtc = DateTime.UtcNow, + CreatedById = ObjectId.Empty // Default value for all new users + }; - this._logger.LogInformation($"Logged in guest with guest id: {guest.GuestId}."); + await this._usersRepository.AddAsync(user, cancellationToken); - return userTokens; + this._logger.LogInformation($"Created guest with guest id: {guest.GuestId}."); } - var role = await this._rolesRepository.GetRoleAsync(r => r.Name == "Guest", cancellationToken); + var refreshToken = await AddRefreshToken(user.Id, cancellationToken); + var tokens = this.GetUserTokens(user, refreshToken); - var newUser = new User - { - GuestId = guest.GuestId, - Roles = new List { role }, - RefreshToken = this.GetRefreshToken(), - RefreshTokenExpiryDate = DateTime.Now.AddDays(30), - CreatedDateUtc = DateTime.UtcNow, - LastModifiedDateUtc = DateTime.UtcNow - }; - - await this._usersRepository.AddAsync(newUser, cancellationToken); - var tokens = this.GetUserTokens(newUser); - - this._logger.LogInformation($"Created guest with guest id: {guest.GuestId}."); + this._logger.LogInformation($"Logged in guest with guest id: {guest.GuestId}."); return tokens; } - public async Task AddToRoleAsync(string roleName, string id, CancellationToken cancellationToken) + public async Task RefreshAccessTokenAsync(TokensModel tokensModel, CancellationToken cancellationToken) { + _logger.LogInformation($"Refreshing access token."); + + var principal = _tokensService.GetPrincipalFromExpiredToken(tokensModel.AccessToken); + var userId = ParseObjectId(principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value); + + var refreshTokenModel = await this._refreshTokensRepository + .GetOneAsync(r => + r.Token == tokensModel.RefreshToken + && r.CreatedById == userId + && r.IsDeleted == false, cancellationToken); + if (refreshTokenModel == null || refreshTokenModel.ExpiryDateUTC < DateTime.UtcNow) + { + throw new SecurityTokenExpiredException(); + } + + var refreshToken = refreshTokenModel.Token; + + // Update Refresh token if it expires in less than 7 days to keep user constantly logged in if he uses the app + if (refreshTokenModel.ExpiryDateUTC.AddDays(-7) < DateTime.UtcNow) + { + await _refreshTokensRepository.DeleteAsync(refreshTokenModel, cancellationToken); + + var newRefreshToken = await AddRefreshToken(userId, cancellationToken); + refreshToken = newRefreshToken.Token; + } + + var tokens = new TokensModel + { + AccessToken = _tokensService.GenerateAccessToken(principal.Claims), + RefreshToken = refreshToken + }; + + this._logger.LogInformation($"Refreshed access token."); + + return tokens; + } + + public async Task AddToRoleAsync(string roleName, string userId, CancellationToken cancellationToken) + { + _logger.LogInformation($"Adding Role: {roleName} to User with Id: {userId}."); + var role = await this._rolesRepository.GetRoleAsync(r => r.Name == roleName, cancellationToken); if (role == null) { throw new EntityNotFoundException(); } - if (!ObjectId.TryParse(id, out var objectId)) - { - throw new InvalidDataException("Provided id is invalid."); - } - - var user = await this._usersRepository.GetUserAsync(objectId, cancellationToken); + var userObjectId = ParseObjectId(userId); + var user = await this._usersRepository.GetUserAsync(userObjectId, cancellationToken); if (user == null) { throw new EntityNotFoundException(); } user.Roles.Add(role); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); - var tokens = this.GetUserTokens(user); + var updatedUser = await this._usersRepository.UpdateUserAsync(user, cancellationToken); + var userDto = this._mapper.Map(updatedUser); - this._logger.LogInformation($"Added role {roleName} to user with id: {id}."); + this._logger.LogInformation($"Added Role: {roleName} to User with Id: {userId}."); - return tokens; + return userDto; } - public async Task RemoveFromRoleAsync(string roleName, string id, CancellationToken cancellationToken) + public async Task RemoveFromRoleAsync(string roleName, string userId, CancellationToken cancellationToken) { + _logger.LogInformation($"Removing Role: {roleName} from User with Id: {userId}."); + var role = await this._rolesRepository.GetRoleAsync(r => r.Name == roleName, cancellationToken); if (role == null) { throw new EntityNotFoundException(); } - if (!ObjectId.TryParse(id, out var objectId)) - { - throw new InvalidDataException("Provided id is invalid."); - } - - var user = await this._usersRepository.GetUserAsync(objectId, cancellationToken); + var userObjectId = ParseObjectId(userId); + var user = await this._usersRepository.GetUserAsync(userObjectId, cancellationToken); if (user == null) { throw new EntityNotFoundException(); } var deletedRole = user.Roles.Find(x => x.Name == role.Name); - user.Roles.Remove(deletedRole); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); - var tokens = this.GetUserTokens(user); - this._logger.LogInformation($"Added role {roleName} to user with id: {id}."); + var updatedUser = await this._usersRepository.UpdateUserAsync(user, cancellationToken); + var userDto = this._mapper.Map(updatedUser); - return tokens; + this._logger.LogInformation($"Removed Role: {roleName} from User with Id: {userId}."); + + return userDto; } public async Task UpdateAsync(UserDto userDto, CancellationToken cancellationToken) { - if (userDto.Email != null) ValidateEmail(userDto.Email); - if (userDto.Phone != null) ValidateNumber(userDto.Phone); - - if (userDto.Roles.Any(x => x.Name == "Guest") && !userDto.Roles.Any(x => x.Name == "User")) - { - if (userDto.Password != null && (userDto.Email != null || userDto.Phone != null)) - { - var roleEntity = await this._rolesRepository.GetRoleAsync(x => x.Name == "User", cancellationToken); - var roleDto = this._mapper.Map(roleEntity); - userDto.Roles.Add(roleDto); - } - } + _logger.LogInformation($"Updating user with id: {GlobalUser.Id}."); var user = await this._usersRepository.GetUserAsync(x => x.Id == GlobalUser.Id, cancellationToken); - if (user == null) { throw new EntityNotFoundException(); } - if (userDto.Roles.Any(x => x.Name == "User") && userDto.Email != null) - { - if (await this._usersRepository.GetUserAsync(x => x.Email == userDto.Email, cancellationToken) != null) - { - throw new EntityAlreadyExistsException("email", userDto.Email); - } - } - if (userDto.Roles.Any(x => x.Name == "User") && userDto.Phone != null) - { - if (await this._usersRepository.GetUserAsync(x => x.Phone == userDto.Phone, cancellationToken) != null) - { - throw new EntityAlreadyExistsException("phone", userDto.Phone); - } - } + await ValidateUserAsync(userDto, user, cancellationToken); this._mapper.Map(userDto, user); - if (!userDto.Password.IsNullOrEmpty()) + if (!string.IsNullOrEmpty(userDto.Password)) { user.PasswordHash = this._passwordHasher.Hash(userDto.Password); } - user.RefreshToken = this.GetRefreshToken(); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); + await CheckAndUpgradeToUserAsync(user, cancellationToken); - var tokens = this.GetUserTokens(user); + var updatedUser = await this._usersRepository.UpdateUserAsync(user, cancellationToken); - this._logger.LogInformation($"Update user with id: {GlobalUser.Id.ToString()}."); + var refreshToken = await AddRefreshToken(user.Id, cancellationToken); + var tokens = this.GetUserTokens(user, refreshToken); - return new UpdateUserModel() { Tokens = tokens, User = this._mapper.Map(user) }; + var updatedUserDto = this._mapper.Map(updatedUser); + + this._logger.LogInformation($"Update user with id: {GlobalUser.Id}."); + + return new UpdateUserModel() + { + Tokens = tokens, + User = updatedUserDto + }; } - public async Task UpdateUserByAdminAsync(string id, UserDto userDto, CancellationToken cancellationToken) + public async Task UpdateUserByAdminAsync(string id, UserDto userDto, CancellationToken cancellationToken) { - if (!ObjectId.TryParse(id, out var objectId)) - { - throw new InvalidDataException("Provided id is invalid."); - } - - var user = await this._usersRepository.GetUserAsync(objectId, cancellationToken); + _logger.LogInformation($"Admin updating User with Id: {id}."); + var userObjectId = ParseObjectId(id); + var user = await this._usersRepository.GetUserAsync(userObjectId, cancellationToken); if (user == null) { throw new EntityNotFoundException(); } + await ValidateUserAsync(userDto, user, cancellationToken); + this._mapper.Map(userDto, user); + var updatedUser = await this._usersRepository.UpdateUserAsync(user, cancellationToken); - user.RefreshToken = this.GetRefreshToken(); - await this._usersRepository.UpdateUserAsync(user, cancellationToken); + var updatedUserDto = this._mapper.Map(updatedUser); + + this._logger.LogInformation($"Admin updated User with Id: {id}."); - var tokens = this.GetUserTokens(user); - - this._logger.LogInformation($"Update user with id: {id}."); - - return new UpdateUserModel() { Tokens = tokens, User = this._mapper.Map(user) }; + return updatedUserDto; } - private string GetRefreshToken() + private async Task AddRefreshToken(ObjectId userId, CancellationToken cancellationToken) { - var refreshToken = this._tokensService.GenerateRefreshToken(); + _logger.LogInformation($"Adding new refresh token for user with Id : {userId}."); - this._logger.LogInformation($"Returned new refresh token."); + var refreshToken = new RefreshToken + { + Token = _tokensService.GenerateRefreshToken(), + ExpiryDateUTC = DateTime.UtcNow.AddDays(30), + CreatedById = userId, + CreatedDateUtc = DateTime.UtcNow + }; + + await this._refreshTokensRepository.AddAsync(refreshToken, cancellationToken); + + this._logger.LogInformation($"Added new refresh token."); return refreshToken; } - private TokensModel GetUserTokens(User user) + private TokensModel GetUserTokens(User user, RefreshToken refreshToken) { var claims = this.GetClaims(user); var accessToken = this._tokensService.GenerateAccessToken(claims); @@ -257,7 +285,7 @@ public class UserManager : IUserManager return new TokensModel { AccessToken = accessToken, - RefreshToken = user.RefreshToken, + RefreshToken = refreshToken.Token, }; } @@ -265,21 +293,56 @@ public class UserManager : IUserManager { var claims = new List() { - new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), - new Claim(ClaimTypes.Email, user.Email ?? string.Empty), - new Claim(ClaimTypes.MobilePhone, user.Phone ?? string.Empty), + new (ClaimTypes.NameIdentifier, user.Id.ToString()), + new (ClaimTypes.Email, user.Email ?? string.Empty), + new (ClaimTypes.MobilePhone, user.Phone ?? string.Empty), }; foreach (var role in user.Roles) { - claims.Add(new Claim(ClaimTypes.Role, role.Name)); + claims.Add(new (ClaimTypes.Role, role.Name)); } - this._logger.LogInformation($"Returned claims for user with id: {user.Id.ToString()}."); + this._logger.LogInformation($"Returned claims for User with Id: {user.Id}."); return claims; } + private async Task CheckAndUpgradeToUserAsync(User user, CancellationToken cancellationToken) + { + if (user.Roles.Any(x => x.Name == "Guest") && !user.Roles.Any(x => x.Name == "User")) + { + if (!string.IsNullOrEmpty(user.PasswordHash) && (!string.IsNullOrEmpty(user.Email) || !string.IsNullOrEmpty(user.Phone))) + { + var role = await this._rolesRepository.GetRoleAsync(x => x.Name == "User", cancellationToken); + user.Roles.Add(role); + } + } + } + + private async Task ValidateUserAsync(UserDto userDto, User user, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(userDto.Email)) + { + ValidateEmail(userDto.Email); + if (userDto.Email != user.Email + && await this._usersRepository.ExistsAsync(x => x.Email == userDto.Email, cancellationToken)) + { + throw new EntityAlreadyExistsException("email", userDto.Email); + } + } + + if (!string.IsNullOrEmpty(userDto.Phone)) + { + ValidatePhone(userDto.Phone); + if (userDto.Phone != user.Phone + && await this._usersRepository.ExistsAsync(x => x.Phone == userDto.Phone, cancellationToken)) + { + throw new EntityAlreadyExistsException("phone", userDto.Phone); + } + } + } + private void ValidateEmail(string email) { string regex = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; @@ -290,7 +353,7 @@ public class UserManager : IUserManager } } - private void ValidateNumber(string phone) + private void ValidatePhone(string phone) { string regex = @"^\+[0-9]{1,15}$"; diff --git a/ShoppingAssistantApi.Infrastructure/Services/RolesService.cs b/ShoppingAssistantApi.Infrastructure/Services/RolesService.cs index 7881ec4..d30bc3b 100644 --- a/ShoppingAssistantApi.Infrastructure/Services/RolesService.cs +++ b/ShoppingAssistantApi.Infrastructure/Services/RolesService.cs @@ -33,14 +33,16 @@ public class RolesService : IRolesService entity.CreatedDateUtc = DateTime.UtcNow; entity.LastModifiedDateUtc = DateTime.UtcNow; await this._repository.AddAsync(entity, cancellationToken); + return this._mapper.Map(entity); } public async Task> GetRolesPageAsync(int pageNumber, int pageSize, CancellationToken cancellationToken) { var entities = await this._repository.GetPageAsync(pageNumber, pageSize, cancellationToken); - var dtos = this._mapper.Map>(entities); var count = await this._repository.GetTotalCountAsync(); + var dtos = this._mapper.Map>(entities); + return new PagedList(dtos, pageNumber, pageSize, count); } } diff --git a/ShoppingAssistantApi.Infrastructure/Services/ServiceBase.cs b/ShoppingAssistantApi.Infrastructure/Services/ServiceBase.cs new file mode 100644 index 0000000..3e1381f --- /dev/null +++ b/ShoppingAssistantApi.Infrastructure/Services/ServiceBase.cs @@ -0,0 +1,15 @@ +namespace ShoppingAssistantApi.Infrastructure.Services; +using MongoDB.Bson; + +public abstract class ServiceBase +{ + public ObjectId ParseObjectId(string? id) + { + if (ObjectId.TryParse(id, out ObjectId objectId)) + { + return objectId; + } + + throw new InvalidDataException("Provided id cannot be parsed to a MongoDb ObjectId."); + } +} diff --git a/ShoppingAssistantApi.Persistance/Repositories/UsersRepository.cs b/ShoppingAssistantApi.Persistance/Repositories/UsersRepository.cs index 198bc08..39b4ab5 100644 --- a/ShoppingAssistantApi.Persistance/Repositories/UsersRepository.cs +++ b/ShoppingAssistantApi.Persistance/Repositories/UsersRepository.cs @@ -27,8 +27,6 @@ public class UsersRepository : BaseRepository, IUsersRepository var updateDefinition = Builders.Update .Set(u => u.Email, user.Email) .Set(u => u.Phone, user.Phone) - .Set(u => u.RefreshToken, user.RefreshToken) - .Set(u => u.RefreshTokenExpiryDate, user.RefreshTokenExpiryDate) .Set(u => u.GuestId, user.GuestId) .Set(u => u.Roles, user.Roles) .Set(u => u.PasswordHash, user.PasswordHash) @@ -42,7 +40,5 @@ public class UsersRepository : BaseRepository, IUsersRepository return await this._collection.FindOneAndUpdateAsync( Builders.Filter.Eq(u => u.Id, user.Id), updateDefinition, options, cancellationToken); - } - }