Files
SharepointToolbox-Web/Services/Auth/UserService.cs
T
kawa cdc93d041a Fix role change silently failing via @bind
The role <select> used a manual value=/@onchange pattern that parsed
e.Value and returned silently when the parse failed, so changing a role
did nothing and showed no message. Switch to @bind + @bind:after so the
framework handles the enum conversion, and log/verify the persisted role
in UpdateRoleAsync (now returns the previous role) for diagnosis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:10:00 +02:00

166 lines
6.4 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;
}
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 result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
return null;
// Transparently upgrade the hash if the algorithm parameters changed
if (result == PasswordVerificationResult.SuccessRehashNeeded)
user.PasswordHash = _hasher.HashPassword(user, password);
user.LastLogin = DateTimeOffset.UtcNow;
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;
}
}