Files
nexus/backend/Services/AuthService.cs
T
developer b7b44494f0
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
2026-06-11 10:06:58 +02:00

272 lines
9.9 KiB
C#

using Microsoft.IdentityModel.Tokens;
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;
using System.Text;
namespace Nexus.Api.Services;
public interface IAuthService
{
Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default);
Task<AuthSession?> RefreshAsync(string refreshToken, CancellationToken ct = default);
Task RevokeAsync(string refreshToken, CancellationToken ct = default);
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
}
public sealed record AuthSession(
string AccessToken,
string RefreshToken,
DateTimeOffset ExpiresAt,
UserInfo User);
public sealed class AuthService : IAuthService
{
private readonly IUserRepository _users;
private readonly IConfiguration _config;
private readonly ILogger<AuthService> _logger;
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
{
_users = users;
_config = config;
_logger = logger;
}
public async Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default)
{
var normalizedEmail = NormalizeEmail(request.Email);
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
if (user is null || !PasswordSecurity.Verify(request.Password, user.PasswordHash, out var needsUpgrade))
{
_logger.LogWarning("Rejected login attempt");
return null;
}
if (needsUpgrade) user.PasswordHash = PasswordSecurity.Hash(request.Password);
user.LastLoginAt = DateTimeOffset.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
await _users.RemoveExpiredTokensAsync(user.Id, ct);
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
}
public async Task<AuthSession?> RefreshAsync(string refreshToken, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
var tokenHash = HashToken(refreshToken);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
if (token is null) return null;
if (token.RevokedAt is not null)
{
await RevokeFamilyAsync(token.FamilyId, ct);
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
return null;
}
if (token.ExpiresAt <= DateTimeOffset.UtcNow) return null;
return await CreateSessionAsync(token.User, token.FamilyId, token, ct);
}
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(refreshToken)) return;
var tokenHash = HashToken(refreshToken);
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 _users.SaveChangesAsync(ct);
}
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
=> 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 _users.GetByIdAsync(userId, ct);
if (user is null) return null;
if (!string.IsNullOrWhiteSpace(request.DisplayName))
{
user.DisplayName = request.DisplayName.Trim();
}
user.UpdatedAt = DateTimeOffset.UtcNow;
await _users.UpdateAsync(user, ct);
return user;
}
public async Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct);
if (user is null) return false;
if (!PasswordSecurity.Verify(request.CurrentPassword, user.PasswordHash, out _))
return false;
user.PasswordHash = PasswordSecurity.Hash(request.NewPassword);
user.UpdatedAt = DateTimeOffset.UtcNow;
await _users.UpdateAsync(user, ct);
return true;
}
public async Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default)
{
// Validate admin token
if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken))
{
_logger.LogWarning("Admin password reset attempted without admin token or token not configured");
return false;
}
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(adminToken),
Encoding.UTF8.GetBytes(AdminResetToken)))
{
_logger.LogWarning("Invalid admin reset token provided");
return false;
}
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
return false;
if (newPassword.Length < 10)
return false;
var normalizedEmail = NormalizeEmail(email);
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
if (user is null)
{
_logger.LogWarning("Admin password reset: user {Email} not found", email);
return false;
}
user.PasswordHash = PasswordSecurity.Hash(newPassword);
user.UpdatedAt = DateTimeOffset.UtcNow;
await _users.UpdateAsync(user, ct);
_logger.LogInformation("Admin password reset completed for {Email}", email);
return true;
}
private async Task<AuthSession?> CreateSessionAsync(
NexusUser user,
Guid familyId,
RefreshToken? replacedToken,
CancellationToken ct)
{
var accessExpiresAt = DateTimeOffset.UtcNow.AddMinutes(GetAccessTokenExpirationMinutes());
var rawRefreshToken = GenerateRefreshToken();
var refreshTokenHash = HashToken(rawRefreshToken);
if (replacedToken is not null)
{
replacedToken.RevokedAt = DateTimeOffset.UtcNow;
replacedToken.ReplacedByTokenHash = refreshTokenHash;
replacedToken.ConcurrencyStamp = Guid.NewGuid();
await _users.UpdateRefreshTokenAsync(replacedToken, ct);
}
await _users.AddRefreshTokenAsync(new RefreshToken
{
UserId = user.Id,
TokenHash = refreshTokenHash,
FamilyId = familyId,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(GetRefreshTokenExpirationDays())
}, ct);
return new AuthSession(
GenerateAccessToken(user, accessExpiresAt),
rawRefreshToken,
accessExpiresAt,
ToUserInfo(user));
}
private string GenerateAccessToken(NexusUser user, DateTimeOffset expiresAt)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetRequiredConfig("Jwt:Key")));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Role, user.Role),
new Claim("display_name", user.DisplayName)
};
var token = new JwtSecurityToken(
issuer: GetRequiredConfig("Jwt:Issuer"),
audience: GetRequiredConfig("Jwt:Audience"),
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
{
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await _users.SaveChangesAsync(ct);
}
private static string GenerateRefreshToken()
{
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
return value.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static string HashToken(string token)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
public static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
private static UserInfo ToUserInfo(NexusUser user) => new()
{
Id = user.Id,
Email = user.Email,
DisplayName = user.DisplayName,
Role = user.Role
};
private string GetRequiredConfig(string key)
=> _config[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}");
private int GetAccessTokenExpirationMinutes()
=> _config.GetValue<int?>("Jwt:AccessTokenExpirationMinutes") ?? 15;
private int GetRefreshTokenExpirationDays()
=> _config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
}