From 7bf6f9c5ec836a675c2fcc43920de0f7cfbc4904 Mon Sep 17 00:00:00 2001 From: maxime Date: Mon, 12 Feb 2024 11:49:35 +0100 Subject: [PATCH] add user service and accounts controller --- API/API.csproj | 4 ++ API/Controllers/AccountsController.cs | 83 +++++++++++++++++++++++++++ AppContext/AppContext.cs | 27 ++++++++- AppContext/AppContext.csproj | 1 + AppContext/Entities/UserEntity.cs | 9 +-- Converters/Converters.csproj | 14 +++++ Converters/ModelToEntities.cs | 17 ++++++ DbServices/DbServices.csproj | 15 +++++ DbServices/DbUserService.cs | 72 +++++++++++++++++++++++ Model/User.cs | 3 + Services/Failures/Failure.cs | 9 +++ Services/ServiceException.cs | 13 +++++ Services/Services.csproj | 13 +++++ Services/UserService.cs | 19 ++++++ StubContext/StubAppContext.cs | 2 + WebAPI.sln | 18 ++++++ 16 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 API/Controllers/AccountsController.cs create mode 100644 Converters/Converters.csproj create mode 100644 Converters/ModelToEntities.cs create mode 100644 DbServices/DbServices.csproj create mode 100644 DbServices/DbUserService.cs create mode 100644 Model/User.cs create mode 100644 Services/Failures/Failure.cs create mode 100644 Services/ServiceException.cs create mode 100644 Services/Services.csproj create mode 100644 Services/UserService.cs diff --git a/API/API.csproj b/API/API.csproj index 78fcbf9..dbebf35 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -19,4 +19,8 @@ + + + + diff --git a/API/Controllers/AccountsController.cs b/API/Controllers/AccountsController.cs new file mode 100644 index 0000000..ae97ad3 --- /dev/null +++ b/API/Controllers/AccountsController.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Model; +using Services; + +namespace API.Controllers; + +public class AccountsController(UserService service) : ControllerBase +{ + private static string _defaultProfilePicture = + "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"; + + [HttpGet("/admin/list-users")] + public async Task> ListUsers( + [Range(0, int.MaxValue, ErrorMessage = "Only positive number allowed")] + int start, + [Range(0, int.MaxValue, ErrorMessage = "Only positive number allowed")] + int n, + [MaxLength(256, ErrorMessage = "Search string is too wide")] + string? search + ) + { + var result = search == null + ? await service.ListUsers(search!) + : await service.ListUsers(); + + return result.Skip(start).Take(n); + } + + [HttpGet("/admin/user/{id:int}")] + public async Task GetUser( + [Range(0, int.MaxValue, ErrorMessage = "Only positive number allowed")] + int id + ) + { + var result = await service.GetUser(id); + if (result == null) + return NotFound(); + + return Ok(result); + } + + [HttpPost("/admin/user")] + public Task AddUser( + [MaxLength(256, ErrorMessage = "Username is too wide")] + string username, + [Range(4, 256, ErrorMessage = "Password must length be between 4 and 256")] + string password, + [MaxLength(256, ErrorMessage = "Email is too wide")] [EmailAddress] + string email, + bool isAdmin = false + ) + { + return service.CreateUser(username, email, password, _defaultProfilePicture, isAdmin); + } + + [HttpDelete("/admin/user")] + public async void RemoveUsers(int[] identifiers) + { + await service.RemoveUsers(identifiers); + } + + [HttpPut("/admin/user/{id:int}")] + public async Task UpdateUser( + int id, + [MaxLength(256, ErrorMessage = "Username is too wide")] + string username, + [MaxLength(256, ErrorMessage = "Email is too wide")] [EmailAddress] + string email, + bool isAdmin + ) + { + try + { + await service.UpdateUser(new User(id, username, email, _defaultProfilePicture, isAdmin)); + return Ok(); + } + catch (ServiceException e) + { + return BadRequest(e.Failures); + } + } +} \ No newline at end of file diff --git a/AppContext/AppContext.cs b/AppContext/AppContext.cs index d71f778..68a8c7c 100644 --- a/AppContext/AppContext.cs +++ b/AppContext/AppContext.cs @@ -1,4 +1,6 @@ -using AppContext.Entities; +using System.Security.Cryptography; +using AppContext.Entities; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; using Microsoft.EntityFrameworkCore; namespace AppContext; @@ -17,4 +19,27 @@ public class AppContext(DbContextOptions options) : DbContext(option ) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.Entity() + .Property(e => e.Password) + .HasConversion( + v => HashString(v), + v => v + ); + } + + private static string HashString(string str) + { + byte[] salt = RandomNumberGenerator.GetBytes(128 / 8); + return Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: str, + salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 50000, + numBytesRequested: 256 / 8 + )); + } } \ No newline at end of file diff --git a/AppContext/AppContext.csproj b/AppContext/AppContext.csproj index 10896a1..22d9aba 100644 --- a/AppContext/AppContext.csproj +++ b/AppContext/AppContext.csproj @@ -8,6 +8,7 @@ + all diff --git a/AppContext/Entities/UserEntity.cs b/AppContext/Entities/UserEntity.cs index ac9b27e..a299397 100644 --- a/AppContext/Entities/UserEntity.cs +++ b/AppContext/Entities/UserEntity.cs @@ -4,12 +4,13 @@ namespace AppContext.Entities; public class UserEntity { - [Key] - public int Id { get; set; } + [Key] public int Id { get; set; } + + public required string Password { get; set; } public required string Name { get; set; } public required string Email { get; set; } public required string ProfilePicture { get; set; } - + public required bool IsAdmin { get; set; } + public ICollection Tactics { get; set; } = new List(); - } \ No newline at end of file diff --git a/Converters/Converters.csproj b/Converters/Converters.csproj new file mode 100644 index 0000000..1e27bad --- /dev/null +++ b/Converters/Converters.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/Converters/ModelToEntities.cs b/Converters/ModelToEntities.cs new file mode 100644 index 0000000..bbf17b2 --- /dev/null +++ b/Converters/ModelToEntities.cs @@ -0,0 +1,17 @@ +using AppContext.Entities; +using Model; + +namespace Converters; + +public static class EntitiesToModels +{ + public static User ToModel(this UserEntity entity) + { + return new User(entity.Id, entity.Name, entity.Email, entity.ProfilePicture, entity.IsAdmin); + } + + // public static Team ToModel(this TeamEntity entity) + // { + // + // } +} \ No newline at end of file diff --git a/DbServices/DbServices.csproj b/DbServices/DbServices.csproj new file mode 100644 index 0000000..2a2951f --- /dev/null +++ b/DbServices/DbServices.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/DbServices/DbUserService.cs b/DbServices/DbUserService.cs new file mode 100644 index 0000000..c80544c --- /dev/null +++ b/DbServices/DbUserService.cs @@ -0,0 +1,72 @@ +using AppContext.Entities; +using Converters; +using Microsoft.EntityFrameworkCore; +using Model; +using Services; +using Services.Failures; + + +namespace DbServices; + +public class DbUserService(AppContext.AppContext context) : UserService +{ + public Task> ListUsers(string nameNeedle) + { + return Task.FromResult( + context.Users + .Where(n => n.Name.ToLower().Contains(nameNeedle.ToLower())) + .AsEnumerable() + .Select(e => e.ToModel()) + ); + } + + public Task> ListUsers() + { + return Task.FromResult( + context.Users + .AsEnumerable() + .Select(e => e.ToModel()) + ); + } + + public async Task GetUser(int id) + { + return (await context.Users.FirstOrDefaultAsync(e => e.Id == id))?.ToModel(); + } + + public async Task CreateUser(string username, string email, string password, string profilePicture, + bool isAdmin) + { + var userEntity = new UserEntity + { + Name = username, + Email = email, + Password = password, + ProfilePicture = profilePicture, + IsAdmin = isAdmin + }; + + await context.Users.AddAsync(userEntity); + await context.SaveChangesAsync(); + + return userEntity.ToModel(); + } + + public async Task RemoveUsers(params int[] identifiers) + { + return await context.Users.Where(u => identifiers.Contains(u.Id)).ExecuteDeleteAsync() > 0; + } + + public async Task UpdateUser(User user) + { + var entity = await context.Users.FirstOrDefaultAsync(e => e.Id == user.Id); + if (entity == null) + throw new ServiceException(Failure.NotFound("User not found")); + + entity.ProfilePicture = user.ProfilePicture; + entity.Name = user.Name; + entity.Email = user.Email; + entity.Id = user.Id; + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Model/User.cs b/Model/User.cs new file mode 100644 index 0000000..db75ffc --- /dev/null +++ b/Model/User.cs @@ -0,0 +1,3 @@ +namespace Model; + +public record User(int Id, string Name, string Email, string ProfilePicture, bool IsAdmin); \ No newline at end of file diff --git a/Services/Failures/Failure.cs b/Services/Failures/Failure.cs new file mode 100644 index 0000000..0d3a150 --- /dev/null +++ b/Services/Failures/Failure.cs @@ -0,0 +1,9 @@ +namespace Services.Failures; + +public record Failure(string Name, string Message) +{ + public static Failure NotFound(string message) + { + return new("not found", message); + } +} \ No newline at end of file diff --git a/Services/ServiceException.cs b/Services/ServiceException.cs new file mode 100644 index 0000000..0551c27 --- /dev/null +++ b/Services/ServiceException.cs @@ -0,0 +1,13 @@ +using Services.Failures; + +namespace Services; + +public class ServiceException : Exception +{ + public List Failures { get; init; } + + public ServiceException(params Failure[] failures) + { + Failures = new List(failures); + } +} \ No newline at end of file diff --git a/Services/Services.csproj b/Services/Services.csproj new file mode 100644 index 0000000..b0ff0a6 --- /dev/null +++ b/Services/Services.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/Services/UserService.cs b/Services/UserService.cs new file mode 100644 index 0000000..3e30634 --- /dev/null +++ b/Services/UserService.cs @@ -0,0 +1,19 @@ +using Model; + +namespace Services; + +public interface UserService +{ + + Task> ListUsers(string nameNeedle); + Task> ListUsers(); + + Task GetUser(int id); + + Task CreateUser(string username, string email, string password, string profilePicture, bool isAdmin); + + Task RemoveUsers(params int[] identifiers); + + Task UpdateUser(User user); + +} \ No newline at end of file diff --git a/StubContext/StubAppContext.cs b/StubContext/StubAppContext.cs index af08652..bb00f2c 100644 --- a/StubContext/StubAppContext.cs +++ b/StubContext/StubAppContext.cs @@ -28,6 +28,8 @@ public class StubAppContext(DbContextOptions options) : AppContext(o Id = ++i, Email = $"{name}@mail.com", Name = name, + Password = "123456", + IsAdmin = true, ProfilePicture = "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png", })); diff --git a/WebAPI.sln b/WebAPI.sln index 62a55a0..4cb00ad 100644 --- a/WebAPI.sln +++ b/WebAPI.sln @@ -10,6 +10,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StubContext", "StubContext\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{B22FA426-EFF2-42E9-96BB-78F1C65E37CC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Services", "Services\Services.csproj", "{5C342359-9DE6-4B47-9DD9-4F519B58448B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbServices", "DbServices\DbServices.csproj", "{EBBF55CF-97CA-4E7D-8603-5FF546093B95}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Converters", "Converters\Converters.csproj", "{465819A9-7158-4612-AC57-ED2C7A0F243E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +42,17 @@ Global {B22FA426-EFF2-42E9-96BB-78F1C65E37CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {B22FA426-EFF2-42E9-96BB-78F1C65E37CC}.Release|Any CPU.ActiveCfg = Release|Any CPU {B22FA426-EFF2-42E9-96BB-78F1C65E37CC}.Release|Any CPU.Build.0 = Release|Any CPU + {5C342359-9DE6-4B47-9DD9-4F519B58448B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C342359-9DE6-4B47-9DD9-4F519B58448B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C342359-9DE6-4B47-9DD9-4F519B58448B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C342359-9DE6-4B47-9DD9-4F519B58448B}.Release|Any CPU.Build.0 = Release|Any CPU + {EBBF55CF-97CA-4E7D-8603-5FF546093B95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBBF55CF-97CA-4E7D-8603-5FF546093B95}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBBF55CF-97CA-4E7D-8603-5FF546093B95}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBBF55CF-97CA-4E7D-8603-5FF546093B95}.Release|Any CPU.Build.0 = Release|Any CPU + {465819A9-7158-4612-AC57-ED2C7A0F243E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {465819A9-7158-4612-AC57-ED2C7A0F243E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {465819A9-7158-4612-AC57-ED2C7A0F243E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {465819A9-7158-4612-AC57-ED2C7A0F243E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal