add profile edition route and a keep alive route
continuous-integration/drone/push Build is passing Details

shared-tactic
maxime 1 year ago
parent 7714126252
commit aab1eb74a2

@ -1,5 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace API.Auth; namespace API.Auth;
@ -25,5 +27,4 @@ public static class Authentication
return ("Bearer " + jwt, expirationDate); return ("Bearer " + jwt, expirationDate);
} }
} }

@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text; using System.Text;
using API.Auth; using API.Auth;
using API.Validation; using API.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Model; using Model;
@ -17,6 +17,14 @@ public class AuthenticationController(IUserService service, IConfiguration confi
{ {
private readonly SymmetricSecurityKey _key = new(Encoding.UTF8.GetBytes(config["JWT:Key"]!)); private readonly SymmetricSecurityKey _key = new(Encoding.UTF8.GetBytes(config["JWT:Key"]!));
[HttpGet("/auth/keep-alive")]
[Authorize]
public void KeepAlive()
{
}
public record GenerateTokenRequest( public record GenerateTokenRequest(
[MaxLength(256, ErrorMessage = "Email address is too wide")] [MaxLength(256, ErrorMessage = "Email address is too wide")]
[EmailAddress] [EmailAddress]
@ -44,7 +52,8 @@ public class AuthenticationController(IUserService service, IConfiguration confi
public record RegisterAccountRequest( 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, string Username,
[MaxLength(256, ErrorMessage = "email is longer than 256")] [MaxLength(256, ErrorMessage = "email is longer than 256")]
[EmailAddress] [EmailAddress]
@ -63,7 +72,6 @@ public class AuthenticationController(IUserService service, IConfiguration confi
}); });
} }
var user = await service.CreateUser( var user = await service.CreateUser(
req.Username, req.Username,
req.Email, req.Email,
@ -90,4 +98,6 @@ public class AuthenticationController(IUserService service, IConfiguration confi
return Authentication.GenerateJwt(_key, claims); return Authentication.GenerateJwt(_key, claims);
} }
} }

@ -1,13 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using API.Context; using API.Context;
using API.DTO; using API.DTO;
using AppContext.Entities; using API.Validation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Model; using Model;
using Services; using Services;
[assembly: InternalsVisibleTo("UnitTests")] [assembly: InternalsVisibleTo("UnitTests")]
namespace API.Controllers; namespace API.Controllers;
[ApiController] [ApiController]
@ -37,10 +39,40 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ
return new GetUserDataResponse(userTeams.ToArray(), userTactics.Select(t => t.ToDto()).ToArray()); 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<IActionResult> 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( public record ShareTacticToUserRequest(
int TacticId, int TacticId,
int UserId int UserId
); );
[HttpPost("/user/share-tactic")] [HttpPost("/user/share-tactic")]
[Authorize] [Authorize]
@ -74,6 +106,7 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ
{ {
return NotFound(); return NotFound();
} }
if (currentUserId != tactic.OwnerId) if (currentUserId != tactic.OwnerId)
{ {
return Unauthorized(); return Unauthorized();
@ -94,6 +127,6 @@ public class UsersController(IUserService users, ITeamService teams, ITacticServ
} }
var sharedTactics = await users.GetSharedTacticsToUser(userId); var sharedTactics = await users.GetSharedTacticsToUser(userId);
return sharedTactics != null ? Ok(sharedTactics) : NotFound(); return Ok(sharedTactics);
} }
} }

@ -9,6 +9,9 @@ public partial class NameAttribute : ValidationAttribute
{ {
var name = context.DisplayName; var name = context.DisplayName;
if (value is null)
return ValidationResult.Success;
if (value is not string str) if (value is not string str)
{ {
return new ValidationResult($"{name} should be a string."); return new ValidationResult($"{name} should be a string.");

@ -46,26 +46,9 @@ public class AppContext : DbContext
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
builder.Entity<UserEntity>()
.Property(e => e.Password)
.HasConversion(
v => HashString(v),
v => v
);
builder.Entity<MemberEntity>() builder.Entity<MemberEntity>()
.HasKey("UserId", "TeamId"); .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
));
}
} }

@ -9,7 +9,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

@ -7,6 +7,7 @@ public class UserEntity
[Key] public int Id { get; set; } [Key] public int Id { get; set; }
public required string Password { get; set; } public required string Password { get; set; }
public required byte[] PasswordSalt { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public required string Email { get; set; } public required string Email { get; set; }
public required string ProfilePicture { get; set; } public required string ProfilePicture { get; set; }

@ -10,6 +10,7 @@
<ProjectReference Include="..\AppContext\AppContext.csproj" /> <ProjectReference Include="..\AppContext\AppContext.csproj" />
<ProjectReference Include="..\Converters\Converters.csproj" /> <ProjectReference Include="..\Converters\Converters.csproj" />
<ProjectReference Include="..\Services\Services.csproj" /> <ProjectReference Include="..\Services\Services.csproj" />
<ProjectReference Include="..\Utils\Utils.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

@ -22,7 +22,6 @@ public class DbUserService(AppContext.AppContext context) : IUserService
public Task<IEnumerable<User>> ListUsers(int start, int count, string? nameNeedle = null) public Task<IEnumerable<User>> ListUsers(int start, int count, string? nameNeedle = null)
{ {
IQueryable<UserEntity> request = context.Users; IQueryable<UserEntity> request = context.Users;
if (nameNeedle != null) if (nameNeedle != null)
@ -47,14 +46,22 @@ public class DbUserService(AppContext.AppContext context) : IUserService
return (await context.Users.FirstOrDefaultAsync(e => e.Email == email))?.ToModel(); return (await context.Users.FirstOrDefaultAsync(e => e.Email == email))?.ToModel();
} }
public async Task<User> CreateUser(string username, string email, string password, string profilePicture, public async Task<User> CreateUser(
bool isAdmin) string username,
string email,
string password,
string profilePicture,
bool isAdmin
)
{ {
var (passwordHash, salt) = Hashing.HashString(password);
var userEntity = new UserEntity var userEntity = new UserEntity
{ {
Name = username, Name = username,
Email = email, Email = email,
Password = password, Password = passwordHash,
PasswordSalt = salt,
ProfilePicture = profilePicture, ProfilePicture = profilePicture,
IsAdmin = isAdmin IsAdmin = isAdmin
}; };
@ -73,27 +80,44 @@ public class DbUserService(AppContext.AppContext context) : IUserService
.ExecuteDeleteAsync() > 0; .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); var entity = await context.Users.FirstOrDefaultAsync(e => e.Id == user.Id);
if (entity == null) if (entity == null)
throw new ServiceException(Failure.NotFound("User not found")); 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.ProfilePicture = user.ProfilePicture;
entity.Name = user.Name; entity.Name = user.Name;
entity.Email = user.Email; entity.Email = user.Email;
entity.Id = user.Id; entity.Id = user.Id;
entity.IsAdmin = user.IsAdmin; entity.IsAdmin = user.IsAdmin;
if (password != null)
{
var (passwordHash, salt) = Hashing.HashString(password);
entity.Password = passwordHash;
entity.PasswordSalt = salt;
}
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public async Task<User?> Authorize(string email, string password) public async Task<User?> Authorize(string email, string password)
{ {
return (await context var entity = await context
.Users .Users
.FirstOrDefaultAsync(u => u.Email == email)) .FirstOrDefaultAsync(u => u.Email == email);
?.ToModel();
if (entity == null)
return null;
return Hashing.PasswordsMatches(entity.Password, password, entity.PasswordSalt) ? entity.ToModel() : null;
} }
public async Task<IEnumerable<Tactic>> GetSharedTacticsToUser(int userId) public async Task<IEnumerable<Tactic>> GetSharedTacticsToUser(int userId)

@ -1,3 +1,6 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace DbServices; namespace DbServices;
public class Security public class Security

@ -12,6 +12,7 @@ class UsersConsole
Name = "Pierre", Name = "Pierre",
Email = "pierre@mail.com", Email = "pierre@mail.com",
Password = "123456", Password = "123456",
PasswordSalt = [1],
ProfilePicture = "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png", ProfilePicture = "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png",
IsAdmin = false IsAdmin = false
}; };

@ -45,7 +45,7 @@ public interface IUserService
/// <summary> /// <summary>
/// Updates an existing user. /// Updates an existing user.
/// </summary> /// </summary>
Task UpdateUser(User user); Task UpdateUser(User user, string? password = null);
public Task<IEnumerable<Tactic>> GetSharedTacticsToUser(int userId); public Task<IEnumerable<Tactic>> GetSharedTacticsToUser(int userId);

@ -1,4 +1,5 @@
using AppContext.Entities; using AppContext.Entities;
using DbServices;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Model; using Model;
@ -24,15 +25,20 @@ public class StubAppContext(DbContextOptions<AppContext> options) : AppContext(o
var i = 0; var i = 0;
builder.Entity<UserEntity>() builder.Entity<UserEntity>()
.HasData(users.ConvertAll(name => new UserEntity .HasData(users.ConvertAll(name =>
{ {
Id = ++i, var (password, salt) = Hashing.HashString("123456");
Email = $"{name}@mail.com", return new UserEntity
Name = name, {
Password = "123456", Id = ++i,
IsAdmin = true, Email = $"{name}@mail.com",
ProfilePicture = Name = name,
"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png", 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<TacticEntity>() builder.Entity<TacticEntity>()

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AppContext\AppContext.csproj" /> <ProjectReference Include="..\AppContext\AppContext.csproj" />
<ProjectReference Include="..\Utils\Utils.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -66,9 +66,9 @@ public class UsersControllerTest
var result = await controller.GetSharedTacticsToUser(2); var result = await controller.GetSharedTacticsToUser(2);
var okResult = result as OkObjectResult; var okResult = result as OkObjectResult;
var sharedTactics = okResult.Value as IEnumerable<Tactic>; var sharedTactics = okResult!.Value as IEnumerable<Tactic>;
sharedTactics.Should().NotBeNull(); sharedTactics!.Should().NotBeNull();
sharedTactics.Should().ContainSingle(); sharedTactics.Should().ContainSingle();
var tactic = sharedTactics.First(); var tactic = sharedTactics.First();

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

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.0-preview.2.24128.4" />
</ItemGroup>
</Project>

@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFConsole", "EFConsole\EFCo
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "APIConsole", "APIConsole\APIConsole.csproj", "{B01BD72E-15D3-4DC6-8DAC-2270A01129A9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "APIConsole", "APIConsole\APIConsole.csproj", "{B01BD72E-15D3-4DC6-8DAC-2270A01129A9}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utils", "Utils\Utils.csproj", "{D6FC4ED1-B4F8-4801-BC79-94627A1E6E0F}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{B01BD72E-15D3-4DC6-8DAC-2270A01129A9}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal

Loading…
Cancel
Save