refactor: Clean Architecture mit Repository Pattern, Controllern und DTOs
CI - Build & Test / Backend (.NET) (push) Successful in 54s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 2s

- 15 Controller-Klassen ersetzen Minimal APIs in Program.cs
- Repository Pattern mit Interfaces + Implementierungen (Project, Task, Activity, User)
- AuthService verwendet jetzt IUserRepository statt direktem DbContext-Zugriff
- SecurityHeadersMiddleware als eigenständige Middleware-Klasse
- PathSecurityHelper als gemeinsamer Helper für Pfadvalidierung
- DTOs in eigenem Namespace Nexus.Api.DTOs
- EF-Entities in Nexus.Api.Data (vorher Nexus.Api.Domain)
- Program.cs auf DI-Registrierung + Middleware reduziert
- Alle 43 Endpoints unverändert erhalten
- Build + 3/3 Tests erfolgreich
This commit is contained in:
2026-06-09 19:52:58 +02:00
parent 13d4c2f157
commit a79d8282dc
45 changed files with 1590 additions and 1182 deletions
+26 -46
View File
@@ -1,8 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Contracts;
using Nexus.Api.Domain;
using Nexus.Api.Infrastructure;
using Nexus.Api.DTOs;
using Nexus.Api.Data;
using Nexus.Api.Repositories;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
@@ -28,13 +27,13 @@ public sealed record AuthSession(
public sealed class AuthService : IAuthService
{
private readonly NexusDbContext _db;
private readonly IUserRepository _users;
private readonly IConfiguration _config;
private readonly ILogger<AuthService> _logger;
public AuthService(NexusDbContext db, IConfiguration config, ILogger<AuthService> logger)
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
{
_db = db;
_users = users;
_config = config;
_logger = logger;
}
@@ -42,7 +41,7 @@ public sealed class AuthService : IAuthService
public async Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default)
{
var normalizedEmail = NormalizeEmail(request.Email);
var user = await _db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
if (user is null || !PasswordSecurity.Verify(request.Password, user.PasswordHash, out var needsUpgrade))
{
@@ -54,7 +53,7 @@ public sealed class AuthService : IAuthService
user.LastLoginAt = DateTimeOffset.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await RemoveExpiredTokensAsync(user.Id, ct);
await _users.RemoveExpiredTokensAsync(user.Id, ct);
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
}
@@ -63,9 +62,7 @@ public sealed class AuthService : IAuthService
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
var tokenHash = HashToken(refreshToken);
var token = await _db.RefreshTokens
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
if (token is null) return null;
@@ -86,20 +83,25 @@ public sealed class AuthService : IAuthService
if (string.IsNullOrWhiteSpace(refreshToken)) return;
var tokenHash = HashToken(refreshToken);
var token = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await _db.SaveChangesAsync(ct);
await _users.SaveChangesAsync(ct);
}
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
=> _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
=> Task.Run(async () =>
{
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
// For read-only access, we call it but the result shouldn't be mutated
return await _users.GetByIdAsync(userId, ct);
}, ct);
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
var user = await _users.GetByIdAsync(userId, ct);
if (user is null) return null;
if (!string.IsNullOrWhiteSpace(request.DisplayName))
@@ -108,13 +110,13 @@ public sealed class AuthService : IAuthService
}
user.UpdatedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
await _users.UpdateAsync(user, ct);
return user;
}
public async Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
var user = await _users.GetByIdAsync(userId, ct);
if (user is null) return false;
if (!PasswordSecurity.Verify(request.CurrentPassword, user.PasswordHash, out _))
@@ -122,7 +124,7 @@ public sealed class AuthService : IAuthService
user.PasswordHash = PasswordSecurity.Hash(request.NewPassword);
user.UpdatedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
await _users.UpdateAsync(user, ct);
return true;
}
@@ -141,25 +143,16 @@ public sealed class AuthService : IAuthService
replacedToken.RevokedAt = DateTimeOffset.UtcNow;
replacedToken.ReplacedByTokenHash = refreshTokenHash;
replacedToken.ConcurrencyStamp = Guid.NewGuid();
await _users.UpdateRefreshTokenAsync(replacedToken, ct);
}
_db.RefreshTokens.Add(new RefreshToken
await _users.AddRefreshTokenAsync(new RefreshToken
{
UserId = user.Id,
TokenHash = refreshTokenHash,
FamilyId = familyId,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(GetRefreshTokenExpirationDays())
});
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
_logger.LogWarning("Concurrent refresh token rotation rejected");
return null;
}
}, ct);
return new AuthSession(
GenerateAccessToken(user, accessExpiresAt),
@@ -194,10 +187,7 @@ public sealed class AuthService : IAuthService
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
{
var activeTokens = await _db.RefreshTokens
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
.ToListAsync(ct);
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
@@ -205,17 +195,7 @@ public sealed class AuthService : IAuthService
token.ConcurrencyStamp = Guid.NewGuid();
}
await _db.SaveChangesAsync(ct);
}
private async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
var oldTokens = await _db.RefreshTokens
.Where(r => r.UserId == userId && (r.ExpiresAt < DateTimeOffset.UtcNow || r.RevokedAt < cutoff))
.ToListAsync(ct);
if (oldTokens.Count > 0) _db.RefreshTokens.RemoveRange(oldTokens);
await _users.SaveChangesAsync(ct);
}
private static string GenerateRefreshToken()