diff --git a/API/Auth/Authentication.cs b/API/Auth/Authentication.cs new file mode 100644 index 0000000..adad050 --- /dev/null +++ b/API/Auth/Authentication.cs @@ -0,0 +1,30 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using Microsoft.IdentityModel.Tokens; + +namespace API.Auth; + +public static class Authentication +{ + private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(50); + + public static (string, DateTime) GenerateJwt(SymmetricSecurityKey key, IEnumerable claims) + { + var expirationDate = DateTime.UtcNow.Add(TokenLifetime); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = expirationDate, + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256) + }; + var tokenHandler = new JwtSecurityTokenHandler(); + + var token = tokenHandler.CreateToken(tokenDescriptor); + + var jwt = tokenHandler.WriteToken(token); + return ("Bearer " + jwt, expirationDate); + } + + +} \ No newline at end of file diff --git a/API/Controllers/AuthenticationController.cs b/API/Controllers/AuthenticationController.cs index 2af78e1..584a869 100644 --- a/API/Controllers/AuthenticationController.cs +++ b/API/Controllers/AuthenticationController.cs @@ -3,12 +3,12 @@ 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.Mvc; using Microsoft.IdentityModel.Tokens; using Model; using Services; -using Services.Failures; namespace API.Controllers; @@ -16,8 +16,6 @@ namespace API.Controllers; public class AuthenticationController(IUserService service, IConfiguration config) : ControllerBase { private readonly SymmetricSecurityKey _key = new(Encoding.UTF8.GetBytes(config["JWT:Key"]!)); - private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(50); - public record GenerateTokenRequest( [MaxLength(256, ErrorMessage = "Email address is too wide")] @@ -64,8 +62,15 @@ public class AuthenticationController(IUserService service, IConfiguration confi }); } - var user = await service.CreateUser(req.Username, req.Email, req.Password, Constants.DefaultProfilePicture, - false); + + var user = await service.CreateUser( + req.Username, + req.Email, + req.Password, + Constants.DefaultProfilePicture, + false + ); + var (jwt, expirationDate) = GenerateJwt(user); return Ok(new AuthenticationResponse(jwt, expirationDate)); } @@ -73,7 +78,6 @@ public class AuthenticationController(IUserService service, IConfiguration confi private (string, DateTime) GenerateJwt(User user) { - var tokenHandler = new JwtSecurityTokenHandler(); var claims = new List { new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), @@ -83,17 +87,6 @@ public class AuthenticationController(IUserService service, IConfiguration confi new(IdentityData.AdminUserClaimName, user.IsAdmin.ToString()) }; - var expirationDate = DateTime.UtcNow.Add(TokenLifetime); - var tokenDescriptor = new SecurityTokenDescriptor - { - Subject = new ClaimsIdentity(claims), - Expires = expirationDate, - SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256) - }; - - var token = tokenHandler.CreateToken(tokenDescriptor); - - var jwt = tokenHandler.WriteToken(token); - return ("Bearer " + jwt, expirationDate); + return Authentication.GenerateJwt(_key, claims); } } \ No newline at end of file diff --git a/API/Controllers/TacticController.cs b/API/Controllers/TacticController.cs index 3849adb..f350947 100644 --- a/API/Controllers/TacticController.cs +++ b/API/Controllers/TacticController.cs @@ -1,7 +1,10 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using API.DTO; using API.Validation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Model; using Services; namespace API.Controllers; @@ -9,24 +12,101 @@ namespace API.Controllers; [ApiController] public class TacticController(ITacticService service) : ControllerBase { - [HttpPut("/tactic/{tacticId:int}/name")] + public record UpdateNameRequest( + [StringLength(50, MinimumLength = 1)] + [Name] + string Name); + + [HttpPut("/tactics/{tacticId:int}/name")] [Authorize] public async Task UpdateName( int tacticId, - [Range(1, 50)] [Name] string name) + [FromBody] UpdateNameRequest req) + { + var userId = this.CurrentUserId(); + if (!await service.HasAnyRights(userId, tacticId)) + { + return Unauthorized(); + } + + var result = await service.UpdateName(tacticId, req.Name); + + return result ? Ok() : NotFound(); + } + + [HttpGet("/tactics/{tacticId:int}")] + [Authorize] + public async Task GetTacticInfo(int tacticId) { var userId = this.CurrentUserId(); if (!await service.HasAnyRights(userId, tacticId)) { - return Forbid(); + return Unauthorized(); } - return await service.UpdateName(tacticId, name) ? Ok() : NotFound(); + var result = await service.GetTactic(tacticId); + return result != null ? Ok(result.ToDto()) : NotFound(); } - // [Authorize] - // public async Task SetTacticStepContent(int tacticId, int stepId) - // { - // - // } + + public record CreateNewRequest( + [StringLength(50, MinimumLength = 1)] + [Name] + string Name, + [AllowedValues("PLAIN", "HALF")] string CourtType + ); + + public record CreateNewResponse(int Id); + + [HttpPost("/tactics")] + [Authorize] + public async Task CreateNew([FromBody] CreateNewRequest req) + { + var userId = this.CurrentUserId(); + + var courtType = req.CourtType switch + { + "PLAIN" => CourtType.Plain, + "HALF" => CourtType.Half, + _ => throw new ArgumentOutOfRangeException() //unreachable + }; + + var id = await service.CreateNew(userId, req.Name, courtType); + return new CreateNewResponse(id); + } + + private record GetStepContentResponse(string Content); + + [HttpGet("/tactics/{tacticId:int}/{stepId:int}")] + [Authorize] + public async Task GetStepContent(int tacticId, int stepId) + { + var userId = this.CurrentUserId(); + + if (!await service.HasAnyRights(userId, tacticId)) + { + return Unauthorized(); + } + + var json = await service.GetTacticStepContent(tacticId, stepId); + return Ok(new GetStepContentResponse(json!)); + } + + public record SaveStepContentRequest(object Content); + + + [HttpPut("/tactics/{tacticId:int}/{stepId:int}")] + [Authorize] + public async Task SaveStepContent(int tacticId, int stepId, [FromBody] SaveStepContentRequest req) + { + var userId = this.CurrentUserId(); + + if (!await service.HasAnyRights(userId, tacticId)) + { + return Unauthorized(); + } + + await service.SetTacticStepContent(tacticId, stepId, JsonSerializer.Serialize(req.Content)); + return Ok(); + } } \ No newline at end of file diff --git a/API/Controllers/UserController.cs b/API/Controllers/UserController.cs index ece3bef..8c6b26c 100644 --- a/API/Controllers/UserController.cs +++ b/API/Controllers/UserController.cs @@ -1,3 +1,4 @@ +using API.DTO; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Model; @@ -16,16 +17,15 @@ public class UserController(IUserService users, ITeamService teams, ITacticServi return (await users.GetUser(userId))!; } - public record GetUserDataResponse(User User, Team[] Teams, Tactic[] Tactics); + public record GetUserDataResponse(Team[] Teams, TacticDto[] Tactics); [Authorize] [HttpGet("/user-data")] public async Task GetUserData() { var userId = this.CurrentUserId(); - var user = await users.GetUser(userId); var userTeams = await teams.ListTeamsOf(userId); var userTactics = await tactics.ListTacticsOf(userId); - return new GetUserDataResponse(user!, userTeams.ToArray(), userTactics.ToArray()); + return new GetUserDataResponse(userTeams.ToArray(), userTactics.Select(t => t.ToDto()).ToArray()); } } \ No newline at end of file diff --git a/API/DTO/ModelToDto.cs b/API/DTO/ModelToDto.cs new file mode 100644 index 0000000..36dddba --- /dev/null +++ b/API/DTO/ModelToDto.cs @@ -0,0 +1,11 @@ +using Model; + +namespace API.DTO; + +public static class ModelToDto +{ + public static TacticDto ToDto(this Tactic t) + { + return new TacticDto(t.Id, t.Name, t.OwnerId, t.CourtType.ToString().ToUpper(), new DateTimeOffset(t.CreationDate).ToUnixTimeMilliseconds()); + } +} \ No newline at end of file diff --git a/API/DTO/TacticDto.cs b/API/DTO/TacticDto.cs new file mode 100644 index 0000000..eb59b74 --- /dev/null +++ b/API/DTO/TacticDto.cs @@ -0,0 +1,3 @@ +namespace API.DTO; + +public record TacticDto(int Id, string Name, int OwnerId, string CourtType, long CreationDate); \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index fab8620..87b3558 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,4 +1,7 @@ +using System.Globalization; using System.Net.Mime; +using System.Text; +using API.Auth; using API.Validation; using DbServices; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -6,7 +9,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using Services; using StubContext; -using System.Text; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; @@ -46,13 +48,11 @@ builder.Services.AddAuthorization(options => options.AddPolicy(IdentityData.AdminUserPolicyName, p => p.RequireClaim(IdentityData.AdminUserClaimName))); -var appContext = new StubAppContext(); -appContext.Database.EnsureCreated(); - -builder.Services.AddScoped(_ => new DbUserService(appContext)); -builder.Services.AddScoped(_ => new DbTeamService(appContext)); -builder.Services.AddScoped(_ => new DbTacticService(appContext)); +builder.Services.AddDbContext(ServiceLifetime.Scoped); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -72,4 +72,21 @@ app.UseAuthorization(); app.MapControllers(); +app.Use((context, next) => +{ + var it = context.User + .Claims + .FirstOrDefault(c => c.Type == IdentityData.IdUserClaimName) + ?.Value; + if (it == null) + return next.Invoke(); + + SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(config["JWT:Key"]!)); + var (jwt, expirationDate) = Authentication.GenerateJwt(key, context.User.Claims); + context.Response.Headers["Next-Authorization"] = jwt; + context.Response.Headers["Next-Authorization-Expiration-Date"] = expirationDate.ToString(CultureInfo.InvariantCulture); + context.Response.Headers["Access-Control-Expose-Headers"] = "*"; + return next.Invoke(); +}); + app.Run(); \ No newline at end of file diff --git a/API/Validation/NameAttribute.cs b/API/Validation/NameAttribute.cs index 62f6eb7..f1d9c59 100644 --- a/API/Validation/NameAttribute.cs +++ b/API/Validation/NameAttribute.cs @@ -23,6 +23,6 @@ public partial class NameAttribute : ValidationAttribute $"{name} should only contain numbers, letters, accents, spaces, _ and - characters."); } - [GeneratedRegex("[^[0-9a-zA-Zà-üÀ-Ü _-]*$]")] + [GeneratedRegex("^[0-9a-zA-Zà-üÀ-Ü _-]*$")] private static partial Regex NameRegex(); } \ No newline at end of file diff --git a/API/appsettings.json b/API/appsettings.json index 43e8833..7212941 100644 --- a/API/appsettings.json +++ b/API/appsettings.json @@ -1,6 +1,6 @@ { "JWT": { - "Key": "Remember to use a secret key on prod" + "Key": "Remember to use a secret key on prod !!" }, "Logging": { "LogLevel": { diff --git a/DbServices/Security.cs b/DbServices/Security.cs new file mode 100644 index 0000000..7f24cd8 --- /dev/null +++ b/DbServices/Security.cs @@ -0,0 +1,6 @@ +namespace DbServices; + +public class Security +{ + +} \ No newline at end of file