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

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

@ -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<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(
int TacticId,
int TacticId,
int UserId
);
);
[HttpPost("/user/share-tactic")]
[Authorize]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
}

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

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

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

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

@ -22,12 +22,11 @@ public class DbUserService(AppContext.AppContext context) : IUserService
public Task<IEnumerable<User>> ListUsers(int start, int count, string? nameNeedle = null)
{
IQueryable<UserEntity> 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<User> CreateUser(string username, string email, string password, string profilePicture,
bool isAdmin)
public async Task<User> 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<User?> 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<IEnumerable<Tactic>> GetSharedTacticsToUser(int userId)

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

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

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

@ -1,4 +1,5 @@
using AppContext.Entities;
using DbServices;
using Microsoft.EntityFrameworkCore;
using Model;
@ -24,15 +25,20 @@ public class StubAppContext(DbContextOptions<AppContext> options) : AppContext(o
var i = 0;
builder.Entity<UserEntity>()
.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<TacticEntity>()

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

@ -66,9 +66,9 @@ public class UsersControllerTest
var result = await controller.GetSharedTacticsToUser(2);
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();
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
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

Loading…
Cancel
Save