feature/user-authentication #2

Merged
lasserh merged 31 commits from feature/user-authentication into main 2026-06-07 13:17:55 +02:00
9 changed files with 382 additions and 0 deletions
Showing only changes of commit d508d498c0 - Show all commits

View file

@ -0,0 +1,13 @@
namespace GermanApp.Application.DTOs.Auth;
/// <summary>
/// DTO for authentication response containing JWT token.
/// </summary>
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; }
}

View file

@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace GermanApp.Application.DTOs.Auth;
/// <summary>
/// DTO for user login.
/// </summary>
public record LoginDto
{
[Required]
[EmailAddress]
public string Email { get; init; } = string.Empty;
[Required]
public string Password { get; init; } = string.Empty;
}

View file

@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace GermanApp.Application.DTOs.Auth;
/// <summary>
/// DTO for user registration.
/// </summary>
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;
}

View file

@ -0,0 +1,32 @@
using GermanApp.Application.DTOs.Auth;
using GermanApp.Domain.Entities;
namespace GermanApp.Application.Interfaces;
/// <summary>
/// Interface for authentication services.
/// Part of the Application layer.
/// </summary>
public interface IAuthService
{
/// <summary>
/// Registers a new user.
/// </summary>
/// <param name="registerDto">User registration data</param>
/// <returns>Authentication response with token</returns>
Task<AuthResponse> RegisterAsync(RegisterDto registerDto);
/// <summary>
/// Authenticates a user and returns a JWT token.
/// </summary>
/// <param name="loginDto">User login data</param>
/// <returns>Authentication response with token</returns>
Task<AuthResponse> LoginAsync(LoginDto loginDto);
/// <summary>
/// Gets the current authenticated user.
/// </summary>
/// <param name="userId">User ID from token claims</param>
/// <returns>The user entity</returns>
Task<User?> GetCurrentUserAsync(int userId);
}

View file

@ -20,6 +20,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />

View file

@ -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;
/// <summary>
/// Authentication service implementation.
/// Part of the Infrastructure layer.
/// </summary>
public class AuthService : IAuthService
{
private readonly AppDbContext _dbContext;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IConfiguration _configuration;
public AuthService(
AppDbContext dbContext,
IPasswordHasher<User> passwordHasher,
IConfiguration configuration)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_configuration = configuration;
}
/// <summary>
/// Registers a new user.
/// </summary>
public async Task<AuthResponse> 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)
};
}
/// <summary>
/// Authenticates a user and returns a JWT token.
/// </summary>
public async Task<AuthResponse> 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)
};
}
/// <summary>
/// Gets the current authenticated user.
/// </summary>
public async Task<User?> GetCurrentUserAsync(int userId)
{
return await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
}
/// <summary>
/// Generates a JWT token for the given user.
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Controller for authentication endpoints.
/// Part of the Presentation layer.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
/// <summary>
/// Register a new user.
/// </summary>
/// <param name="registerDto">Registration data</param>
/// <returns>Authentication response with JWT token</returns>
[HttpPost("register")]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Login an existing user.
/// </summary>
/// <param name="loginDto">Login data</param>
/// <returns>Authentication response with JWT token</returns>
[HttpPost("login")]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Get current authenticated user information.
/// </summary>
/// <returns>Current user information</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> 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);
}
}
}

View file

@ -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<AppDbContext>();
// Add Password Hasher for custom User entity
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
// 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<IAuthService, AuthService>();
// 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();

View file

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