Backend login and register
This commit is contained in:
@@ -6,4 +6,20 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MailKit" Version="4.14.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Core" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="MimeKit" Version="4.14.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
6
src/Application/Common/Results/Error.cs
Normal file
6
src/Application/Common/Results/Error.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Application.Common.Results;
|
||||
|
||||
public sealed record Error(string Code, string Message)
|
||||
{
|
||||
internal static Error None => new(ErrorTypeConstant.None, string.Empty);
|
||||
}
|
||||
11
src/Application/Common/Results/ErrorTypeConstant.cs
Normal file
11
src/Application/Common/Results/ErrorTypeConstant.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Application.Common.Results;
|
||||
|
||||
public class ErrorTypeConstant
|
||||
{
|
||||
public const string None = "None";
|
||||
public const string ValidationError = "ValidationError";
|
||||
public const string InternalServerError = "InternalServerError";
|
||||
public const string NotFound = "NotFoundError";
|
||||
public const string Unauthorized = "UnauthorizedError";
|
||||
public const string Forbidden = "ForbiddenError";
|
||||
}
|
||||
37
src/Application/Common/Results/Result.cs
Normal file
37
src/Application/Common/Results/Result.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace Application.Common.Results;
|
||||
|
||||
public class Result
|
||||
{
|
||||
protected Result(bool isSuccess, Error error)
|
||||
{
|
||||
if ((isSuccess && error != Error.None) || (!isSuccess && error == Error.None))
|
||||
throw new InvalidOperationException("Invalid operation");
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
public Error Error { get; }
|
||||
|
||||
public static Result Success()
|
||||
{
|
||||
return new Result(true, Error.None);
|
||||
}
|
||||
|
||||
public static Result<TValue> Success<TValue>(TValue value)
|
||||
{
|
||||
return new Result<TValue>(value, true, Error.None);
|
||||
}
|
||||
|
||||
public static Result Failure(Error error)
|
||||
{
|
||||
return new Result(false, error);
|
||||
}
|
||||
|
||||
public static Result<TValue> Failure<TValue>(Error error)
|
||||
{
|
||||
return new Result<TValue>(default!, false, error);
|
||||
}
|
||||
}
|
||||
13
src/Application/Common/Results/TResult.cs
Normal file
13
src/Application/Common/Results/TResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Application.Common.Results;
|
||||
|
||||
public class Result<TValue> : Result
|
||||
{
|
||||
private readonly TValue _value;
|
||||
|
||||
protected internal Result(TValue value, bool isSuccess, Error error) : base(isSuccess, error)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public TValue Value => IsSuccess ? _value : throw new InvalidOperationException("No value for failure result");
|
||||
}
|
||||
12
src/Application/DTOs/PagedResult.cs
Normal file
12
src/Application/DTOs/PagedResult.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Application.DTOs;
|
||||
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public List<T> Items { get; set; } = [];
|
||||
public int TotalCount { get; set; }
|
||||
public int PageNumber { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||
public bool HasPreviousPage => PageNumber > 1;
|
||||
public bool HasNext => PageNumber < TotalPages;
|
||||
}
|
||||
8
src/Application/DTOs/ResetPasswordDto.cs
Normal file
8
src/Application/DTOs/ResetPasswordDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Application.DTOs;
|
||||
|
||||
public record ResetPasswordDto
|
||||
{
|
||||
public required string Email { get; init; }
|
||||
public required string EmailToken { get; init; }
|
||||
public required string NewPassword { get; init; }
|
||||
}
|
||||
10
src/Application/DTOs/UserDto.cs
Normal file
10
src/Application/DTOs/UserDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Application.DTOs;
|
||||
|
||||
public record UserDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public required string Username { get; init; }
|
||||
public DateTime LastLogin { get; init; }
|
||||
public List<string> Roles { get; init; } = [];
|
||||
}
|
||||
29
src/Application/Errors/AuthError.cs
Normal file
29
src/Application/Errors/AuthError.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Application.Common.Results;
|
||||
|
||||
namespace Application.Errors;
|
||||
|
||||
public static class AuthError
|
||||
{
|
||||
public static Error InvalidRegisterRequest => new(ErrorTypeConstant.ValidationError, "Invalid register request");
|
||||
|
||||
public static Error EmailAlreadyExists => new(ErrorTypeConstant.ValidationError, "E-Mail already exists");
|
||||
|
||||
public static Error UsernameAlreadyExists => new(ErrorTypeConstant.ValidationError, "Username already exists");
|
||||
public static Error InvalidLoginRequest => new(ErrorTypeConstant.ValidationError, "Invalid login request");
|
||||
|
||||
public static Error UserNotFound => new(ErrorTypeConstant.NotFound, "User not found");
|
||||
|
||||
public static Error InvalidPassword => new(ErrorTypeConstant.ValidationError, "Invalid Password");
|
||||
public static Error InvalidResetLink => new(ErrorTypeConstant.ValidationError, "Invalid reset link");
|
||||
|
||||
|
||||
public static Error CreateInvalidLoginRequestError(IEnumerable<string> errors)
|
||||
{
|
||||
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
|
||||
}
|
||||
|
||||
public static Error CreateInvalidRegisterRequestError(IEnumerable<string> errors)
|
||||
{
|
||||
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
|
||||
}
|
||||
}
|
||||
54
src/Application/Errors/UserError.cs
Normal file
54
src/Application/Errors/UserError.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Application.Common.Results;
|
||||
|
||||
namespace Application.Errors;
|
||||
|
||||
public static class UserError
|
||||
{
|
||||
public static Error InternalServerError =>
|
||||
new(ErrorTypeConstant.InternalServerError, "something went wrong");
|
||||
|
||||
public static Error UserNotFound =>
|
||||
new(ErrorTypeConstant.NotFound, "User not found");
|
||||
|
||||
public static Error UserCookieConsentNotFound =>
|
||||
new(ErrorTypeConstant.NotFound, "Cookie consent not found for user");
|
||||
|
||||
public static Error FailedToAssignRole =>
|
||||
new(ErrorTypeConstant.InternalServerError, "failed to assign role");
|
||||
|
||||
public static Error FailedToRevokeRole =>
|
||||
new(ErrorTypeConstant.InternalServerError, "failed to revoke role");
|
||||
|
||||
public static Error UserAlreadyHasRole =>
|
||||
new(ErrorTypeConstant.ValidationError, "User already has role");
|
||||
|
||||
public static Error UserHasNoRole =>
|
||||
new(ErrorTypeConstant.ValidationError, "User already has no role");
|
||||
|
||||
public static Error FailedToRevokeCookieConsent =>
|
||||
new(ErrorTypeConstant.InternalServerError, "failed to revoke cookie consent");
|
||||
|
||||
public static Error UserAlreadyHasCookieConsent =>
|
||||
new(ErrorTypeConstant.ValidationError, "User already has cookie consent");
|
||||
|
||||
public static Error UserHasNoCookieConsent =>
|
||||
new(ErrorTypeConstant.ValidationError, "User already has no cookie consent");
|
||||
|
||||
public static Error CannotDeleteYourself =>
|
||||
new(ErrorTypeConstant.ValidationError, "You cannot delete your own account");
|
||||
|
||||
public static Error CreateInvalidUserUpdateRequestError(IEnumerable<string> errors)
|
||||
{
|
||||
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
|
||||
}
|
||||
|
||||
public static Error CreateInvalidCookieConsentError(IEnumerable<string> errors)
|
||||
{
|
||||
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
|
||||
}
|
||||
|
||||
public static Error CreateInvalidLoginRequestError(IEnumerable<string> errors)
|
||||
{
|
||||
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
|
||||
}
|
||||
}
|
||||
20
src/Application/Extensions/ServiceCollectionExtensions.cs
Normal file
20
src/Application/Extensions/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Application.Interfaces;
|
||||
using Application.Services;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Application.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddValidatorsFromAssembly(typeof(ServiceCollectionExtensions).Assembly);
|
||||
services.AddScoped<IAuthenticationService, AuthenticationService>();
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
services.AddScoped<IEmailService, EmailService>();
|
||||
services.AddScoped<IUserService, UserService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
14
src/Application/Interfaces/IAuthenticationService.cs
Normal file
14
src/Application/Interfaces/IAuthenticationService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Application.Common.Results;
|
||||
using Application.DTOs;
|
||||
using Application.Models;
|
||||
|
||||
namespace Application.Interfaces;
|
||||
|
||||
public interface IAuthenticationService
|
||||
{
|
||||
Task<Result> RegisterAsync(RegisterRequest request);
|
||||
Task<Result> LoginAsync(LoginRequest request);
|
||||
Task<Result> RefreshTokensAsync(RefreshTokenRequest request);
|
||||
Task<Result> SendResetEmailAsync(string email);
|
||||
Task<Result> ResetPasswordAsync(ResetPasswordDto resetPasswordDto);
|
||||
}
|
||||
8
src/Application/Interfaces/IEmailService.cs
Normal file
8
src/Application/Interfaces/IEmailService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Application.Models;
|
||||
|
||||
namespace Application.Interfaces;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
void SendEmailAsync(EmailRequest emailRequest);
|
||||
}
|
||||
10
src/Application/Interfaces/IJwtService.cs
Normal file
10
src/Application/Interfaces/IJwtService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Application.Interfaces;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
Task<string> GenerateTokenAsync(User user);
|
||||
Task<string> GenerateAndSaveRefreshTokenAsync(User user);
|
||||
Task<User?> ValidateRefreshTokenAsync(int userId, string refreshToken);
|
||||
}
|
||||
15
src/Application/Interfaces/IUserService.cs
Normal file
15
src/Application/Interfaces/IUserService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Application.Common.Results;
|
||||
using Application.DTOs;
|
||||
using Application.Models;
|
||||
|
||||
namespace Application.Interfaces;
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<Result<PagedResult<UserDto>>> GetAsync(int pageNumber = 1, int pageSize = 10);
|
||||
Task<Result<string>> UpdateAsync(UserUpdateRequest user);
|
||||
Task<Result<string>> DeleteAsync(int id, int currentUserId);
|
||||
Task<Result<UserDto>> GetUserByIdAsync(int id);
|
||||
Task<Result<string>> AssignRoleAsync(AssingRoleRequest roleRequest);
|
||||
Task<Result<string>> RevokeRoleAsync(AssingRoleRequest roleRequest);
|
||||
}
|
||||
3
src/Application/Models/AssingRoleRequest.cs
Normal file
3
src/Application/Models/AssingRoleRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public record AssingRoleRequest(int UserId, int RoleId);
|
||||
15
src/Application/Models/EmailRequest.cs
Normal file
15
src/Application/Models/EmailRequest.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public class EmailRequest
|
||||
{
|
||||
public EmailRequest(string to, string subject, string content)
|
||||
{
|
||||
To = to;
|
||||
Subject = subject;
|
||||
Content = content;
|
||||
}
|
||||
|
||||
public string To { get; set; }
|
||||
public string Subject { get; set; }
|
||||
public string Content { get; set; }
|
||||
}
|
||||
3
src/Application/Models/LoginRequest.cs
Normal file
3
src/Application/Models/LoginRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
7
src/Application/Models/RefreshTokenRequest.cs
Normal file
7
src/Application/Models/RefreshTokenRequest.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public class RefreshTokenRequest
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
}
|
||||
7
src/Application/Models/RegisterRequest.cs
Normal file
7
src/Application/Models/RegisterRequest.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Username,
|
||||
string Email,
|
||||
string Password
|
||||
);
|
||||
7
src/Application/Models/TokenResponse.cs
Normal file
7
src/Application/Models/TokenResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public class TokenResponse
|
||||
{
|
||||
public required string AccessToken { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
}
|
||||
3
src/Application/Models/UserUpdateRequest.cs
Normal file
3
src/Application/Models/UserUpdateRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Application.Models;
|
||||
|
||||
public record UserUpdateRequest(int Id, string Username, string Email);
|
||||
147
src/Application/Services/AuthenticationService.cs
Normal file
147
src/Application/Services/AuthenticationService.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Security.Cryptography;
|
||||
using Application.Common.Results;
|
||||
using Application.DTOs;
|
||||
using Application.Errors;
|
||||
using Application.Interfaces;
|
||||
using Application.Models;
|
||||
using Application.Validators;
|
||||
using Domain.Entities;
|
||||
using Domain.Interface;
|
||||
using Infrastructure.Utilities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Application.Services;
|
||||
|
||||
public class AuthenticationService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IUserRepository iUserRepository,
|
||||
LoginRequestValidator loginRequestValidator,
|
||||
RegisterRequestValidator registerRequestValidator,
|
||||
IJwtService jwtService,
|
||||
IEmailService emailService) : IAuthenticationService
|
||||
{
|
||||
public async Task<Result> RegisterAsync(RegisterRequest registerRequest)
|
||||
{
|
||||
var validationResult = await registerRequestValidator.ValidateAsync(registerRequest);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var errors = validationResult.Errors.Select(x => x.ErrorMessage);
|
||||
return Result.Failure(AuthError.CreateInvalidRegisterRequestError(errors));
|
||||
}
|
||||
|
||||
var emailExists = await iUserRepository.GetUserByEmailAsync(registerRequest.Email);
|
||||
if (emailExists is not null) return Result.Failure(AuthError.EmailAlreadyExists);
|
||||
|
||||
var usernameExists = await iUserRepository.GetUserByUsernameAsync(registerRequest.Username);
|
||||
if (usernameExists is not null) return Result.Failure(AuthError.UsernameAlreadyExists);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Username = registerRequest.Username,
|
||||
Email = registerRequest.Email,
|
||||
Password = registerRequest.Password,
|
||||
UserRoles = [new UserRole { RoleId = 3 }]
|
||||
};
|
||||
|
||||
var passwordHasher = new PasswordHasher<User>();
|
||||
var hashedPassword = passwordHasher.HashPassword(user, registerRequest.Password);
|
||||
user.Password = hashedPassword;
|
||||
|
||||
await iUserRepository.AddAsync(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
return Result.Success("User registered successfully.");
|
||||
}
|
||||
|
||||
public async Task<Result> LoginAsync(LoginRequest loginRequest)
|
||||
{
|
||||
var validationResult = await loginRequestValidator.ValidateAsync(loginRequest);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var errors = validationResult.Errors.Select(x => x.ErrorMessage);
|
||||
return Result.Failure(AuthError.CreateInvalidLoginRequestError(errors));
|
||||
}
|
||||
|
||||
var (email, password) = loginRequest;
|
||||
var user = await iUserRepository.GetUserByEmailAsync(email);
|
||||
if (user is null) return Result.Failure(AuthError.UserNotFound);
|
||||
|
||||
var passwordHasher = new PasswordHasher<User>();
|
||||
var verificationResult = passwordHasher.VerifyHashedPassword(user, user.Password, password);
|
||||
if (verificationResult == PasswordVerificationResult.Failed)
|
||||
return Result.Failure(AuthError.InvalidPassword);
|
||||
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
iUserRepository.Update(user);
|
||||
|
||||
|
||||
var token = new TokenResponse
|
||||
{
|
||||
AccessToken = await jwtService.GenerateTokenAsync(user),
|
||||
RefreshToken = await jwtService.GenerateAndSaveRefreshTokenAsync(user)
|
||||
};
|
||||
var result = new
|
||||
{
|
||||
Token = token, user.Username
|
||||
};
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
|
||||
public async Task<Result> RefreshTokensAsync(RefreshTokenRequest request)
|
||||
{
|
||||
var user = await jwtService.ValidateRefreshTokenAsync(request.UserId, request.RefreshToken);
|
||||
if (user is null) return Result.Failure(AuthError.UserNotFound);
|
||||
|
||||
var result = new TokenResponse
|
||||
{
|
||||
AccessToken = await jwtService.GenerateTokenAsync(user),
|
||||
RefreshToken = await jwtService.GenerateAndSaveRefreshTokenAsync(user)
|
||||
};
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
|
||||
public async Task<Result> SendResetEmailAsync(string email)
|
||||
{
|
||||
var user = await iUserRepository.GetUserByEmailAsync(email);
|
||||
if (user is null) return Result.Failure(AuthError.UserNotFound);
|
||||
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(64);
|
||||
var emailToken = Convert.ToBase64String(tokenBytes);
|
||||
user.ResetPasswordToken = emailToken;
|
||||
user.ResetPasswordTokenExpiryTime = DateTime.UtcNow.AddMinutes(15);
|
||||
var emailModel = new EmailRequest(email, "Reset Password!!", EmailBody.EmailStringBody(email, emailToken));
|
||||
emailService.SendEmailAsync(emailModel);
|
||||
iUserRepository.Update(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
return Result.Success("Reset email sent successfully");
|
||||
}
|
||||
|
||||
public async Task<Result> ResetPasswordAsync(ResetPasswordDto resetPasswordDto)
|
||||
{
|
||||
// Normalize the incoming token if '+' was converted to space by transport layers.
|
||||
var normalizedToken = (resetPasswordDto.EmailToken ?? string.Empty).Replace(" ", "+");
|
||||
|
||||
var user = await iUserRepository.GetUserByEmailAsync(resetPasswordDto.Email);
|
||||
if (user is null) return Result.Failure(AuthError.UserNotFound);
|
||||
|
||||
var tokenCode = user.ResetPasswordToken;
|
||||
var emailTokenExpiryTime = user.ResetPasswordTokenExpiryTime;
|
||||
|
||||
// Validate token and expiration using UTC to match stored times
|
||||
if (string.IsNullOrWhiteSpace(normalizedToken) || tokenCode != normalizedToken ||
|
||||
emailTokenExpiryTime < DateTime.UtcNow) return Result.Failure(AuthError.InvalidResetLink);
|
||||
|
||||
var passwordHasher = new PasswordHasher<User>();
|
||||
var hashedPassword = passwordHasher.HashPassword(user, resetPasswordDto.NewPassword);
|
||||
user.Password = hashedPassword;
|
||||
|
||||
// Invalidate the reset token after successful use
|
||||
user.ResetPasswordToken = null;
|
||||
user.ResetPasswordTokenExpiryTime = default;
|
||||
|
||||
iUserRepository.Update(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
return Result.Success("Password reset successfully");
|
||||
}
|
||||
}
|
||||
49
src/Application/Services/EmailService.cs
Normal file
49
src/Application/Services/EmailService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Application.Interfaces;
|
||||
using Application.Models;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MimeKit;
|
||||
using MimeKit.Text;
|
||||
|
||||
namespace Application.Services;
|
||||
|
||||
public class EmailService(IConfiguration config, Func<ISmtpClient>? smtpClientFactory)
|
||||
: IEmailService
|
||||
{
|
||||
private readonly Func<ISmtpClient> _smtpClientFactory = smtpClientFactory ?? (() => new SmtpClient());
|
||||
|
||||
public EmailService(IConfiguration config) : this(config, null)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendEmailAsync(EmailRequest emailRequest)
|
||||
{
|
||||
var emailMessage = new MimeMessage();
|
||||
var from = config["EmailSettings:From"];
|
||||
emailMessage.From.Add(new MailboxAddress("RssReader", from));
|
||||
emailMessage.To.Add(new MailboxAddress(emailRequest.To, emailRequest.To));
|
||||
emailMessage.Subject = emailRequest.Subject;
|
||||
emailMessage.Body = new TextPart(TextFormat.Html)
|
||||
{
|
||||
Text = string.Format(emailRequest.Content)
|
||||
};
|
||||
|
||||
var client = _smtpClientFactory();
|
||||
try
|
||||
{
|
||||
client.Connect(config["EmailSettings:SmtpServer"], 465, true);
|
||||
client.Authenticate(config["EmailSettings:From"], config["EmailSettings:Password"]);
|
||||
client.Send(emailMessage);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Disconnect(true);
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/Application/Services/JwtService.cs
Normal file
65
src/Application/Services/JwtService.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Application.Interfaces;
|
||||
using Domain.Entities;
|
||||
using Domain.Interface;
|
||||
using Infrastructure.Utilities;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Application.Services;
|
||||
|
||||
public class JwtService(
|
||||
IConfiguration configuration,
|
||||
IUserRepository userRepository,
|
||||
IUnitOfWork unitOfWork) : IJwtService
|
||||
{
|
||||
public async Task<string> GenerateTokenAsync(User user)
|
||||
{
|
||||
var secretKey = configuration["Jwt:Key"];
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey!));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
var roles = await userRepository.GetUserRolesByEmailAsync(user.Email);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Email, user.Email),
|
||||
new("UserId", user.Id.ToString()),
|
||||
new("username", user.Username)
|
||||
};
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddDays(1), // set a token expiration time
|
||||
SigningCredentials = credentials,
|
||||
Issuer = configuration["Jwt:Issuer"],
|
||||
Audience = configuration["Jwt:Audience"]
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var securityToken = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(securityToken);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAndSaveRefreshTokenAsync(User user)
|
||||
{
|
||||
var refreshToken = GenerateRefreshTokenHelper.GenerateRefreshToken();
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(5);
|
||||
userRepository.Update(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
|
||||
public async Task<User?> ValidateRefreshTokenAsync(int userId, string refreshToken)
|
||||
{
|
||||
var user = await userRepository.GetUserByIdAsync(userId);
|
||||
if (user is null || user.RefreshToken != refreshToken
|
||||
|| user.RefreshTokenExpiryTime <= DateTime.UtcNow)
|
||||
return null;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
163
src/Application/Services/UserService.cs
Normal file
163
src/Application/Services/UserService.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Application.Common.Results;
|
||||
using Application.DTOs;
|
||||
using Application.Errors;
|
||||
using Application.Interfaces;
|
||||
using Application.Models;
|
||||
using Application.Validators;
|
||||
using Domain.Interface;
|
||||
|
||||
namespace Application.Services;
|
||||
|
||||
public class UserService(
|
||||
IUnitOfWork unitOfWork,
|
||||
UserUpdateRequestValidator userUpdateRequestValidator,
|
||||
IUserRepository userRepository,
|
||||
IUserRoleRepository userRoleRepository) : IUserService
|
||||
{
|
||||
public async Task<Result<PagedResult<UserDto>>> GetAsync(int pageNumber = 1, int pageSize = 10)
|
||||
{
|
||||
try
|
||||
{
|
||||
// fetch all users
|
||||
var users = await userRepository.GetAllAsync();
|
||||
var totalCount = users.Count;
|
||||
var pagedItems = users
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(user => new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
LastLogin = user.LastLogin,
|
||||
Roles = user.UserRoles.Select(x => x.Role?.Name ?? "Unknown").ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var pageResult = new PagedResult<UserDto>
|
||||
{
|
||||
Items = pagedItems,
|
||||
TotalCount = totalCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
};
|
||||
return Result.Success(pageResult);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<string>> UpdateAsync(UserUpdateRequest userUpdateRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
// validate request
|
||||
var validationResult = await userUpdateRequestValidator.ValidateAsync(userUpdateRequest);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var errors = validationResult.Errors.Select(a => a.ErrorMessage);
|
||||
return Result.Failure<string>(UserError.CreateInvalidUserUpdateRequestError(errors));
|
||||
}
|
||||
|
||||
// check if a user exists
|
||||
var user = await userRepository.GetByIdAsync(userUpdateRequest.Id);
|
||||
if (user == null) return Result.Failure<string>(UserError.UserNotFound);
|
||||
|
||||
// update user
|
||||
user.Username = userUpdateRequest.Username;
|
||||
user.Email = userUpdateRequest.Email;
|
||||
userRepository.Update(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
return Result.Success("User updated successfully");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<string>> DeleteAsync(int id, int currentUserId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Prevent users from deleting themselves
|
||||
if (id == currentUserId) return Result.Failure<string>(UserError.CannotDeleteYourself);
|
||||
|
||||
var user = await userRepository.GetByIdAsync(id);
|
||||
if (user == null) return Result.Failure<string>(UserError.UserNotFound);
|
||||
|
||||
userRepository.Delete(user);
|
||||
await unitOfWork.CommitAsync();
|
||||
return Result.Success("User deleted successfully");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<UserDto>> GetUserByIdAsync(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(id);
|
||||
if (user is null) return Result.Failure<UserDto>(UserError.UserNotFound);
|
||||
var userDetails = new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
LastLogin = user.LastLogin,
|
||||
Roles = user.UserRoles.Select(x => x.Role?.Name ?? "Unknown").ToList()
|
||||
};
|
||||
return Result.Success(userDetails);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<string>> AssignRoleAsync(AssingRoleRequest roleRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var isUserHasRole = userRoleRepository.HasRoleAsync(roleRequest.UserId, roleRequest.RoleId);
|
||||
if (isUserHasRole.Result) return Result.Failure<string>(UserError.UserAlreadyHasRole);
|
||||
var result = await userRoleRepository.AddRoleAsync(roleRequest.UserId, roleRequest.RoleId);
|
||||
return result
|
||||
? Result.Success("Role assigned successfully")
|
||||
: Result.Failure<string>(UserError.FailedToAssignRole);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<string>> RevokeRoleAsync(AssingRoleRequest roleRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var isUserHasRole = userRoleRepository.HasRoleAsync(roleRequest.UserId, roleRequest.RoleId);
|
||||
if (!isUserHasRole.Result) return Result.Failure<string>(UserError.UserHasNoRole);
|
||||
|
||||
var result = await userRoleRepository.RemoveRoleAsync(roleRequest.UserId, roleRequest.RoleId);
|
||||
return result
|
||||
? Result.Success("Role revoked successfully")
|
||||
: Result.Failure<string>(UserError.FailedToRevokeRole);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Application/Validators/LoginRequestValidator.cs
Normal file
17
src/Application/Validators/LoginRequestValidator.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Application.Models;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Application.Validators;
|
||||
|
||||
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||
{
|
||||
public LoginRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("Email is required.")
|
||||
.EmailAddress().WithMessage("Email is not valid.");
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty().WithMessage("Password is required.");
|
||||
}
|
||||
}
|
||||
29
src/Application/Validators/RegisterRequestValidator.cs
Normal file
29
src/Application/Validators/RegisterRequestValidator.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Application.Models;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Application.Validators;
|
||||
|
||||
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
||||
{
|
||||
public RegisterRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("{PropertyName} is required.")
|
||||
.EmailAddress().WithMessage("{PropertyName} is not valid.");
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty().WithMessage("{PropertyName} is required.")
|
||||
.MinimumLength(10).WithMessage("{PropertyName} must be at least 10 characters long.")
|
||||
.Matches("[A-Z]").WithMessage("{PropertyName} must contain at least one uppercase letter.")
|
||||
.Matches("[a-z]").WithMessage("{PropertyName} must contain at least one lowercase letter.")
|
||||
.Matches("[0-9]").WithMessage("{PropertyName} must contain at least one digit.")
|
||||
.Matches("[^a-zA-Z0-9]").WithMessage("{PropertyName} must contain at least one special character.");
|
||||
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty().WithMessage("{PropertyName} is required.")
|
||||
.MinimumLength(3).WithMessage("{PropertyName} must be at least 3 characters long.")
|
||||
.MaximumLength(20).WithMessage("{PropertyName} must not exceed 20 characters.")
|
||||
.Matches("^[a-zA-Z0-9_]*$")
|
||||
.WithMessage("{PropertyName} can only contain letters, numbers, and underscores.");
|
||||
}
|
||||
}
|
||||
21
src/Application/Validators/UserUpdateRequestValidator.cs
Normal file
21
src/Application/Validators/UserUpdateRequestValidator.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Application.Models;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Application.Validators;
|
||||
|
||||
public class UserUpdateRequestValidator : AbstractValidator<UserUpdateRequest>
|
||||
{
|
||||
public UserUpdateRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty().WithMessage("Username is required.")
|
||||
.MaximumLength(50).WithMessage(user => "Username must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("Email is required.")
|
||||
.EmailAddress().WithMessage("Email is invalid.");
|
||||
|
||||
RuleFor(x => x.Id)
|
||||
.NotEmpty().GreaterThan(0).WithMessage("Id is required.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user