c4a1775d7d
- Add per-account lockout + IP rate limiter on local sign-in (A07) - Emit CSP and security headers on every response (A05) - Run container as non-root `app`, /data 0700 (A05/A02) - Stop reflecting raw token-endpoint body into redirect URL (A09) - Handle missing refresh_token in connect callback without a 500 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
8.0 KiB
C#
203 lines
8.0 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.Extensions.Logging;
|
|
using SharepointToolbox.Web.Core.Models;
|
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
|
|
|
namespace SharepointToolbox.Web.Services.Auth;
|
|
|
|
public class UserService : IUserService
|
|
{
|
|
private readonly UserRepository _repo;
|
|
private readonly IPasswordHasher<AppUser> _hasher;
|
|
private readonly ILogger<UserService> _logger;
|
|
|
|
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
|
|
{
|
|
_repo = repo;
|
|
_hasher = hasher;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
|
|
{
|
|
var email = principal.FindFirstValue(ClaimTypes.Email)
|
|
?? principal.FindFirstValue("preferred_username")
|
|
?? throw new InvalidOperationException("OIDC token has no email claim.");
|
|
|
|
var display = principal.FindFirstValue("name")
|
|
?? principal.FindFirstValue(ClaimTypes.Name)
|
|
?? email;
|
|
|
|
var existing = await _repo.FindByEmailAsync(email);
|
|
if (existing is not null)
|
|
{
|
|
existing.LastLogin = DateTimeOffset.UtcNow;
|
|
existing.DisplayName = display;
|
|
await _repo.UpsertAsync(existing);
|
|
return existing;
|
|
}
|
|
|
|
// First user ever → Admin; subsequent → TechN0
|
|
var all = await _repo.LoadAsync();
|
|
var role = all.Count == 0 ? UserRole.Admin : UserRole.TechN0;
|
|
|
|
var user = new AppUser
|
|
{
|
|
Email = email,
|
|
DisplayName = display,
|
|
Role = role,
|
|
Provider = AuthProvider.Entra,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
LastLogin = DateTimeOffset.UtcNow
|
|
};
|
|
await _repo.UpsertAsync(user);
|
|
return user;
|
|
}
|
|
|
|
public Task<AppUser?> GetByEmailAsync(string email) => _repo.FindByEmailAsync(email);
|
|
|
|
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
|
|
|
|
public async Task<UserRole> UpdateRoleAsync(string userId, UserRole role)
|
|
{
|
|
var users = (await _repo.LoadAsync()).ToList();
|
|
var user = users.FirstOrDefault(u => u.Id == userId)
|
|
?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users.");
|
|
|
|
var oldRole = user.Role;
|
|
user.Role = role;
|
|
await _repo.UpsertAsync(user);
|
|
|
|
// Verify the write landed by re-reading the row from disk.
|
|
var persisted = (await _repo.LoadAsync()).FirstOrDefault(u => u.Id == user.Id)?.Role;
|
|
_logger.LogInformation(
|
|
"UpdateRoleAsync: {Email} (id {Id}) {OldRole} → {NewRole}; persisted value now {Persisted}.",
|
|
user.Email, user.Id, oldRole, role, persisted);
|
|
|
|
return oldRole;
|
|
}
|
|
|
|
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
|
|
|
|
public async Task<AppUser> CreateLocalUserAsync(string email, string displayName, UserRole role, string password)
|
|
{
|
|
email = email.Trim();
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
throw new InvalidOperationException("Email is required.");
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
throw new InvalidOperationException("Password is required.");
|
|
|
|
if (await _repo.FindByEmailAsync(email) is not null)
|
|
throw new InvalidOperationException($"A user with email '{email}' already exists.");
|
|
|
|
// First user ever → Admin; otherwise use the requested role
|
|
var all = await _repo.LoadAsync();
|
|
var effectiveRole = all.Count == 0 ? UserRole.Admin : role;
|
|
|
|
var user = new AppUser
|
|
{
|
|
Email = email,
|
|
DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName.Trim(),
|
|
Role = effectiveRole,
|
|
Provider = AuthProvider.Local,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
LastLogin = null
|
|
};
|
|
user.PasswordHash = _hasher.HashPassword(user, password);
|
|
await _repo.UpsertAsync(user);
|
|
return user;
|
|
}
|
|
|
|
// Brute-force lockout: after this many consecutive failures the account is locked
|
|
// for the window below. Tuned to stop guessing while staying usable for a fat-fingered
|
|
// admin. The counter resets on any successful sign-in.
|
|
private const int LockoutThreshold = 5;
|
|
private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15);
|
|
|
|
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
|
|
{
|
|
var user = await _repo.FindByEmailAsync(email);
|
|
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
|
|
return null;
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
// Account currently locked → refuse without even checking the password, so a locked
|
|
// account can't be probed. Returning null (generic failure) avoids account enumeration.
|
|
if (user.LockoutEndUtc is { } until && until > now)
|
|
{
|
|
_logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until);
|
|
return null;
|
|
}
|
|
|
|
// Lock window elapsed → clear it before re-evaluating.
|
|
if (user.LockoutEndUtc is not null)
|
|
{
|
|
user.LockoutEndUtc = null;
|
|
user.FailedLoginCount = 0;
|
|
}
|
|
|
|
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
|
if (result == PasswordVerificationResult.Failed)
|
|
{
|
|
user.FailedLoginCount++;
|
|
if (user.FailedLoginCount >= LockoutThreshold)
|
|
{
|
|
user.LockoutEndUtc = now + LockoutWindow;
|
|
_logger.LogWarning(
|
|
"Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.",
|
|
user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount);
|
|
}
|
|
await _repo.UpsertAsync(user);
|
|
return null;
|
|
}
|
|
|
|
// Transparently upgrade the hash if the algorithm parameters changed
|
|
if (result == PasswordVerificationResult.SuccessRehashNeeded)
|
|
user.PasswordHash = _hasher.HashPassword(user, password);
|
|
|
|
// Success → clear the failure trail.
|
|
user.FailedLoginCount = 0;
|
|
user.LockoutEndUtc = null;
|
|
user.LastLogin = now;
|
|
await _repo.UpsertAsync(user);
|
|
return user;
|
|
}
|
|
|
|
public async Task SetPasswordAsync(string userId, string newPassword)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newPassword))
|
|
throw new InvalidOperationException("Password is required.");
|
|
|
|
var users = (await _repo.LoadAsync()).ToList();
|
|
var user = users.FirstOrDefault(u => u.Id == userId)
|
|
?? throw new KeyNotFoundException($"User {userId} not found.");
|
|
if (user.Provider != AuthProvider.Local)
|
|
throw new InvalidOperationException("Only local accounts have passwords.");
|
|
|
|
user.PasswordHash = _hasher.HashPassword(user, newPassword);
|
|
await _repo.UpsertAsync(user);
|
|
}
|
|
|
|
public async Task<bool> ChangePasswordAsync(string userId, string currentPassword, string newPassword)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newPassword))
|
|
throw new InvalidOperationException("New password is required.");
|
|
|
|
var users = (await _repo.LoadAsync()).ToList();
|
|
var user = users.FirstOrDefault(u => u.Id == userId)
|
|
?? throw new KeyNotFoundException($"User {userId} not found.");
|
|
if (user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
|
|
return false;
|
|
|
|
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, currentPassword);
|
|
if (result == PasswordVerificationResult.Failed)
|
|
return false;
|
|
|
|
user.PasswordHash = _hasher.HashPassword(user, newPassword);
|
|
await _repo.UpsertAsync(user);
|
|
return true;
|
|
}
|
|
}
|