Backend login and register

This commit is contained in:
2026-02-19 13:49:01 +01:00
parent 0b6bb019b6
commit 93a78e4614
62 changed files with 11588 additions and 13 deletions

View File

@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>9c947c10-2373-4590-92a9-e5fe6b759c69</UserSecretsId>
<SpaRoot>..\ClientApp\</SpaRoot>
<SpaProxyServerUrl>http://localhost:44492</SpaProxyServerUrl>
<SpaProxyLaunchCommand>npm start</SpaProxyLaunchCommand>
@@ -16,4 +17,8 @@
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using API.Extension;
using Application.DTOs;
using Application.Interfaces;
using Application.Models;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class AuthController(IAuthenticationService authService) : BaseApiController
{
[HttpPost("register")]
public async Task<IResult> Register(RegisterRequest registerRequest)
{
var response = await authService.RegisterAsync(registerRequest);
return response.ToHttpResponse();
}
[HttpPost("login")]
public async Task<IResult> Login(LoginRequest loginRequest)
{
var response = await authService.LoginAsync(loginRequest);
return response.ToHttpResponse();
}
[HttpPost("refresh-token")]
public async Task<IResult> RefreshToken(RefreshTokenRequest refreshTokenRequest)
{
var response = await authService.RefreshTokensAsync(refreshTokenRequest);
return response.ToHttpResponse();
}
[HttpPost("send-reset-email/{email}")]
public async Task<IResult> SendResetEmail(string email)
{
var response = await authService.SendResetEmailAsync(email);
return response.ToHttpResponse();
}
[HttpPost("reset-password")]
public async Task<IResult> ResetPassword(ResetPasswordDto resetPasswordDto)
{
var response = await authService.ResetPasswordAsync(resetPasswordDto);
return response.ToHttpResponse();
}
}

View File

@@ -0,0 +1,63 @@
using API.Extension;
using Application.Interfaces;
using Application.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class UserController(IUserService userService) : BaseApiController
{
[Authorize(Roles = "SuperAdmin, Admin")]
[HttpGet]
public async Task<IResult> GetAllUsers(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
var users = await userService.GetAsync(pageNumber, pageSize);
return users.ToHttpResponse();
}
[Authorize]
[HttpPut]
public async Task<IResult> UpdateUser([FromBody] UserUpdateRequest userUpdateRequest)
{
var result = await userService.UpdateAsync(userUpdateRequest);
return result.ToHttpResponse();
}
[Authorize]
[HttpDelete("{id}")]
public async Task<IResult> DeleteUser(int id)
{
var currentUserId = int.Parse(User.FindFirst("UserId")!.Value);
var result = await userService.DeleteAsync(id, currentUserId);
return result.ToHttpResponse();
}
[Authorize]
[HttpGet("{id}")]
public async Task<IResult> GetUserById(int id)
{
var user = await userService.GetUserByIdAsync(id);
return user.ToHttpResponse();
}
[Authorize(Roles = "SuperAdmin")]
[HttpPost("assign-role")]
public async Task<IResult> AssignRole([FromBody] AssingRoleRequest roleRequest)
{
var result = await userService.AssignRoleAsync(roleRequest);
return result.ToHttpResponse();
}
[Authorize(Roles = "SuperAdmin")]
[HttpDelete("revoke-role")]
public async Task<IResult> RevokeRole([FromBody] AssingRoleRequest roleRequest)
{
var result = await userService.RevokeRoleAsync(roleRequest);
return result.ToHttpResponse();
}
}

View File

@@ -0,0 +1,32 @@
using Application.Common.Results;
namespace API.Extension;
public static class ResultExtension
{
public static IResult ToHttpResponse(this Result result)
{
if (result.IsSuccess) return Results.Ok(result);
return MapErrorResponse(result.Error, result);
}
public static IResult ToHttpResponse<T>(this Result<T> result)
{
if (result.IsSuccess)
return Results.Ok(result);
return MapErrorResponse(result.Error, result);
}
private static IResult MapErrorResponse(Error? error, object result)
{
return error?.Code switch
{
ErrorTypeConstant.ValidationError => Results.BadRequest(result),
ErrorTypeConstant.NotFound => Results.NotFound(result),
ErrorTypeConstant.Forbidden => Results.Forbid(),
ErrorTypeConstant.Unauthorized => Results.Unauthorized(),
_ => Results.Problem(error?.Message, statusCode: 500)
};
}
}

View File

@@ -4,7 +4,8 @@ namespace API.Extension;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddWebServices(this IServiceCollection services)
public static IServiceCollection AddWebServices(this IServiceCollection services,
ConfigurationManager builderConfiguration)
{
services.AddSwaggerGen(options =>
{

View File

@@ -1,4 +1,9 @@
using API.Extension;
using Application.Extensions;
using Infrastructure.Context;
using Infrastructure.Extensions;
using Infrastructure.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -7,7 +12,18 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
builder.Services.AddWebServices();
builder.Services.AddWebServices(builder.Configuration);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
// PostgreSql Database for development
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
var postgreSqlSettings =
builder.Configuration.GetRequiredSection("PostgreSqlSettings").Get<PostgreSqlSettings>();
var connectionString = postgreSqlSettings?.ConnectionString;
options.UseNpgsql(connectionString);
});
builder.Services.AddCors(options =>
{

View File

@@ -5,5 +5,18 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"PostgreSqlSettings": {
"Database": "template_db",
"Username": "natlinux"
},
"jwt": {
"Key": "veryveryveryveryveryveryverysecretkey",
"Issuer": "https://localhost:7091",
"Audience": "http://localhost:5184"
},
"EmailSettings": {
"SmtpServer": "smtp.gmail.com",
"Port": 465
}
}

View File

@@ -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>

View 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);
}

View 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";
}

View 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);
}
}

View 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");
}

View 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;
}

View 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; }
}

View 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; } = [];
}

View 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));
}
}

View 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));
}
}

View 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;
}
}

View 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);
}

View File

@@ -0,0 +1,8 @@
using Application.Models;
namespace Application.Interfaces;
public interface IEmailService
{
void SendEmailAsync(EmailRequest emailRequest);
}

View 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);
}

View 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);
}

View File

@@ -0,0 +1,3 @@
namespace Application.Models;
public record AssingRoleRequest(int UserId, int RoleId);

View 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; }
}

View File

@@ -0,0 +1,3 @@
namespace Application.Models;
public record LoginRequest(string Email, string Password);

View File

@@ -0,0 +1,7 @@
namespace Application.Models;
public class RefreshTokenRequest
{
public int UserId { get; set; }
public required string RefreshToken { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Application.Models;
public record RegisterRequest(
string Username,
string Email,
string Password
);

View File

@@ -0,0 +1,7 @@
namespace Application.Models;
public class TokenResponse
{
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace Application.Models;
public record UserUpdateRequest(int Id, string Username, string Email);

View 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");
}
}

View 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();
}
}
}

View 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;
}
}

View 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;
}
}
}

View 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.");
}
}

View 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.");
}
}

View 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.");
}
}

9504
src/ClientApp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
namespace Domain.Entities;
public class Role
{
public required int Id { get; set; }
public required string Name { get; set; }
public List<UserRole> UserRoles { get; set; } = [];
}

View File

@@ -0,0 +1,17 @@
namespace Domain.Entities;
public class User
{
public int Id { get; set; }
public required string Username { get; set; }
public required string Email { get; set; }
public required string Password { get; set; }
public DateTime LastLogin { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiryTime { get; set; }
public string? ResetPasswordToken { get; set; }
public DateTime ResetPasswordTokenExpiryTime { get; set; }
public List<UserRole> UserRoles { get; set; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace Domain.Entities;
public class UserRole
{
public int UserId { get; set; }
public User? User { get; set; }
public int RoleId { get; set; }
public Role? Role { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace Domain.Interface;
public interface IGenericRepository<TEntity> where TEntity : class
{
Task<TEntity?> GetByIdAsync(int id);
Task<IReadOnlyList<TEntity>> GetAllAsync();
Task<TEntity> AddAsync(TEntity entity);
TEntity Update(TEntity entity);
void Delete(TEntity entity);
}

View File

@@ -0,0 +1,7 @@
namespace Domain.Interface;
public interface IUnitOfWork
{
void Commit();
Task CommitAsync();
}

View File

@@ -0,0 +1,12 @@
using Domain.Entities;
namespace Domain.Interface;
public interface IUserRepository : IGenericRepository<User>
{
// extra implementation
Task<User?> GetUserByEmailAsync(string email);
Task<User?> GetUserByUsernameAsync(string username);
Task<List<string>> GetUserRolesByEmailAsync(string email);
Task<User?> GetUserByIdAsync(int id);
}

View File

@@ -0,0 +1,8 @@
namespace Domain.Interface;
public interface IUserRoleRepository
{
Task<bool> AddRoleAsync(int userId, int roleId);
Task<bool> RemoveRoleAsync(int userId, int roleId);
Task<bool> HasRoleAsync(int userId, int roleId);
}

View File

@@ -0,0 +1,27 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Infrastructure.Configuration;
public class RoleConfiguration : IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.ToTable("Roles", "auth");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(50);
builder.HasMany(x => x.UserRoles)
.WithOne(x => x.Role)
.HasForeignKey(x => x.RoleId);
builder.HasData(
new Role { Id = 1, Name = "SuperAdmin" },
new Role { Id = 2, Name = "Admin" },
new Role { Id = 3, Name = "User" }
);
}
}

View File

@@ -0,0 +1,47 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Infrastructure.Configuration;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("Users", "auth");
builder.HasKey(k => k.Id);
builder.Property(x => x.Username)
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
builder.Property(x => x.Password)
.IsRequired()
.HasMaxLength(250)
.HasColumnType("varchar(250)");
builder.Property(x => x.Email)
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasAnnotation("RegularExpression", @"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$");
builder.HasMany(x => x.UserRoles)
.WithOne(x => x.User)
.HasForeignKey(x => x.UserId);
builder.HasData(
new User
{
Id = 1, Username = "Superadmin", Email = "superadmin@wenske-services-development.de",
Password = "AQAAAAIAAYagAAAAEADJEu1s5qUJyP4gDUrBGyqSNtKU2IKBpZm0JqfyvOkJnqVeOHZBUrEhNr7IdQRDBQ=="
},
new User
{
Id = 2, Username = "Admin", Email = "admin@wenske-services-development.de",
Password = "AQAAAAIAAYagAAAAEIOUiJUfMrM1Lpt4Ae3FLQOB/Bk6WHtndRAWUp132afVunMvRqkT6Hhh+27kkNW8YQ=="
});
}
}

View File

@@ -0,0 +1,25 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Infrastructure.Configuration;
public class UserRoleConfiguration : IEntityTypeConfiguration<UserRole>
{
public void Configure(EntityTypeBuilder<UserRole> builder)
{
builder.ToTable("UserRoles", "auth");
builder.HasKey(x => new { x.UserId, x.RoleId });
builder.HasOne(x => x.User)
.WithMany(x => x.UserRoles)
.HasForeignKey(x => x.UserId);
builder.HasOne(x => x.Role)
.WithMany(x => x.UserRoles)
.HasForeignKey(x => x.RoleId);
builder.HasData(
new UserRole { UserId = 1, RoleId = 1 },
new UserRole { UserId = 2, RoleId = 2 });
}
}

View File

@@ -0,0 +1,16 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Context;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<UserRole> UserRoles { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
}

View File

@@ -0,0 +1,30 @@
using Infrastructure.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Context;
public static class DbContextOptionsFactory
{
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext(string[] args)
{
// Build the configuration manually
var configuration = new ConfigurationBuilder()
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../API"))
.AddJsonFile("appsettings.json")
.AddUserSecrets<ApplicationDbContextFactory>()
.Build();
// Read PostgreSQLSettings from configuration
var postgreSqlSettings = configuration.GetRequiredSection("PostgreSQLSettings").Get<PostgreSqlSettings>();
var connectionString = postgreSqlSettings?.ConnectionString;
var optionBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
if (connectionString != null)
optionBuilder.UseNpgsql(connectionString);
return new ApplicationDbContext(optionBuilder.Options);
}
}
}

View File

@@ -0,0 +1,19 @@
using Domain.Interface;
using Infrastructure.Repositories;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Infrastructure.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// Register repositories and unit of work
services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUserRoleRepository, UserRoleRepository>();
return services;
}
}

View File

@@ -4,6 +4,32 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>9c947c10-2373-4590-92a9-e5fe6b759c69</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.9" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Core" Version="2.3.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,185 @@
// <auto-generated />
using System;
using Infrastructure.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260206104345_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Domain.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Roles", "auth");
b.HasData(
new
{
Id = 1,
Name = "SuperAdmin"
},
new
{
Id = 2,
Name = "Admin"
},
new
{
Id = 3,
Name = "User"
});
});
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasAnnotation("RegularExpression", "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$");
b.Property<DateTime>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("ResetPasswordToken")
.HasColumnType("text");
b.Property<DateTime>("ResetPasswordTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.HasKey("Id");
b.ToTable("Users", "auth");
b.HasData(
new
{
Id = 1,
Email = "admin@rss.wenske-services-development.de",
LastLogin = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Password = "AQAAAAIAAYagAAAAELBxAsYVTssn5taCQ7CMo+Mzn0i87Jt8ZXJTe7cgG5hfN3wDJzIkQaotyFhM/mQGaQ==",
ResetPasswordTokenExpiryTime = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Username = "Superadmin"
},
new
{
Id = 2,
Email = "info@rss.wenske-services-development.de",
LastLogin = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Password = "AQAAAAIAAYagAAAAEP0NWiTTz20doMf/KL0WkBR+5roc5KTouMrfiHk2MMXOQn+E+C5Q4dqWD7PnNoxUmQ==",
ResetPasswordTokenExpiryTime = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Username = "Admin"
});
});
modelBuilder.Entity("Domain.Entities.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "auth");
b.HasData(
new
{
UserId = 1,
RoleId = 1
},
new
{
UserId = 2,
RoleId = 2
});
});
modelBuilder.Entity("Domain.Entities.UserRole", b =>
{
b.HasOne("Domain.Entities.Role", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Domain.Entities.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.Role", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,136 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "auth");
migrationBuilder.CreateTable(
name: "Roles",
schema: "auth",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
schema: "auth",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Username = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
Email = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
Password = table.Column<string>(type: "varchar(250)", maxLength: 250, nullable: false),
LastLogin = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RefreshToken = table.Column<string>(type: "text", nullable: true),
RefreshTokenExpiryTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ResetPasswordToken = table.Column<string>(type: "text", nullable: true),
ResetPasswordTokenExpiryTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRoles",
schema: "auth",
columns: table => new
{
UserId = table.Column<int>(type: "integer", nullable: false),
RoleId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_UserRoles_Roles_RoleId",
column: x => x.RoleId,
principalSchema: "auth",
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserRoles_Users_UserId",
column: x => x.UserId,
principalSchema: "auth",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
schema: "auth",
table: "Roles",
columns: new[] { "Id", "Name" },
values: new object[,]
{
{ 1, "SuperAdmin" },
{ 2, "Admin" },
{ 3, "User" }
});
migrationBuilder.InsertData(
schema: "auth",
table: "Users",
columns: new[] { "Id", "Email", "LastLogin", "Password", "RefreshToken", "RefreshTokenExpiryTime", "ResetPasswordToken", "ResetPasswordTokenExpiryTime", "Username" },
values: new object[,]
{
{ 1, "admin@rss.wenske-services-development.de", new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), "AQAAAAIAAYagAAAAELBxAsYVTssn5taCQ7CMo+Mzn0i87Jt8ZXJTe7cgG5hfN3wDJzIkQaotyFhM/mQGaQ==", null, null, null, new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), "Superadmin" },
{ 2, "info@rss.wenske-services-development.de", new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), "AQAAAAIAAYagAAAAEP0NWiTTz20doMf/KL0WkBR+5roc5KTouMrfiHk2MMXOQn+E+C5Q4dqWD7PnNoxUmQ==", null, null, null, new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), "Admin" }
});
migrationBuilder.InsertData(
schema: "auth",
table: "UserRoles",
columns: new[] { "RoleId", "UserId" },
values: new object[,]
{
{ 1, 1 },
{ 2, 2 }
});
migrationBuilder.CreateIndex(
name: "IX_UserRoles_RoleId",
schema: "auth",
table: "UserRoles",
column: "RoleId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserRoles",
schema: "auth");
migrationBuilder.DropTable(
name: "Roles",
schema: "auth");
migrationBuilder.DropTable(
name: "Users",
schema: "auth");
}
}
}

View File

@@ -0,0 +1,182 @@
// <auto-generated />
using System;
using Infrastructure.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Domain.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Roles", "auth");
b.HasData(
new
{
Id = 1,
Name = "SuperAdmin"
},
new
{
Id = 2,
Name = "Admin"
},
new
{
Id = 3,
Name = "User"
});
});
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasAnnotation("RegularExpression", "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$");
b.Property<DateTime>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("varchar(250)");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("ResetPasswordToken")
.HasColumnType("text");
b.Property<DateTime>("ResetPasswordTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.HasKey("Id");
b.ToTable("Users", "auth");
b.HasData(
new
{
Id = 1,
Email = "admin@rss.wenske-services-development.de",
LastLogin = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Password = "AQAAAAIAAYagAAAAELBxAsYVTssn5taCQ7CMo+Mzn0i87Jt8ZXJTe7cgG5hfN3wDJzIkQaotyFhM/mQGaQ==",
ResetPasswordTokenExpiryTime = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Username = "Superadmin"
},
new
{
Id = 2,
Email = "info@rss.wenske-services-development.de",
LastLogin = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Password = "AQAAAAIAAYagAAAAEP0NWiTTz20doMf/KL0WkBR+5roc5KTouMrfiHk2MMXOQn+E+C5Q4dqWD7PnNoxUmQ==",
ResetPasswordTokenExpiryTime = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Username = "Admin"
});
});
modelBuilder.Entity("Domain.Entities.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "auth");
b.HasData(
new
{
UserId = 1,
RoleId = 1
},
new
{
UserId = 2,
RoleId = 2
});
});
modelBuilder.Entity("Domain.Entities.UserRole", b =>
{
b.HasOne("Domain.Entities.Role", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Domain.Entities.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.Role", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Domain.Interface;
using Infrastructure.Context;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Repositories;
public class GenericRepository<TEntity>(ApplicationDbContext applicationDbContext)
: IGenericRepository<TEntity> where TEntity : class
{
protected readonly DbSet<TEntity> DbSet = applicationDbContext.Set<TEntity>();
public async Task<TEntity> AddAsync(TEntity entity)
{
var entry = await DbSet.AddAsync(entity);
return entry.Entity;
}
public void Delete(TEntity entity)
{
DbSet.Remove(entity);
}
public async Task<IReadOnlyList<TEntity>> GetAllAsync()
{
return await DbSet.ToListAsync();
}
public async Task<TEntity?> GetByIdAsync(int id)
{
return await DbSet.FindAsync(id);
}
public TEntity Update(TEntity entity)
{
var entry = DbSet.Update(entity);
return entry.Entity;
}
}

View File

@@ -0,0 +1,17 @@
using Domain.Interface;
using Infrastructure.Context;
namespace Infrastructure.Repositories;
public class UnitOfWork(ApplicationDbContext context) : IUnitOfWork
{
public void Commit()
{
context.SaveChanges();
}
public async Task CommitAsync()
{
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Domain.Interface;
using Infrastructure.Context;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Repositories;
public class UserRepository(ApplicationDbContext applicationDbContext)
: GenericRepository<User>(applicationDbContext), IUserRepository
{
public async Task<User?> GetUserByEmailAsync(string email)
{
return await DbSet.FirstOrDefaultAsync(u => u.Email == email);
}
public async Task<User?> GetUserByUsernameAsync(string username)
{
return await DbSet.FirstOrDefaultAsync(u => u.Username == username);
}
public async Task<List<string>> GetUserRolesByEmailAsync(string email)
{
return await DbSet
.Where(ur => ur.Email == email)
.SelectMany(ur => ur.UserRoles)
.Select(ur => ur.Role!.Name)
.ToListAsync();
}
public async Task<User?> GetUserByIdAsync(int id)
{
return await DbSet.FindAsync(id);
}
}

View File

@@ -0,0 +1,62 @@
using Domain.Entities;
using Domain.Interface;
using Infrastructure.Context;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Repositories;
public class UserRoleRepository(ApplicationDbContext applicationDbContext) : IUserRoleRepository
{
public async Task<bool> AddRoleAsync(int userId, int roleId)
{
try
{
var userRole = new UserRole
{
UserId = userId,
RoleId = roleId
};
await applicationDbContext.UserRoles.AddAsync(userRole);
var result = await applicationDbContext.SaveChangesAsync() > 0;
return result;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<bool> RemoveRoleAsync(int userId, int roleId)
{
try
{
var userRole =
await applicationDbContext.UserRoles.FirstOrDefaultAsync(u => u.UserId == userId && u.RoleId == roleId);
if (userRole is null) return false;
applicationDbContext.UserRoles.Remove(userRole);
var result = await applicationDbContext.SaveChangesAsync() > 0;
return result;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<bool> HasRoleAsync(int userId, int roleId)
{
try
{
var isUserHasRole =
await applicationDbContext.UserRoles.AnyAsync(u => u.UserId == userId && u.RoleId == roleId);
return isUserHasRole;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}

View File

@@ -0,0 +1,72 @@
namespace Infrastructure.Utilities;
public static class EmailBody
{
public static string EmailStringBody(string email, string emailToken)
{
// URL-encode parameters for security
var encodedEmail = Uri.EscapeDataString(email);
var encodedToken = Uri.EscapeDataString(emailToken);
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Reset Your Password</title>
</head>
<body style=""margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f4f4;"">
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background-color: #f4f4f4; padding: 20px 0;"">
<tr>
<td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background: linear-gradient(#4d82c3, #605d5d, #fda400) no-repeat; border-radius: 10px; overflow: hidden; max-width: 100%;"">
<tr>
<td style=""padding: 40px 30px; background-color: rgba(255, 255, 255, 0.95); margin: 20px;"">
<h1 style=""color: #333; text-align: center; margin: 0 0 20px 0; font-size: 28px;"">Reset Your Password</h1>
<hr style=""border: none; border-top: 2px solid #4d82c3; margin: 20px 0;"">
<p style=""color: #555; text-align: center; font-size: 16px; line-height: 1.6; margin: 20px 0;"">
You're receiving this email because you requested a password reset for your RssReader account.
</p>
<p style=""color: #555; font-size: 16px; line-height: 1.6; margin: 20px 0;"">
Please click the button below to choose a new password:
</p>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""margin: 30px 0;"">
<tr>
<td align=""center"">
<a href=""https://rss.wenske-services-development.de/reset?email={encodedEmail}&code={encodedToken}""
target=""_blank""
style=""background-color: #4d82c3;
color: #ffffff;
padding: 15px 40px;
text-decoration: none;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
display: inline-block;"">
Reset Password
</a>
</td>
</tr>
</table>
<p style=""color: #999; font-size: 14px; line-height: 1.6; margin: 30px 0 0 0; border-top: 1px solid #e0e0e0; padding-top: 20px;"">
If you didn't request a password reset, you can safely ignore this email.
</p>
<p style=""color: #555; font-size: 16px; margin: 30px 0 0 0;"">
Kind regards,<br>
<strong>The RssReader Team</strong>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>";
}
}

View File

@@ -0,0 +1,14 @@
using System.Security.Cryptography;
namespace Infrastructure.Utilities;
public abstract class GenerateRefreshTokenHelper
{
public static string GenerateRefreshToken()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}

View File

@@ -0,0 +1,11 @@
namespace Infrastructure.Utilities;
public class PostgreSqlSettings
{
public string? Host { get; set; }
public string? Database { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string ConnectionString => $"Host={Host};Database={Database};Username={Username};Password={Password};";
}