diff --git a/API/Auth/Authentication.cs b/API/Auth/Authentication.cs index 6165e51..f063e08 100644 --- a/API/Auth/Authentication.cs +++ b/API/Auth/Authentication.cs @@ -1,5 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; using Microsoft.IdentityModel.Tokens; namespace API.Auth; @@ -25,5 +27,4 @@ public static class Authentication return ("Bearer " + jwt, expirationDate); } - } \ No newline at end of file diff --git a/API/Controllers/AuthenticationController.cs b/API/Controllers/AuthenticationController.cs index 0717732..7ab047e 100644 --- a/API/Controllers/AuthenticationController.cs +++ b/API/Controllers/AuthenticationController.cs @@ -1,10 +1,10 @@ using System.ComponentModel.DataAnnotations; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Security.Cryptography; using System.Text; using API.Auth; using API.Validation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using Model; @@ -16,7 +16,15 @@ namespace API.Controllers; public class AuthenticationController(IUserService service, IConfiguration config) : ControllerBase { private readonly SymmetricSecurityKey _key = new(Encoding.UTF8.GetBytes(config["JWT:Key"]!)); - + + [HttpGet("/auth/keep-alive")] + [Authorize] + public void KeepAlive() + { + + } + + public record GenerateTokenRequest( [MaxLength(256, ErrorMessage = "Email address is too wide")] [EmailAddress] @@ -44,7 +52,8 @@ public class AuthenticationController(IUserService service, IConfiguration confi public record RegisterAccountRequest( - [MaxLength(256, ErrorMessage = "name is longer than 256")] + [StringLength(256, MinimumLength = 4, ErrorMessage = "password length must be between 4 and 256")] + [Name] string Username, [MaxLength(256, ErrorMessage = "email is longer than 256")] [EmailAddress] @@ -62,8 +71,7 @@ public class AuthenticationController(IUserService service, IConfiguration confi { "email", ["The email address already exists"] } }); } - - + var user = await service.CreateUser( req.Username, req.Email, @@ -90,4 +98,6 @@ public class AuthenticationController(IUserService service, IConfiguration confi return Authentication.GenerateJwt(_key, claims); } + + } \ No newline at end of file diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index ea73e3a..b6022f2 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,13 +1,15 @@ +using System.ComponentModel.DataAnnotations; using System.Runtime.CompilerServices; using API.Context; using API.DTO; -using AppContext.Entities; +using API.Validation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Model; using Services; [assembly: InternalsVisibleTo("UnitTests")] + namespace API.Controllers; [ApiController] @@ -36,19 +38,49 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ var userTactics = await tactics.ListTacticsOf(userId); return new GetUserDataResponse(userTeams.ToArray(), userTactics.Select(t => t.ToDto()).ToArray()); } + + public record ChangeUserInformationRequest( + [EmailAddress] string? Email = null, + [Name] string? Name = null, + [StringLength(1024)] string? ProfilePicture = null, + [StringLength(256, MinimumLength = 4, ErrorMessage = "password length must be between 4 and 256")] + string? Password = null + ); + + [HttpPut("/user")] + [Authorize] + public async Task ChangeUserInformation([FromBody] ChangeUserInformationRequest req) + { + var userId = accessor.CurrentUserId(HttpContext); + var currentUser = (await users.GetUser(userId))!; + await users.UpdateUser( + new User( + userId, + req.Name ?? currentUser.Name, + req.Email ?? currentUser.Email, + req.ProfilePicture ?? currentUser.ProfilePicture, + currentUser.IsAdmin + ), + req.Password + ); + + + return Ok(); + } + public record ShareTacticToUserRequest( - int TacticId, + int TacticId, int UserId - ); - + ); + [HttpPost("/user/share-tactic")] [Authorize] public async Task ShareTactic([FromBody] ShareTacticToUserRequest sharedTactic) { var currentUserId = accessor.CurrentUserId(HttpContext); var tactic = await tactics.GetTactic(sharedTactic.TacticId); - + if (tactic == null) { return NotFound(); @@ -62,7 +94,7 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ var result = await tactics.ShareTactic(sharedTactic.TacticId, sharedTactic.UserId, null); return result ? Ok() : NotFound(); } - + [HttpDelete("/tactics/shared/{tacticId:int}/user/{userId:int}")] [Authorize] public async Task UnshareTactic(int tacticId, int userId) @@ -74,6 +106,7 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ { return NotFound(); } + if (currentUserId != tactic.OwnerId) { return Unauthorized(); @@ -82,7 +115,7 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ var success = await tactics.UnshareTactic(tacticId, userId, null); return success ? Ok() : NotFound(); } - + [HttpGet("/tactics/shared/user/{userId:int}")] [Authorize] public async Task GetSharedTacticsToUser(int userId) @@ -94,6 +127,6 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ } var sharedTactics = await users.GetSharedTacticsToUser(userId); - return sharedTactics != null ? Ok(sharedTactics) : NotFound(); + return Ok(sharedTactics); } } \ No newline at end of file diff --git a/API/Validation/NameAttribute.cs b/API/Validation/NameAttribute.cs index f1d9c59..1a162a7 100644 --- a/API/Validation/NameAttribute.cs +++ b/API/Validation/NameAttribute.cs @@ -9,6 +9,9 @@ public partial class NameAttribute : ValidationAttribute { var name = context.DisplayName; + if (value is null) + return ValidationResult.Success; + if (value is not string str) { return new ValidationResult($"{name} should be a string."); diff --git a/AppContext/AppContext.cs b/AppContext/AppContext.cs index c6d2618..0b1ac00 100644 --- a/AppContext/AppContext.cs +++ b/AppContext/AppContext.cs @@ -46,26 +46,9 @@ public class AppContext : DbContext protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); - builder.Entity() - .Property(e => e.Password) - .HasConversion( - v => HashString(v), - v => v - ); - builder.Entity() .HasKey("UserId", "TeamId"); } - 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 daec449..efe5191 100644 --- a/AppContext/AppContext.csproj +++ b/AppContext/AppContext.csproj @@ -9,7 +9,6 @@ - all diff --git a/AppContext/Entities/UserEntity.cs b/AppContext/Entities/UserEntity.cs index a299397..df08189 100644 --- a/AppContext/Entities/UserEntity.cs +++ b/AppContext/Entities/UserEntity.cs @@ -7,6 +7,7 @@ public class UserEntity [Key] public int Id { get; set; } public required string Password { get; set; } + public required byte[] PasswordSalt { get; set; } public required string Name { get; set; } public required string Email { get; set; } public required string ProfilePicture { get; set; } diff --git a/DbServices/DbServices.csproj b/DbServices/DbServices.csproj index 2a2951f..1442b75 100644 --- a/DbServices/DbServices.csproj +++ b/DbServices/DbServices.csproj @@ -10,6 +10,7 @@ + diff --git a/DbServices/DbUserService.cs b/DbServices/DbUserService.cs index 8cdbac5..70fab02 100644 --- a/DbServices/DbUserService.cs +++ b/DbServices/DbUserService.cs @@ -22,12 +22,11 @@ public class DbUserService(AppContext.AppContext context) : IUserService public Task> ListUsers(int start, int count, string? nameNeedle = null) { - IQueryable request = context.Users; if (nameNeedle != null) request = request.Where(u => u.Name.ToLower().Contains(nameNeedle.ToLower())); - + return Task.FromResult( request .Skip(start) @@ -46,15 +45,23 @@ public class DbUserService(AppContext.AppContext context) : IUserService { return (await context.Users.FirstOrDefaultAsync(e => e.Email == email))?.ToModel(); } - - public async Task CreateUser(string username, string email, string password, string profilePicture, - bool isAdmin) + + public async Task CreateUser( + string username, + string email, + string password, + string profilePicture, + bool isAdmin + ) { + var (passwordHash, salt) = Hashing.HashString(password); + var userEntity = new UserEntity { Name = username, Email = email, - Password = password, + Password = passwordHash, + PasswordSalt = salt, ProfilePicture = profilePicture, IsAdmin = isAdmin }; @@ -73,27 +80,44 @@ public class DbUserService(AppContext.AppContext context) : IUserService .ExecuteDeleteAsync() > 0; } - public async Task UpdateUser(User user) + public async Task UpdateUser(User user, string? password = null) { var entity = await context.Users.FirstOrDefaultAsync(e => e.Id == user.Id); if (entity == null) throw new ServiceException(Failure.NotFound("User not found")); + var emailEntity = await context.Users.FirstOrDefaultAsync(e => e.Email == user.Email); + if (emailEntity != null && emailEntity.Id != entity.Id) + { + throw new ServiceException(new Failure("email conflict", "this provided email is used by another account")); + } + entity.ProfilePicture = user.ProfilePicture; entity.Name = user.Name; entity.Email = user.Email; entity.Id = user.Id; entity.IsAdmin = user.IsAdmin; - + + if (password != null) + { + var (passwordHash, salt) = Hashing.HashString(password); + entity.Password = passwordHash; + entity.PasswordSalt = salt; + } + await context.SaveChangesAsync(); } public async Task Authorize(string email, string password) { - return (await context - .Users - .FirstOrDefaultAsync(u => u.Email == email)) - ?.ToModel(); + var entity = await context + .Users + .FirstOrDefaultAsync(u => u.Email == email); + + if (entity == null) + return null; + + return Hashing.PasswordsMatches(entity.Password, password, entity.PasswordSalt) ? entity.ToModel() : null; } public async Task> GetSharedTacticsToUser(int userId) diff --git a/DbServices/Security.cs b/DbServices/Security.cs index 7f24cd8..e636579 100644 --- a/DbServices/Security.cs +++ b/DbServices/Security.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + namespace DbServices; public class Security diff --git a/EFConsole/UsersConsole.cs b/EFConsole/UsersConsole.cs index d96aaa6..ed1d80b 100644 --- a/EFConsole/UsersConsole.cs +++ b/EFConsole/UsersConsole.cs @@ -12,6 +12,7 @@ class UsersConsole Name = "Pierre", Email = "pierre@mail.com", Password = "123456", + PasswordSalt = [1], ProfilePicture = "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png", IsAdmin = false }; diff --git a/Services/UserService.cs b/Services/UserService.cs index b81a507..34439dc 100644 --- a/Services/UserService.cs +++ b/Services/UserService.cs @@ -45,7 +45,7 @@ public interface IUserService /// /// Updates an existing user. /// - Task UpdateUser(User user); + Task UpdateUser(User user, string? password = null); public Task> GetSharedTacticsToUser(int userId); diff --git a/StubContext/StubAppContext.cs b/StubContext/StubAppContext.cs index a53ba0a..498fd78 100644 --- a/StubContext/StubAppContext.cs +++ b/StubContext/StubAppContext.cs @@ -1,4 +1,5 @@ using AppContext.Entities; +using DbServices; using Microsoft.EntityFrameworkCore; using Model; @@ -24,15 +25,20 @@ public class StubAppContext(DbContextOptions options) : AppContext(o var i = 0; builder.Entity() - .HasData(users.ConvertAll(name => new UserEntity + .HasData(users.ConvertAll(name => { - 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", + var (password, salt) = Hashing.HashString("123456"); + return new UserEntity + { + Id = ++i, + Email = $"{name}@mail.com", + Name = name, + Password = password, + PasswordSalt = salt, + IsAdmin = true, + ProfilePicture = + "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png", + }; })); builder.Entity() diff --git a/StubContext/StubContext.csproj b/StubContext/StubContext.csproj index 4a83198..f60dcbf 100644 --- a/StubContext/StubContext.csproj +++ b/StubContext/StubContext.csproj @@ -9,6 +9,7 @@ + diff --git a/UnitTests/UserControllerTest.cs b/UnitTests/UserControllerTest.cs index 03019cf..cedbad8 100644 --- a/UnitTests/UserControllerTest.cs +++ b/UnitTests/UserControllerTest.cs @@ -66,9 +66,9 @@ public class UsersControllerTest var result = await controller.GetSharedTacticsToUser(2); var okResult = result as OkObjectResult; - var sharedTactics = okResult.Value as IEnumerable; + var sharedTactics = okResult!.Value as IEnumerable; - sharedTactics.Should().NotBeNull(); + sharedTactics!.Should().NotBeNull(); sharedTactics.Should().ContainSingle(); var tactic = sharedTactics.First(); diff --git a/Utils/Hashing.cs b/Utils/Hashing.cs new file mode 100644 index 0000000..9d0420c --- /dev/null +++ b/Utils/Hashing.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + +namespace DbServices; + +public class Hashing +{ + public static (string, byte[]) HashString(string str) + { + byte[] salt = RandomNumberGenerator.GetBytes(128 / 8); + string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: str, + salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 50000, + numBytesRequested: 256 / 8 + )); + + return (hashed, salt); + } + + public static bool PasswordsMatches(string password, string str, byte[] salt) + { + string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: str, + salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 50000, + numBytesRequested: 256 / 8 + )); + + return hashed == password; + } + +} \ No newline at end of file diff --git a/Utils/Utils.csproj b/Utils/Utils.csproj new file mode 100644 index 0000000..eda5cd8 --- /dev/null +++ b/Utils/Utils.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/WebAPI.sln b/WebAPI.sln index a6888fe..33e5119 100644 --- a/WebAPI.sln +++ b/WebAPI.sln @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFConsole", "EFConsole\EFCo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "APIConsole", "APIConsole\APIConsole.csproj", "{B01BD72E-15D3-4DC6-8DAC-2270A01129A9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utils", "Utils\Utils.csproj", "{D6FC4ED1-B4F8-4801-BC79-94627A1E6E0F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -74,5 +76,9 @@ Global {B01BD72E-15D3-4DC6-8DAC-2270A01129A9}.Debug|Any CPU.Build.0 = Debug|Any CPU {B01BD72E-15D3-4DC6-8DAC-2270A01129A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {B01BD72E-15D3-4DC6-8DAC-2270A01129A9}.Release|Any CPU.Build.0 = Release|Any CPU + {D6FC4ED1-B4F8-4801-BC79-94627A1E6E0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6FC4ED1-B4F8-4801-BC79-94627A1E6E0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6FC4ED1-B4F8-4801-BC79-94627A1E6E0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6FC4ED1-B4F8-4801-BC79-94627A1E6E0F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal