From b6962e10243e3327e601c6eb6f5e9a43fed1ae99 Mon Sep 17 00:00:00 2001 From: Denis Urs Rudolph Date: Fri, 3 Apr 2026 21:00:16 +0200 Subject: [PATCH] Add JWT authentication with AuthController and services --- backend/Controllers/AuthController.cs | 92 +++++++++++++++++++ backend/DTOs/AuthDtos.cs | 45 +++++++++ backend/Program.cs | 29 ++++++ backend/RacePlannerApi.csproj | 3 + backend/Services/JwtTokenService.cs | 43 +++++++++ openspec/changes/new-raceplanner-app/tasks.md | 24 ++--- 6 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 backend/Controllers/AuthController.cs create mode 100644 backend/DTOs/AuthDtos.cs create mode 100644 backend/Services/JwtTokenService.cs diff --git a/backend/Controllers/AuthController.cs b/backend/Controllers/AuthController.cs new file mode 100644 index 0000000..e348874 --- /dev/null +++ b/backend/Controllers/AuthController.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using RacePlannerApi.Data; +using RacePlannerApi.DTOs; +using RacePlannerApi.Models; +using RacePlannerApi.Services; + +namespace RacePlannerApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly RacePlannerDbContext _context; + private readonly JwtTokenService _jwtService; + + public AuthController(RacePlannerDbContext context, JwtTokenService jwtService) + { + _context = context; + _jwtService = jwtService; + } + + [HttpPost("register")] + public async Task> Register(RegisterRequest request) + { + // Check if email already exists + if (await _context.Users.AnyAsync(u => u.Email == request.Email)) + { + return Conflict(new { error = "Email already registered" }); + } + + // Create new user + var user = new User + { + Email = request.Email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + Name = request.Name, + Role = request.Role + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + // Generate token + var token = _jwtService.GenerateToken(user); + + return Ok(new AuthResponse + { + Token = token, + User = new UserDto + { + Id = user.Id, + Email = user.Email, + Name = user.Name, + Role = user.Role.ToString() + } + }); + } + + [HttpPost("login")] + public async Task> Login(LoginRequest request) + { + // Find user by email + var user = await _context.Users.FirstOrDefaultAsync(u => u.Email == request.Email); + + if (user == null) + { + return Unauthorized(new { error = "Invalid credentials" }); + } + + // Verify password + if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + { + return Unauthorized(new { error = "Invalid credentials" }); + } + + // Generate token + var token = _jwtService.GenerateToken(user); + + return Ok(new AuthResponse + { + Token = token, + User = new UserDto + { + Id = user.Id, + Email = user.Email, + Name = user.Name, + Role = user.Role.ToString() + } + }); + } +} \ No newline at end of file diff --git a/backend/DTOs/AuthDtos.cs b/backend/DTOs/AuthDtos.cs new file mode 100644 index 0000000..d269f84 --- /dev/null +++ b/backend/DTOs/AuthDtos.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using RacePlannerApi.Models; + +namespace RacePlannerApi.DTOs; + +public class RegisterRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(8)] + public string Password { get; set; } = string.Empty; + + [Required] + [MinLength(2)] + public string Name { get; set; } = string.Empty; + + public UserRole Role { get; set; } = UserRole.Participant; +} + +public class LoginRequest +{ + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + public string Password { get; set; } = string.Empty; +} + +public class AuthResponse +{ + public string Token { get; set; } = string.Empty; + public UserDto User { get; set; } = null!; +} + +public class UserDto +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index 5a459d5..53a9f06 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,5 +1,9 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using RacePlannerApi.Data; +using RacePlannerApi.Services; var builder = WebApplication.CreateBuilder(args); @@ -8,6 +12,29 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); +// Configure JWT Authentication +var jwtKey = builder.Configuration["Jwt:Key"] ?? "your-secret-key-here-minimum-32-characters-long"; +var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "RacePlannerApi"; +var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "RacePlannerClient"; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + }); + +// Register services +builder.Services.AddScoped(); + // Configure Entity Framework Core with PostgreSQL var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=localhost;Database=RacePlanner;Username=postgres;Password=postgres"; @@ -40,6 +67,8 @@ app.UseHttpsRedirection(); // Apply CORS app.UseCors("AllowFrontend"); +// Authentication & Authorization +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/backend/RacePlannerApi.csproj b/backend/RacePlannerApi.csproj index e164626..ec5f009 100644 --- a/backend/RacePlannerApi.csproj +++ b/backend/RacePlannerApi.csproj @@ -7,12 +7,15 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/backend/Services/JwtTokenService.cs b/backend/Services/JwtTokenService.cs new file mode 100644 index 0000000..48d5ad5 --- /dev/null +++ b/backend/Services/JwtTokenService.cs @@ -0,0 +1,43 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using RacePlannerApi.Models; + +namespace RacePlannerApi.Services; + +public class JwtTokenService +{ + private readonly IConfiguration _configuration; + + public JwtTokenService(IConfiguration configuration) + { + _configuration = configuration; + } + + public string GenerateToken(User user) + { + var securityKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? "your-secret-key-here-minimum-32-characters-long")); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(JwtRegisteredClaimNames.Name, user.Name), + new Claim("role", user.Role.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: _configuration["Jwt:Issuer"] ?? "RacePlannerApi", + audience: _configuration["Jwt:Audience"] ?? "RacePlannerClient", + claims: claims, + expires: DateTime.Now.AddHours(24), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} \ No newline at end of file diff --git a/openspec/changes/new-raceplanner-app/tasks.md b/openspec/changes/new-raceplanner-app/tasks.md index bbd9bb9..05d8a21 100644 --- a/openspec/changes/new-raceplanner-app/tasks.md +++ b/openspec/changes/new-raceplanner-app/tasks.md @@ -1,20 +1,20 @@ ## 1. Project Setup -- [ ] 1.1 Initialize project structure with backend and frontend directories -- [ ] 1.2 Setup .NET backend (ASP.NET Core Web API with C#) -- [ ] 1.3 Setup Next.js frontend with TypeScript using Bun runtime -- [ ] 1.4 Configure ESLint and Prettier for frontend -- [ ] 1.5 Setup Entity Framework Core with PostgreSQL -- [ ] 1.6 Create initial database schema migration (EF Core) +- [x] 1.1 Initialize project structure with backend and frontend directories +- [x] 1.2 Setup .NET backend (ASP.NET Core Web API with C#) +- [x] 1.3 Setup Next.js frontend with TypeScript using Bun runtime +- [x] 1.4 Configure ESLint and Prettier for frontend +- [x] 1.5 Setup Entity Framework Core with PostgreSQL +- [x] 1.6 Create initial database schema migration (EF Core) ## 2. Database Schema -- [ ] 2.1 Create users table with roles -- [ ] 2.2 Create events table with organizer foreign key -- [ ] 2.3 Create registrations table with event and user foreign keys -- [ ] 2.4 Create payments table with registration foreign key -- [ ] 2.5 Create announcements table with event foreign key -- [ ] 2.6 Add database indexes for common queries +- [x] 2.1 Create users table with roles +- [x] 2.2 Create events table with organizer foreign key +- [x] 2.3 Create registrations table with event and user foreign keys +- [x] 2.4 Create payments table with registration foreign key +- [x] 2.5 Create announcements table with event foreign key +- [x] 2.6 Add database indexes for common queries ## 3. User Authentication (user-auth)