diff --git a/CSR.Application/Class1.cs b/CSR.Application/Class1.cs deleted file mode 100644 index 47f3cf6..0000000 --- a/CSR.Application/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CSR.Application; - -public class Class1 -{ - -} diff --git a/CSR.Application/Interfaces/IRoleRepository.cs b/CSR.Application/Interfaces/IRoleRepository.cs new file mode 100644 index 0000000..d8afc00 --- /dev/null +++ b/CSR.Application/Interfaces/IRoleRepository.cs @@ -0,0 +1,13 @@ +namespace CSR.Application.Interfaces; + +using CSR.Domain.Entities; + +public interface IRoleRepository +{ + Task GetByIdAsync(int id); + // Task GetByNameAsync(string name); + // Task> GetAllAsync(); + Task AddAsync(Role role); + // Task UpdateAsync(Role role); + Task DeleteAsync(int id); +} diff --git a/CSR.Application/Interfaces/IUserRepository.cs b/CSR.Application/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..75f180d --- /dev/null +++ b/CSR.Application/Interfaces/IUserRepository.cs @@ -0,0 +1,14 @@ +namespace CSR.Application.Interfaces; + +using CSR.Domain.Entities; + +public interface IUserRepository +{ + Task GetByIdAsync(int id); + // Task GetByEmailAsync(string email); + // Task GetByUsernameAsync(string username); + // Task> GetAllAsync(); + Task AddAsync(User user); + Task UpdateAsync(User user); + Task DeleteAsync(int id); +} diff --git a/CSR.Application/Interfaces/IUserService.cs b/CSR.Application/Interfaces/IUserService.cs new file mode 100644 index 0000000..50dfc27 --- /dev/null +++ b/CSR.Application/Interfaces/IUserService.cs @@ -0,0 +1,8 @@ +namespace CSR.Application.Interfaces; + +public interface IUserService +{ + public record CreateUserResult(Domain.Entities.User? User, string? ErrorMessage); + + public void CreateUser(string username, string email, string password, bool isAdmin = false); +} diff --git a/CSR.Application/Services/UserService.cs b/CSR.Application/Services/UserService.cs new file mode 100644 index 0000000..b3878a1 --- /dev/null +++ b/CSR.Application/Services/UserService.cs @@ -0,0 +1,14 @@ +namespace CSR.Application.Services; + +using CSR.Application.Interfaces; +using CSR.Domain.Entities; + +public class UserService(IUserRepository userRepository) : IUserService +{ + private readonly IUserRepository _userRepository = userRepository; + + public void CreateNewUser(string username, string email, string password, bool isAdmin = false) + { + + } +} diff --git a/CSR.Domain/CSR.Domain.csproj b/CSR.Domain/CSR.Domain.csproj index bb23fb7..8b67162 100644 --- a/CSR.Domain/CSR.Domain.csproj +++ b/CSR.Domain/CSR.Domain.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/CSR.Domain/Entities/Role.cs b/CSR.Domain/Entities/Role.cs new file mode 100644 index 0000000..7bb02c0 --- /dev/null +++ b/CSR.Domain/Entities/Role.cs @@ -0,0 +1,33 @@ +namespace CSR.Domain.Entities; + +public record Role +{ + public int Id { get; private set; } + public string Name { get; private set; } + + private Role(int id, string name) + { + Id = id; + Name = name; + } + + internal static Role LoadExisting(int id, string name) + { + return new Role(id, name); + } + + public static Role Admin + { + get + { + return new Role(1, "Admin"); + } + } + public static Role User + { + get + { + return new Role(2, "User"); + } + } +} diff --git a/CSR.Domain/Entities/User.cs b/CSR.Domain/Entities/User.cs new file mode 100644 index 0000000..2c03a22 --- /dev/null +++ b/CSR.Domain/Entities/User.cs @@ -0,0 +1,173 @@ +namespace CSR.Domain.Entities; + +using System.Net.Mail; +using CSR.Domain.Interfaces; + +public class User +{ + public int Id { get; private set; } + public string Username { get; private set; } + public string Email { get; private set; } + public string PasswordHash { get; private set; } + + public int RoleId { get; private set; } + public Role Role { get; private set; } + + private User(int id, string username, string email, string passwordHash, int roleId, Role role) + { + Id = id; + Username = username; + Email = email; + PasswordHash = passwordHash; + RoleId = roleId; + Role = role; + } + + // This role is used when a new user is created + public static readonly Role DefaultRole = Role.User; + + + internal static User LoadExisting(int id, string username, string email, string passwordHash, int roleId, Role role) + { + return new User(id, username, email, passwordHash, roleId, role); + } + + + public (bool Success, IEnumerable? Errors) UpdatePassword(string password, IPasswordHasher passwordHasher, User requestingUser) + { + if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin") + { + throw new UnauthorizedAccessException("Only admins or the same user can update passwords."); + } + + var validityCheck = IsValidPassword(password); + + if (validityCheck.IsValid) + { + PasswordHash = passwordHasher.HashPassword(password); + } + + return validityCheck; + } + + + public bool VerifyPasswordAgainstHash(string providedPassword, IPasswordHasher passwordHasher, User requestingUser) + { + if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin") + { + throw new UnauthorizedAccessException("Only admins or the same user can verify passwords."); + } + + return passwordHasher.VerifyHashedPassword(PasswordHash, providedPassword); + } + + + public static (bool IsValid, IEnumerable? Errors) IsValidPassword(string password) + { + var Errors = new List(); + if (password.Length < 8 || password.Length > 32) + { + Errors.Add("Password must be between 8 and 32 characters long."); + } + if (!password.Any(char.IsUpper)) + { + Errors.Add("Password must contain at least one uppercase letter."); + } + if (!password.Any(char.IsLower)) + { + Errors.Add("Password must contain at least one lowercase letter."); + } + if (!password.Any(char.IsDigit)) + { + Errors.Add("Password must contain at least one digit."); + } + if (Errors.Count > 0) + { + return (false, Errors); + } + return (true, null); + } + + + public (bool Success, IEnumerable? Errors) UpdateEmail(string email, User requestingUser) + { + if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin") + { + throw new UnauthorizedAccessException("Only admins or the same user can update email."); + } + + if (IsValidEmail(email)) + { + Email = email; + return (true, null); + } + return (false, new List { "Invalid email format." }); + } + + + public static bool IsValidEmail(string email) + { + try + { + var mailAddress = new MailAddress(email); + return true; + } + catch (FormatException) + { + return false; + } + } + + + public (bool Success, IEnumerable? Errors) UpdateUsername(string username, User requestingUser) + { + if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin") + { + throw new UnauthorizedAccessException("Only admins can update username."); + } + + var validityCheck = IsValidUsername(username); + if (validityCheck.IsValid) + { + Username = username; + } + + return validityCheck; + } + + + public static (bool IsValid, IEnumerable? Errors) IsValidUsername(string username) + { + var Errors = new List(); + if (!username.All(char.IsLetterOrDigit)) + { + Errors.Add("Username must be alphanumeric."); + } + if (string.IsNullOrWhiteSpace(username) || username.Length < 3) + { + Errors.Add("Username must be at least 3 characters long."); + } + if (username.Length > 32) + { + Errors.Add("Username must be less than 32 characters long."); + } + + if (Errors.Count > 0) + { + return (false, Errors); + } + + return (true, null); + } + + + public void UpdateRole(Role role, User requestingUser) + { + if (requestingUser.Role.Name != "Admin") + { + throw new UnauthorizedAccessException("Only admins can assign roles."); + } + Role = role; + RoleId = role.Id; + } +} diff --git a/CSR.Domain/Interfaces/IPasswordHasher.cs b/CSR.Domain/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..8f8d987 --- /dev/null +++ b/CSR.Domain/Interfaces/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace CSR.Domain.Interfaces; + +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyHashedPassword(string hashedPassword, string providedPassword); +} diff --git a/CSR.Domain/Role.cs b/CSR.Domain/Role.cs deleted file mode 100644 index b74e26f..0000000 --- a/CSR.Domain/Role.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CSR.Domain; - -public class Role -{ - -} diff --git a/CSR.Domain/User.cs b/CSR.Domain/User.cs deleted file mode 100644 index 9a3d2a6..0000000 --- a/CSR.Domain/User.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CSR.Domain; - -public class User -{ - -} diff --git a/CSR.Infrastructure/CSR.Infrastructure.csproj b/CSR.Infrastructure/CSR.Infrastructure.csproj index a77ba63..51234eb 100644 --- a/CSR.Infrastructure/CSR.Infrastructure.csproj +++ b/CSR.Infrastructure/CSR.Infrastructure.csproj @@ -2,6 +2,14 @@ + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/CSR.Infrastructure/Class1.cs b/CSR.Infrastructure/Class1.cs deleted file mode 100644 index b85d6c8..0000000 --- a/CSR.Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CSR.Infrastructure; - -public class Class1 -{ - -} diff --git a/CSR.Infrastructure/Persistence/CSRDbContext.cs b/CSR.Infrastructure/Persistence/CSRDbContext.cs new file mode 100644 index 0000000..fb7759c --- /dev/null +++ b/CSR.Infrastructure/Persistence/CSRDbContext.cs @@ -0,0 +1,37 @@ +namespace CSR.Infrastructure.Persistence; + +using Microsoft.EntityFrameworkCore; +using CSR.Infrastructure.Persistence.Configurations; + + +public class CSRDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users { get; set; } + public DbSet Roles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new UserConfiguration()); + modelBuilder.ApplyConfiguration(new RoleConfiguration()); + + modelBuilder.Entity() + .HasIndex(u => u.Username) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(u => u.Role) + .WithMany(r => r.Users) + .HasForeignKey(u => u.RoleId); + + // --- Seed data --- // + + var adminRole = new Role { Id = 1, Name = "Admin" }; + var userRole = new Role { Id = 2, Name = "User" }; + + modelBuilder.Entity() + .HasData(adminRole, userRole); + + + base.OnModelCreating(modelBuilder); + } +} diff --git a/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs b/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs new file mode 100644 index 0000000..c26bc0a --- /dev/null +++ b/CSR.Infrastructure/Persistence/Configurations/RoleConfiguration.cs @@ -0,0 +1,19 @@ +namespace CSR.Infrastructure.Persistence.Configurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + + +public class RoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(u => u.Id) + .HasColumnName("RoleId") + .IsRequired(); + + builder.Property(u => u.Name) + .HasColumnName("RoleName") + .IsRequired(); + } +} diff --git a/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..0b2c7fb --- /dev/null +++ b/CSR.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,15 @@ +namespace CSR.Infrastructure.Persistence.Configurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(u => u.PasswordHash) + .HasColumnName("Password") + .IsRequired(); + } +} diff --git a/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs b/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs new file mode 100644 index 0000000..6bdef81 --- /dev/null +++ b/CSR.Infrastructure/Persistence/Repositories/RoleRepository.cs @@ -0,0 +1,55 @@ +namespace Csr.Infrastructure.Persistence.Repositories; + +using CSR.Domain.Entities; +using CSR.Application.Interfaces; +using Microsoft.EntityFrameworkCore; + +public class RoleRepository(CSR.Infrastructure.Persistence.CSRDbContext context) : IRoleRepository +{ + private readonly CSR.Infrastructure.Persistence.CSRDbContext _context = context; + + public async Task GetByIdAsync(int id) + { + var roleEntity = await _context.Roles + .Include(r => r.Id) + .SingleOrDefaultAsync(r => r.Id == id); + + if (roleEntity == null) + { + return null; // No entity found, return null domain model + } + + var role = Role.LoadExisting( + roleEntity.Id, + roleEntity.Name + ); + + return role; + } + + + public async Task AddAsync(Role role) + { + var roleEntity = new CSR.Infrastructure.Persistence.Role + { + Id = role.Id, + Name = role.Name + }; + + _context.Roles.Add(roleEntity); + await _context.SaveChangesAsync(); + } + + + public async Task DeleteAsync(int id) + { + var roleEntity = new CSR.Infrastructure.Persistence.Role + { + Id = id, + Name = string.Empty + }; + + _context.Roles.Remove(roleEntity); + await _context.SaveChangesAsync(); + } +} diff --git a/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs b/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs new file mode 100644 index 0000000..16034e7 --- /dev/null +++ b/CSR.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -0,0 +1,105 @@ +namespace Csr.Infrastructure.Persistence.Repositories; + +using CSR.Domain.Entities; +using CSR.Application.Interfaces; +using Microsoft.EntityFrameworkCore; + +public class UserRepository(CSR.Infrastructure.Persistence.CSRDbContext context) : IUserRepository +{ + private readonly CSR.Infrastructure.Persistence.CSRDbContext _context = context; + + public async Task GetByIdAsync(int id) + { + var userEntity = await _context.Users + .Include(u => u.Role) + .SingleOrDefaultAsync(u => u.Id == id); + + if (userEntity == null) + { + return null; // No entity found, return null domain model + } + + var user = User.LoadExisting( + userEntity.Id, + userEntity.Username, + userEntity.Email, + userEntity.PasswordHash, + userEntity.RoleId, + Role.LoadExisting( + userEntity.Role.Id, + userEntity.Role.Name + ) + ); + + return user; + } + + + public async Task AddAsync(User user) + { + var userEntity = new CSR.Infrastructure.Persistence.User + { + Username = user.Username, + Email = user.Email, + PasswordHash = user.PasswordHash, + RoleId = user.RoleId, + Role = new CSR.Infrastructure.Persistence.Role + { + Id = user.Role.Id, + Name = user.Role.Name + } + }; + + _context.Users.Add(userEntity); + await _context.SaveChangesAsync(); + } + + + public async Task UpdateAsync(User user) + { + var userEntity = await _context.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Id == user.Id); + + if (userEntity == null) + { + // NOTE should I throw an exception here? + return; + } + + + userEntity.Id = user.Id; + userEntity.Username = user.Username; + userEntity.Email = user.Email; + userEntity.PasswordHash = user.PasswordHash; + userEntity.RoleId = user.RoleId; + userEntity.Role = new CSR.Infrastructure.Persistence.Role { Id = user.Role.Id, Name = user.Role.Name }; + + // Prevent EF from trying to update the Role entity + _context.Entry(userEntity.Role).State = EntityState.Unchanged; + + _context.Users.Update(userEntity); + await _context.SaveChangesAsync(); + } + + + public async Task DeleteAsync(int id) + { + var userEntity = new CSR.Infrastructure.Persistence.User + { + Id = id, + Username = string.Empty, + Email = string.Empty, + PasswordHash = string.Empty, + RoleId = 0, + Role = new CSR.Infrastructure.Persistence.Role + { + Id = 0, + Name = string.Empty + } + }; + + _context.Users.Remove(userEntity); + await _context.SaveChangesAsync(); + } +} diff --git a/CSR.Infrastructure/Persistence/Role.cs b/CSR.Infrastructure/Persistence/Role.cs new file mode 100644 index 0000000..91c8ea0 --- /dev/null +++ b/CSR.Infrastructure/Persistence/Role.cs @@ -0,0 +1,10 @@ +namespace CSR.Infrastructure.Persistence; + +public class Role +{ + public int Id { get; set; } + public required string Name { get; set; } + + // Navigation property + public ICollection Users { get; set; } = new HashSet(); +} diff --git a/CSR.Infrastructure/Persistence/User.cs b/CSR.Infrastructure/Persistence/User.cs new file mode 100644 index 0000000..1b6fc3e --- /dev/null +++ b/CSR.Infrastructure/Persistence/User.cs @@ -0,0 +1,17 @@ +namespace CSR.Infrastructure.Persistence; + +public class User +{ + public int Id { get; set; } + public required string Username { get; set; } + public required string Email { get; set; } + public required string PasswordHash { get; set; } + public required int RoleId { get; set; } + + // Navigation property + public Role Role { get; set; } = null!; + + // prevent direct instantiation + // use UserService to create a new user + internal User() { } +} diff --git a/CSR.WebUI/Program.cs b/CSR.WebUI/Program.cs index b580f44..887bf29 100644 --- a/CSR.WebUI/Program.cs +++ b/CSR.WebUI/Program.cs @@ -1,16 +1,57 @@ +using CSR.Infrastructure.Persistence; +using CSR.Application.Services; +using Microsoft.EntityFrameworkCore; + + var builder = WebApplication.CreateBuilder(args); +// Get configuration from appsettings.json, environment variables, and Docker secrets in that order +builder.Configuration + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .AddKeyPerFile("/run/secrets", optional: true); + // Add services to the container. builder.Services.AddRazorPages(); +// Set up connection to SQLite database +var dbPath = builder.Configuration["Database:Path"]; +if (string.IsNullOrEmpty(dbPath)) +{ + var folder = Environment.SpecialFolder.LocalApplicationData; + var path = Environment.GetFolderPath(folder); + dbPath = Path.Join(path, "csr.db"); +} + +builder.Services.AddDbContext(options => +{ + options.UseSqlite($"Data Source={dbPath}"); +}); + + var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + var config = services.GetRequiredService(); + + var context = services.GetRequiredService(); + var userService = services.GetRequiredService(); + + var adminUsername = config["Admin:Username"] ?? "admin"; + var adminEmail = config["Admin:Email"] ?? ""; + var adminPassword = config["Admin:Password"] ?? "password"; + + userService.CreateUser(adminUsername, adminEmail, adminPassword, true); +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/Error"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); + app.UseExceptionHandler("/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); } app.UseHttpsRedirection();