From d508d498c09e5f7a92b154b76dd6d85186607e7b Mon Sep 17 00:00:00 2001 From: Lasse Rune Hansen Date: Fri, 5 Jun 2026 13:27:43 +0200 Subject: [PATCH] feat(backend/auth): implement User Authentication feature - Add DTOs: RegisterDto, LoginDto, AuthResponse - Add IAuthService interface - Implement AuthService with JWT token generation - Create AuthController with register, login, me endpoints - Add JWT configuration to appsettings.json - Configure JWT Bearer authentication in Program.cs - Add PasswordHasher for custom User entity - Update User entity with ChangePassword method - Add necessary NuGet packages for JWT auth Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../Application/DTOs/Auth/AuthResponse.cs | 13 ++ GermanApp/Application/DTOs/Auth/LoginDto.cs | 16 +++ .../Application/DTOs/Auth/RegisterDto.cs | 22 +++ .../Application/Interfaces/IAuthService.cs | 32 +++++ GermanApp/GermanApp.csproj | 3 + .../Infrastructure/Services/AuthService.cs | 132 ++++++++++++++++++ .../Controllers/AuthController.cs | 106 ++++++++++++++ GermanApp/Program.cs | 52 +++++++ GermanApp/appsettings.json | 6 + 9 files changed, 382 insertions(+) create mode 100644 GermanApp/Application/DTOs/Auth/AuthResponse.cs create mode 100644 GermanApp/Application/DTOs/Auth/LoginDto.cs create mode 100644 GermanApp/Application/DTOs/Auth/RegisterDto.cs create mode 100644 GermanApp/Application/Interfaces/IAuthService.cs create mode 100644 GermanApp/Infrastructure/Services/AuthService.cs create mode 100644 GermanApp/Presentation/Controllers/AuthController.cs diff --git a/GermanApp/Application/DTOs/Auth/AuthResponse.cs b/GermanApp/Application/DTOs/Auth/AuthResponse.cs new file mode 100644 index 0000000..4e32bb2 --- /dev/null +++ b/GermanApp/Application/DTOs/Auth/AuthResponse.cs @@ -0,0 +1,13 @@ +namespace GermanApp.Application.DTOs.Auth; + +/// +/// DTO for authentication response containing JWT token. +/// +public record AuthResponse +{ + public int UserId { get; init; } + public string Username { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string Token { get; init; } = string.Empty; + public DateTime ExpiresAt { get; init; } +} diff --git a/GermanApp/Application/DTOs/Auth/LoginDto.cs b/GermanApp/Application/DTOs/Auth/LoginDto.cs new file mode 100644 index 0000000..3be9a34 --- /dev/null +++ b/GermanApp/Application/DTOs/Auth/LoginDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace GermanApp.Application.DTOs.Auth; + +/// +/// DTO for user login. +/// +public record LoginDto +{ + [Required] + [EmailAddress] + public string Email { get; init; } = string.Empty; + + [Required] + public string Password { get; init; } = string.Empty; +} diff --git a/GermanApp/Application/DTOs/Auth/RegisterDto.cs b/GermanApp/Application/DTOs/Auth/RegisterDto.cs new file mode 100644 index 0000000..a0b046d --- /dev/null +++ b/GermanApp/Application/DTOs/Auth/RegisterDto.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace GermanApp.Application.DTOs.Auth; + +/// +/// DTO for user registration. +/// +public record RegisterDto +{ + [Required] + [StringLength(50, MinimumLength = 3)] + public string Username { get; init; } = string.Empty; + + [Required] + [EmailAddress] + [StringLength(100)] + public string Email { get; init; } = string.Empty; + + [Required] + [StringLength(100, MinimumLength = 8)] + public string Password { get; init; } = string.Empty; +} diff --git a/GermanApp/Application/Interfaces/IAuthService.cs b/GermanApp/Application/Interfaces/IAuthService.cs new file mode 100644 index 0000000..6b0dd75 --- /dev/null +++ b/GermanApp/Application/Interfaces/IAuthService.cs @@ -0,0 +1,32 @@ +using GermanApp.Application.DTOs.Auth; +using GermanApp.Domain.Entities; + +namespace GermanApp.Application.Interfaces; + +/// +/// Interface for authentication services. +/// Part of the Application layer. +/// +public interface IAuthService +{ + /// + /// Registers a new user. + /// + /// User registration data + /// Authentication response with token + Task RegisterAsync(RegisterDto registerDto); + + /// + /// Authenticates a user and returns a JWT token. + /// + /// User login data + /// Authentication response with token + Task LoginAsync(LoginDto loginDto); + + /// + /// Gets the current authenticated user. + /// + /// User ID from token claims + /// The user entity + Task GetCurrentUserAsync(int userId); +} diff --git a/GermanApp/GermanApp.csproj b/GermanApp/GermanApp.csproj index bed793f..7b3fb89 100644 --- a/GermanApp/GermanApp.csproj +++ b/GermanApp/GermanApp.csproj @@ -20,6 +20,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/GermanApp/Infrastructure/Services/AuthService.cs b/GermanApp/Infrastructure/Services/AuthService.cs new file mode 100644 index 0000000..ad6f361 --- /dev/null +++ b/GermanApp/Infrastructure/Services/AuthService.cs @@ -0,0 +1,132 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using GermanApp.Application.DTOs.Auth; +using GermanApp.Application.Interfaces; +using GermanApp.Domain.Entities; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace GermanApp.Infrastructure.Services; + +/// +/// Authentication service implementation. +/// Part of the Infrastructure layer. +/// +public class AuthService : IAuthService +{ + private readonly AppDbContext _dbContext; + private readonly IPasswordHasher _passwordHasher; + private readonly IConfiguration _configuration; + + public AuthService( + AppDbContext dbContext, + IPasswordHasher passwordHasher, + IConfiguration configuration) + { + _dbContext = dbContext; + _passwordHasher = passwordHasher; + _configuration = configuration; + } + + /// + /// Registers a new user. + /// + public async Task RegisterAsync(RegisterDto registerDto) + { + // Check if username or email already exists + if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username)) + throw new InvalidOperationException("Username already taken"); + + if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email)) + throw new InvalidOperationException("Email already in use"); + + // Hash password and create user + var user = User.Create(registerDto.Username, registerDto.Email.ToLowerInvariant(), string.Empty); + var passwordHash = _passwordHasher.HashPassword(user, registerDto.Password); + user.ChangePassword(passwordHash); + + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + // Generate JWT token + var token = GenerateJwtToken(user); + + return new AuthResponse + { + UserId = user.Id, + Username = user.Username, + Email = user.Email, + Token = token, + ExpiresAt = DateTime.UtcNow.AddHours(24) + }; + } + + /// + /// Authenticates a user and returns a JWT token. + /// + public async Task LoginAsync(LoginDto loginDto) + { + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == loginDto.Email); + + if (user == null) + throw new UnauthorizedAccessException("Invalid email or password"); + + // Verify password + var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, loginDto.Password); + if (result == PasswordVerificationResult.Failed) + throw new UnauthorizedAccessException("Invalid email or password"); + + // Generate JWT token + var token = GenerateJwtToken(user); + + return new AuthResponse + { + UserId = user.Id, + Username = user.Username, + Email = user.Email, + Token = token, + ExpiresAt = DateTime.UtcNow.AddHours(24) + }; + } + + /// + /// Gets the current authenticated user. + /// + public async Task GetCurrentUserAsync(int userId) + { + return await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId); + } + + /// + /// Generates a JWT token for the given user. + /// + private string GenerateJwtToken(User user) + { + var securityKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? "super-secret-key-at-least-32-characters")); + + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, "User") + }; + + var token = new JwtSecurityToken( + issuer: _configuration["Jwt:Issuer"] ?? "DeutschLernen", + audience: _configuration["Jwt:Audience"] ?? "DeutschLernen", + claims: claims, + expires: DateTime.UtcNow.AddHours(24), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/GermanApp/Presentation/Controllers/AuthController.cs b/GermanApp/Presentation/Controllers/AuthController.cs new file mode 100644 index 0000000..9633d9c --- /dev/null +++ b/GermanApp/Presentation/Controllers/AuthController.cs @@ -0,0 +1,106 @@ +using GermanApp.Application.DTOs.Auth; +using GermanApp.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Net; + +namespace GermanApp.Presentation.Controllers; + +/// +/// Controller for authentication endpoints. +/// Part of the Presentation layer. +/// +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + /// + /// Register a new user. + /// + /// Registration data + /// Authentication response with JWT token + [HttpPost("register")] + [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)] + public async Task Register([FromBody] RegisterDto registerDto) + { + try + { + var result = await _authService.RegisterAsync(registerDto); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message); + } + } + + /// + /// Login an existing user. + /// + /// Login data + /// Authentication response with JWT token + [HttpPost("login")] + [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)] + public async Task Login([FromBody] LoginDto loginDto) + { + try + { + var result = await _authService.LoginAsync(loginDto); + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(ex.Message); + } + catch (Exception ex) + { + return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message); + } + } + + /// + /// Get current authenticated user information. + /// + /// Current user information + [HttpGet("me")] + [Authorize] + [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.Unauthorized)] + public async Task GetCurrentUser() + { + try + { + var userId = int.Parse(User.FindFirst("nameid")?.Value ?? "0"); + if (userId == 0) + return Unauthorized(); + + var user = await _authService.GetCurrentUserAsync(userId); + if (user == null) + return Unauthorized(); + + return Ok(new AuthResponse + { + UserId = user.Id, + Username = user.Username, + Email = user.Email + }); + } + catch (Exception ex) + { + return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message); + } + } +} diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index 014fb9c..b1df8d5 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -1,13 +1,21 @@ using GermanApp.Application.DTOs; +using GermanApp.Application.Interfaces; using GermanApp.Application.UseCases.Commands; +using GermanApp.Domain.Entities; using GermanApp.Domain.Interfaces; using GermanApp.Infrastructure.Data.DbContext; using GermanApp.Infrastructure.Data.Repositories; using GermanApp.Infrastructure.Data.SeedData; +using GermanApp.Infrastructure.Services; +using GermanApp.Presentation.Controllers; using GermanApp.Presentation.Endpoints; using GermanApp.Shared.Middleware; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using Serilog; +using System.Text; // Configure Serilog Log.Logger = new LoggerConfiguration() @@ -36,6 +44,41 @@ try builder.Services.AddHealthChecks() .AddDbContextCheck(); + // Add Password Hasher for custom User entity + builder.Services.AddScoped, PasswordHasher>(); + + // Configure JWT Authentication + var jwtKey = builder.Configuration["Jwt:Key"] ?? "super-secret-key-at-least-32-characters"; + var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "DeutschLernen"; + var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "DeutschLernen"; + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = 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)), + ClockSkew = TimeSpan.Zero + }; + }); + + // Add Authorization + builder.Services.AddAuthorization(); + + // Register AuthService + builder.Services.AddScoped(); + // Configure CORS builder.Services.AddCors(options => { @@ -94,12 +137,21 @@ try app.UseSwaggerUI(); } + // Use Authentication & Authorization + app.UseAuthentication(); + app.UseAuthorization(); + // Use CORS app.UseCors("AllowAll"); // Use Health Checks app.MapHealthChecks("/health"); + // Map Auth endpoints + app.MapControllerRoute( + name: "api", + pattern: "api/{controller}/{action}/{id?}" ); + // Seed database with initial data app.SeedDatabase(); diff --git a/GermanApp/appsettings.json b/GermanApp/appsettings.json index 7b19f94..41ed9f9 100644 --- a/GermanApp/appsettings.json +++ b/GermanApp/appsettings.json @@ -8,5 +8,11 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres" + }, + "Jwt": { + "Key": "your-super-secret-key-at-least-32-characters-long", + "Issuer": "DeutschLernen", + "Audience": "DeutschLernen", + "ExpireHours": 24 } }