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>
|
||||
<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" />
|
||||
|
|
|
|||
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.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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue