This commit is contained in:
2026-06-02 15:46:13 +02:00
25 changed files with 951 additions and 215 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ public class AuditService : IAuditService
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
sb.AppendLine(string.Join(",",
CsvEscape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
CsvEscape(e.UserEmail),
CsvEscape(e.UserDisplay),
CsvEscape(e.UserRole.ToString()),
+16 -1
View File
@@ -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);
}
+91 -1
View File
@@ -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;
}
}
+26 -2
View File
@@ -1,29 +1,41 @@
using System.Text;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using SharepointToolbox.Web.Services.Audit;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services.Export;
/// <summary>
/// Triggers browser file downloads from Blazor Server components.
/// Converts string export outputs to bytes and invokes JS download.
/// Every download is audit-logged as a report-export action.
/// </summary>
public class WebExportService
{
private readonly IJSRuntime _js;
private readonly IJSRuntime _js;
private readonly IAuditService _audit;
private readonly IUserSessionService _session;
public WebExportService(IJSRuntime js) { _js = js; }
public WebExportService(IJSRuntime js, IAuditService audit, IUserSessionService session)
{
_js = js;
_audit = audit;
_session = session;
}
public async Task DownloadCsvAsync(string content, string fileName)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(content);
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/csv;charset=utf-8", Convert.ToBase64String(bytes));
await LogExportAsync(fileName, bytes.Length);
}
public async Task DownloadHtmlAsync(string content, string fileName)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes));
await LogExportAsync(fileName, bytes.Length);
}
/// <summary>
@@ -33,5 +45,17 @@ public class WebExportService
public async Task DownloadBytesAsync(byte[] content, string fileName, string mime)
{
await _js.InvokeVoidAsync("sptb.downloadFile", fileName, mime, Convert.ToBase64String(content));
await LogExportAsync(fileName, content.Length);
}
/// <summary>
/// Records the download as a "ReportExport" audit entry. The file name encodes
/// the report kind (search_, permissions_, storage_, …) and timestamp.
/// </summary>
private Task LogExportAsync(string fileName, int byteCount)
{
var client = _session.CurrentProfile?.Name ?? string.Empty;
var sizeKb = (byteCount / 1024.0).ToString("F1");
return _audit.LogAsync("ReportExport", client, Array.Empty<string>(), $"{fileName} ({sizeKb} KB)");
}
}