Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web
This commit is contained in:
@@ -6,11 +6,26 @@ namespace SharepointToolbox.Web.Services.Auth;
|
||||
public interface IUserService
|
||||
{
|
||||
/// <summary>Auto-provision on first OIDC login; update LastLogin on subsequent logins.
|
||||
/// First user ever becomes Admin automatically.</summary>
|
||||
/// First user ever becomes Admin automatically. Tags the user as <see cref="AuthProvider.Entra"/>.</summary>
|
||||
Task<AppUser> ProvisionAsync(ClaimsPrincipal principal);
|
||||
|
||||
Task<AppUser?> GetByEmailAsync(string email);
|
||||
Task<IReadOnlyList<AppUser>> GetAllAsync();
|
||||
Task UpdateRoleAsync(string userId, UserRole role);
|
||||
Task DeleteAsync(string userId);
|
||||
|
||||
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
|
||||
/// <exception cref="InvalidOperationException">Email already in use.</exception>
|
||||
Task<AppUser> CreateLocalUserAsync(string email, string displayName, UserRole role, string password);
|
||||
|
||||
/// <summary>Validate local credentials. Returns the user and updates LastLogin on success; null otherwise.
|
||||
/// Only matches <see cref="AuthProvider.Local"/> accounts.</summary>
|
||||
Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password);
|
||||
|
||||
/// <summary>Admin reset — set a local user's password without knowing the current one.</summary>
|
||||
Task SetPasswordAsync(string userId, string newPassword);
|
||||
|
||||
/// <summary>Self-service — change own password after verifying the current one.</summary>
|
||||
/// <returns>true if the current password matched and the change was saved.</returns>
|
||||
Task<bool> ChangePasswordAsync(string userId, string currentPassword, string newPassword);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
@@ -7,8 +8,13 @@ namespace SharepointToolbox.Web.Services.Auth;
|
||||
public class UserService : IUserService
|
||||
{
|
||||
private readonly UserRepository _repo;
|
||||
private readonly IPasswordHasher<AppUser> _hasher;
|
||||
|
||||
public UserService(UserRepository repo) { _repo = repo; }
|
||||
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher)
|
||||
{
|
||||
_repo = repo;
|
||||
_hasher = hasher;
|
||||
}
|
||||
|
||||
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
@@ -38,6 +44,7 @@ public class UserService : IUserService
|
||||
Email = email,
|
||||
DisplayName = display,
|
||||
Role = role,
|
||||
Provider = AuthProvider.Entra,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastLogin = DateTimeOffset.UtcNow
|
||||
};
|
||||
@@ -59,4 +66,87 @@ public class UserService : IUserService
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user