feature/user-authentication #2
9 changed files with 382 additions and 0 deletions
13
GermanApp/Application/DTOs/Auth/AuthResponse.cs
Normal file
13
GermanApp/Application/DTOs/Auth/AuthResponse.cs
Normal 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; }
|
||||||
|
}
|
||||||
16
GermanApp/Application/DTOs/Auth/LoginDto.cs
Normal file
16
GermanApp/Application/DTOs/Auth/LoginDto.cs
Normal 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;
|
||||||
|
}
|
||||||
22
GermanApp/Application/DTOs/Auth/RegisterDto.cs
Normal file
22
GermanApp/Application/DTOs/Auth/RegisterDto.cs
Normal 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;
|
||||||
|
}
|
||||||
32
GermanApp/Application/Interfaces/IAuthService.cs
Normal file
32
GermanApp/Application/Interfaces/IAuthService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</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" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
|
||||||
|
|
|
||||||
132
GermanApp/Infrastructure/Services/AuthService.cs
Normal file
132
GermanApp/Infrastructure/Services/AuthService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
GermanApp/Presentation/Controllers/AuthController.cs
Normal file
106
GermanApp/Presentation/Controllers/AuthController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
using GermanApp.Application.DTOs;
|
using GermanApp.Application.DTOs;
|
||||||
|
using GermanApp.Application.Interfaces;
|
||||||
using GermanApp.Application.UseCases.Commands;
|
using GermanApp.Application.UseCases.Commands;
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
using GermanApp.Domain.Interfaces;
|
using GermanApp.Domain.Interfaces;
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
using GermanApp.Infrastructure.Data.DbContext;
|
||||||
using GermanApp.Infrastructure.Data.Repositories;
|
using GermanApp.Infrastructure.Data.Repositories;
|
||||||
using GermanApp.Infrastructure.Data.SeedData;
|
using GermanApp.Infrastructure.Data.SeedData;
|
||||||
|
using GermanApp.Infrastructure.Services;
|
||||||
|
using GermanApp.Presentation.Controllers;
|
||||||
using GermanApp.Presentation.Endpoints;
|
using GermanApp.Presentation.Endpoints;
|
||||||
using GermanApp.Shared.Middleware;
|
using GermanApp.Shared.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
// Configure Serilog
|
// Configure Serilog
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
|
@ -36,6 +44,41 @@ try
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddDbContextCheck<AppDbContext>();
|
.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
|
// Configure CORS
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -94,12 +137,21 @@ try
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use Authentication & Authorization
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
// Use CORS
|
// Use CORS
|
||||||
app.UseCors("AllowAll");
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
// Use Health Checks
|
// Use Health Checks
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
// Map Auth endpoints
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "api",
|
||||||
|
pattern: "api/{controller}/{action}/{id?}" );
|
||||||
|
|
||||||
// Seed database with initial data
|
// Seed database with initial data
|
||||||
app.SeedDatabase();
|
app.SeedDatabase();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,11 @@
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue