Compare commits
17 Commits
582cc54189
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9480e9537a | |||
| 181c82d310 | |||
| c4a1775d7d | |||
| 0adc2d4300 | |||
| 17f6010a93 | |||
| fe33960c0e | |||
| 84b77d99f6 | |||
| 08dd27d91d | |||
| 38ffe714a2 | |||
| def8647de1 | |||
| f6a36f3bd9 | |||
| c41abc0ea5 | |||
| fe0fcdb7da | |||
| cdc93d041a | |||
| 98683bbd5e | |||
| e190e40b07 | |||
| 5f51e9d16d |
@@ -146,15 +146,15 @@
|
|||||||
new("/permissions", "🔐", "tab.permissions", "", "profile"),
|
new("/permissions", "🔐", "tab.permissions", "", "profile"),
|
||||||
new("/storage", "💾", "tab.storage", "", "profile"),
|
new("/storage", "💾", "tab.storage", "", "profile"),
|
||||||
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
|
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
|
||||||
new("/versions", "🗂️", "versions.tab", "", "profile"),
|
new("/versions", "🗂️", "versions.tab", "", "write"),
|
||||||
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
|
new("/transfer", "📦", "nav.fileTransfer", "", "write"),
|
||||||
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
|
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "write"),
|
||||||
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
|
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "write"),
|
||||||
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
|
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "write"),
|
||||||
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
|
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
|
||||||
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
|
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
|
||||||
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
|
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
|
||||||
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
|
new("/templates", "📐", "tab.templates", "nav.section.config", "write"),
|
||||||
new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"),
|
new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"),
|
||||||
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
|
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
|
||||||
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
|
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
|
||||||
@@ -170,6 +170,7 @@
|
|||||||
.Where(i => i.Scope switch
|
.Where(i => i.Scope switch
|
||||||
{
|
{
|
||||||
"profile" => Session.HasProfile,
|
"profile" => Session.HasProfile,
|
||||||
|
"write" => Session.HasProfile && UserContext.Role >= UserRole.TechN1,
|
||||||
"admin" => UserContext.Role == UserRole.Admin,
|
"admin" => UserContext.Role == UserRole.Admin,
|
||||||
"auth" => UserContext.IsAuthenticated,
|
"auth" => UserContext.IsAuthenticated,
|
||||||
_ => true
|
_ => true
|
||||||
@@ -226,8 +227,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If profile selected but no credentials → show modal (cert profiles never prompt)
|
// If profile selected but no credentials → show modal (cert profiles never prompt)
|
||||||
if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
|
if (ShouldPromptForCredentials)
|
||||||
await _credModal.ShowAsync();
|
await _credModal!.ShowAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when the selected profile authenticates app-only via a stored certificate —
|
// True when the selected profile authenticates app-only via a stored certificate —
|
||||||
@@ -235,6 +236,15 @@
|
|||||||
private bool CurrentProfileUsesCert =>
|
private bool CurrentProfileUsesCert =>
|
||||||
Session.CurrentProfile is { } p && AppOnly.IsConfigured(p);
|
Session.CurrentProfile is { } p && AppOnly.IsConfigured(p);
|
||||||
|
|
||||||
|
// Whether to auto-show the delegated sign-in modal. Only admins are ever asked to
|
||||||
|
// authenticate: standard technicians (TechN0/TechN1) operate under the profile's app
|
||||||
|
// (certificate) identity and must never be prompted when selecting a profile. A profile
|
||||||
|
// that isn't cert-configured is an admin setup concern, not a sign-in for the technician.
|
||||||
|
private bool ShouldPromptForCredentials =>
|
||||||
|
Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert
|
||||||
|
&& UserContext.Role == UserRole.Admin
|
||||||
|
&& _credModal is not null;
|
||||||
|
|
||||||
private async Task HandleOAuthCallbackAsync()
|
private async Task HandleOAuthCallbackAsync()
|
||||||
{
|
{
|
||||||
var uri = new Uri(Nav.Uri);
|
var uri = new Uri(Nav.Uri);
|
||||||
@@ -320,8 +330,9 @@
|
|||||||
// operating on the old connection.
|
// operating on the old connection.
|
||||||
await RefreshCredentialState();
|
await RefreshCredentialState();
|
||||||
// New profile selected and no valid credentials for it → prompt to connect.
|
// New profile selected and no valid credentials for it → prompt to connect.
|
||||||
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
|
// Standard technicians are never prompted (see ShouldPromptForCredentials).
|
||||||
await _credModal.ShowAsync();
|
if (ShouldPromptForCredentials)
|
||||||
|
await _credModal!.ShowAsync();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@page "/admin/audit"
|
@page "/admin/audit"
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
|
||||||
@inject IAuditService AuditService
|
@inject IAuditService AuditService
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@page "/admin/users"
|
@page "/admin/users"
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
|
||||||
@inject IUserService UserService
|
@inject IUserService UserService
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject IAuditService Audit
|
@inject IAuditService Audit
|
||||||
@@ -87,7 +87,7 @@ else
|
|||||||
<td style="padding:8px">
|
<td style="padding:8px">
|
||||||
<select class="form-input" style="width:130px"
|
<select class="form-input" style="width:130px"
|
||||||
value="@user.Role"
|
value="@user.Role"
|
||||||
@onchange="e => OnRoleChange(user, e)"
|
@onchange="@(e => OnRoleChange(user, e))"
|
||||||
disabled="@(user.Email == UserContext.Email)">
|
disabled="@(user.Email == UserContext.Email)">
|
||||||
@foreach (var role in Enum.GetValues<UserRole>())
|
@foreach (var role in Enum.GetValues<UserRole>())
|
||||||
{
|
{
|
||||||
@@ -198,9 +198,8 @@ else
|
|||||||
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
|
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var oldRole = user.Role;
|
var oldRole = await UserService.UpdateRoleAsync(user.Id, newRole);
|
||||||
await UserService.UpdateRoleAsync(user.Id, newRole);
|
user.Role = newRole;
|
||||||
user.Role = newRole;
|
|
||||||
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
|
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
|
||||||
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
|
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
|
||||||
_message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName);
|
_message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName);
|
||||||
|
|||||||
@@ -79,6 +79,13 @@
|
|||||||
{
|
{
|
||||||
<span class="chip chip-green">@T["profiles.active"]</span>
|
<span class="chip chip-green">@T["profiles.active"]</span>
|
||||||
}
|
}
|
||||||
|
@if (!AppOnlyFactory.IsConfigured(p))
|
||||||
|
{
|
||||||
|
<span class="chip chip-yellow"
|
||||||
|
title="No certificate configured — standard technicians can't use this profile. Open it and run 'Register app' (or upload a certificate) to enable shared access.">
|
||||||
|
⚠ No shared access
|
||||||
|
</span>
|
||||||
|
}
|
||||||
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||||
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
||||||
</button>
|
</button>
|
||||||
@@ -393,7 +400,10 @@
|
|||||||
|
|
||||||
if (_editing == null)
|
if (_editing == null)
|
||||||
{
|
{
|
||||||
_form.Id = Guid.NewGuid().ToString();
|
// Keep the Id assigned by the TenantProfile constructor. "Register app" may have
|
||||||
|
// already provisioned the cert and stored it on disk under this Id; reassigning a
|
||||||
|
// fresh Guid here would orphan that cert file and make the UI re-prompt for upload.
|
||||||
|
if (string.IsNullOrEmpty(_form.Id)) _form.Id = Guid.NewGuid().ToString();
|
||||||
_profiles.Add(_form);
|
_profiles.Add(_form);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
@inject AuthenticationStateProvider AuthProvider
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
@inject IUserService UserService
|
@inject IUserService UserService
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject NavigationManager Nav
|
||||||
@inject ILogger<AppInitializer> Logger
|
@inject ILogger<AppInitializer> Logger
|
||||||
|
@implements IDisposable
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using SharepointToolbox.Web.Services.Auth
|
@using SharepointToolbox.Web.Services.Auth
|
||||||
@using SharepointToolbox.Web.Services.Session
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@
|
@* Invisible component. Seeds IUserContextAccessor on circuit init and re-reads it from the
|
||||||
|
store on every navigation, so an admin's role change applies on the user's next page change. *@
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private string? _email;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var state = await AuthProvider.GetAuthenticationStateAsync();
|
var state = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
@@ -20,23 +26,51 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var email = principal.FindFirst("preferred_username")?.Value
|
_email = principal.FindFirst("preferred_username")?.Value
|
||||||
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
|
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
|
||||||
if (string.IsNullOrEmpty(email))
|
if (string.IsNullOrEmpty(_email))
|
||||||
{
|
{
|
||||||
var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"));
|
var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"));
|
||||||
Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims);
|
Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await UserService.GetByEmailAsync(email);
|
await SeedAsync(logSeed: true);
|
||||||
|
|
||||||
|
// Re-read the user (and current role) from the store on each navigation. The role lives
|
||||||
|
// in the scoped UserContextAccessor for the circuit's lifetime, so without this a role
|
||||||
|
// change made by an admin would not reach the affected user's live session.
|
||||||
|
Nav.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedAsync(bool logSeed)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_email)) return;
|
||||||
|
|
||||||
|
var user = await UserService.GetByEmailAsync(_email);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", email);
|
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", _email);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
|
if (logSeed)
|
||||||
|
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
|
||||||
|
|
||||||
UserContext.Initialize(user);
|
UserContext.Initialize(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SeedAsync(logSeed: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "AppInitializer: failed to refresh UserContext on navigation to '{Location}'.", e.Location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ public class AppDomainOptions
|
|||||||
|
|
||||||
return domain + "/" + path.TrimStart('/');
|
return domain + "/" + path.TrimStart('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configured domain as an absolute base <see cref="Uri"/> (scheme + host [+ port]), or
|
||||||
|
/// <c>null</c> when no domain is set or it can't be parsed.
|
||||||
|
/// </summary>
|
||||||
|
public Uri? GetBaseUri() =>
|
||||||
|
BuildUrl("/") is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,18 @@ public static class StringExtensions
|
|||||||
{
|
{
|
||||||
public static string? TrimOrNull(this string? s)
|
public static string? TrimOrNull(this string? s)
|
||||||
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <paramref name="returnUrl"/> only when it is a safe site-relative path,
|
||||||
|
/// otherwise "/". Rejects absolute URLs and protocol-relative paths ("//evil.com",
|
||||||
|
/// "/\evil.com") so a post-auth / post-connect redirect can never leave the app.
|
||||||
|
/// Used by every login and OAuth-connect redirect to prevent open redirects.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToLocalReturnUrl(this string? returnUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(returnUrl)) return "/";
|
||||||
|
if (returnUrl[0] != '/') return "/"; // not site-relative
|
||||||
|
if (returnUrl.Length > 1 && (returnUrl[1] == '/' || returnUrl[1] == '\\')) return "/"; // protocol-relative
|
||||||
|
return returnUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,17 @@ public class AppUser
|
|||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset? LastLogin { get; set; }
|
public DateTimeOffset? LastLogin { get; set; }
|
||||||
|
|
||||||
|
// ── Local-account brute-force lockout ───────────────────────────────────────
|
||||||
|
// Consecutive failed password attempts and, once the threshold is hit, the UTC
|
||||||
|
// instant the account unlocks again. Only meaningful for AuthProvider.Local.
|
||||||
|
// A per-account counter (not just an IP rate limiter) is the control that holds
|
||||||
|
// up here: forwarded headers are trusted from any source, so an attacker who can
|
||||||
|
// rotate X-Forwarded-For would evade IP-based throttling but not this.
|
||||||
|
|
||||||
|
/// <summary>Consecutive failed local-login attempts since the last success.</summary>
|
||||||
|
public int FailedLoginCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC instant the account unlocks; null when not locked.</summary>
|
||||||
|
public DateTimeOffset? LockoutEndUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -1,6 +1,5 @@
|
|||||||
# Base images pinned to exact patch for reproducible builds. Floating `:10.0` tags
|
# Base images pinned to exact patch for reproducible builds. Floating `:10.0` tags
|
||||||
# drift; a stale/pre-GA SDK base silently drops the Blazor framework static assets
|
# drift between machines; bump deliberately. (SDK 10.0.203 + runtime 10.0.8.)
|
||||||
# (blazor.web.js) from the publish manifest → 404 in production. Bump deliberately.
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -9,17 +8,33 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends curl \
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0.203 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["SharepointToolbox.Web.csproj", "."]
|
COPY ["SharepointToolbox.Web.csproj", "."]
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN dotnet publish -c Release -o /app/publish --no-restore
|
# Do NOT add --no-restore here. The restore above runs with only the .csproj present
|
||||||
|
# (no source, no wwwroot); pairing that cached state with `publish --no-restore`
|
||||||
|
# silently drops the Blazor framework static assets (wwwroot/_framework/blazor.web.js)
|
||||||
|
# from the output → the boot script 404s and no interactive circuit starts on any page.
|
||||||
|
# Letting publish restore against the full project re-materializes them. (Reproduced;
|
||||||
|
# the early restore above is kept only to cache the NuGet layer.)
|
||||||
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Run as the non-root `app` user shipped in the aspnet image (UID 1654) instead of root.
|
||||||
|
# /data holds the crown jewels (Data Protection keys, app-only certs, the user store), so
|
||||||
|
# create it owned by `app` with 0700 before declaring the volume — Docker seeds a fresh
|
||||||
|
# named volume from the image path's ownership/mode, so the running user can write it and
|
||||||
|
# other host users can't read the keys/certs at rest.
|
||||||
|
RUN mkdir -p /data \
|
||||||
|
&& chown -R app:app /app /data \
|
||||||
|
&& chmod 700 /data
|
||||||
|
USER app
|
||||||
|
|
||||||
# Volume for persistent data (profiles, settings, templates, logs, exports)
|
# Volume for persistent data (profiles, settings, templates, logs, exports)
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SharepointToolbox.Web.Core.Config;
|
using SharepointToolbox.Web.Core.Config;
|
||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
using SharepointToolbox.Web.Services.Auth;
|
using SharepointToolbox.Web.Services.Auth;
|
||||||
@@ -51,7 +52,10 @@ public static class OAuthEndpoints
|
|||||||
TenantId = profile.TenantId,
|
TenantId = profile.TenantId,
|
||||||
ClientId = profile.ClientId,
|
ClientId = profile.ClientId,
|
||||||
SpHost = ExtractHost(profile.TenantUrl),
|
SpHost = ExtractHost(profile.TenantUrl),
|
||||||
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl,
|
// Constrain to a site-relative path: the callback appends the redeemable
|
||||||
|
// token_key to this URL, so an external returnUrl would leak the client's
|
||||||
|
// SharePoint refresh token off-domain.
|
||||||
|
ReturnUrl = returnUrl.ToLocalReturnUrl(),
|
||||||
IsRegistration = false,
|
IsRegistration = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,12 +72,18 @@ public static class OAuthEndpoints
|
|||||||
string? error_description,
|
string? error_description,
|
||||||
IOAuthFlowCache flowCache,
|
IOAuthFlowCache flowCache,
|
||||||
IOptions<ClientConnectOptions> opts,
|
IOptions<ClientConnectOptions> opts,
|
||||||
IHttpClientFactory httpClientFactory) =>
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILoggerFactory loggerFactory) =>
|
||||||
{
|
{
|
||||||
|
var log = loggerFactory.CreateLogger("SharepointToolbox.Web.OAuth.Connect");
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(error))
|
if (!string.IsNullOrEmpty(error))
|
||||||
{
|
{
|
||||||
var errMsg = Uri.EscapeDataString(error_description ?? error);
|
// The provider's verbose error_description can carry correlation/trace ids and
|
||||||
return Results.Redirect($"/?connect_error={errMsg}");
|
// lands in the URL bar + proxy access logs. Log it server-side; surface only the
|
||||||
|
// short, safe OAuth error code (e.g. "access_denied") to the browser.
|
||||||
|
log.LogWarning("Connect callback returned error {Error}: {Description}", error, error_description);
|
||||||
|
return Results.Redirect($"/?connect_error={Uri.EscapeDataString(error)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||||
@@ -103,14 +113,24 @@ public static class OAuthEndpoints
|
|||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
|
// The raw token-endpoint body can contain trace ids / claim hints — keep it out of
|
||||||
return Results.Redirect($"/?connect_error={msg}");
|
// the URL and the proxy logs. Record it server-side, redirect with a generic notice.
|
||||||
|
log.LogWarning("Token exchange failed ({Status}): {Body}", resp.StatusCode, json);
|
||||||
|
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Token exchange failed. Please try connecting again.")}");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
var upn = ExtractUpnFromIdToken(root);
|
var upn = ExtractUpnFromIdToken(root);
|
||||||
var refreshToken = root.GetProperty("refresh_token").GetString()!;
|
|
||||||
|
// offline_access should yield a refresh_token; if the tenant/app withheld it the
|
||||||
|
// session can't be persisted. Fail cleanly instead of throwing a 500 + stack trace.
|
||||||
|
if (!root.TryGetProperty("refresh_token", out var refreshTokenEl) ||
|
||||||
|
refreshTokenEl.GetString() is not { Length: > 0 } refreshToken)
|
||||||
|
{
|
||||||
|
log.LogWarning("Token response had no refresh_token for tenant {Tenant}.", flowState.TenantId);
|
||||||
|
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Sign-in did not return a refresh token. Please try again.")}");
|
||||||
|
}
|
||||||
|
|
||||||
var tokens = new SessionTokens
|
var tokens = new SessionTokens
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1564,7 +1564,7 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
|
|||||||
<value>Sélectionné</value>
|
<value>Sélectionné</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profiles.subtitle" xml:space="preserve">
|
<data name="profiles.subtitle" xml:space="preserve">
|
||||||
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session — aucun secret n'est stocké sur le disque.</value>
|
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profiles.tenantid.label" xml:space="preserve">
|
<data name="profiles.tenantid.label" xml:space="preserve">
|
||||||
<value>ID de tenant :</value>
|
<value>ID de tenant :</value>
|
||||||
|
|||||||
@@ -1564,7 +1564,7 @@ This tab does the reverse: you select one or more users and it finds every objec
|
|||||||
<value>Selected</value>
|
<value>Selected</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profiles.subtitle" xml:space="preserve">
|
<data name="profiles.subtitle" xml:space="preserve">
|
||||||
<value>Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</value>
|
<value>Manage SharePoint tenant connections. Credentials are entered per session.</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="profiles.tenantid.label" xml:space="preserve">
|
<data name="profiles.tenantid.label" xml:space="preserve">
|
||||||
<value>Tenant ID:</value>
|
<value>Tenant ID:</value>
|
||||||
|
|||||||
+124
-15
@@ -6,10 +6,13 @@ using Microsoft.AspNetCore.Antiforgery;
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using SharepointToolbox.Web.Core.Config;
|
using SharepointToolbox.Web.Core.Config;
|
||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
using SharepointToolbox.Web.Infrastructure.Auth;
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
using SharepointToolbox.Web.Infrastructure.OAuth;
|
using SharepointToolbox.Web.Infrastructure.OAuth;
|
||||||
@@ -69,6 +72,12 @@ builder.Services.AddDataProtection()
|
|||||||
// Localization string source — Scoped: one per circuit, with its own explicit culture.
|
// Localization string source — Scoped: one per circuit, with its own explicit culture.
|
||||||
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
|
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
|
||||||
|
|
||||||
|
// ── Public domain ─────────────────────────────────────────────────────────────
|
||||||
|
// App__Domain (e.g. sptb.example.com) drives both OIDC sign-in (below) and the
|
||||||
|
// SharePoint-connect redirect URI. Bound once here so both consumers share it.
|
||||||
|
var appDomain = new AppDomainOptions();
|
||||||
|
builder.Configuration.GetSection("App").Bind(appDomain);
|
||||||
|
|
||||||
// ── Authentication ────────────────────────────────────────────────────────────
|
// ── Authentication ────────────────────────────────────────────────────────────
|
||||||
if (builder.Environment.IsDevelopment())
|
if (builder.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -103,7 +112,11 @@ else
|
|||||||
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
|
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
|
||||||
options.SessionStore = null;
|
options.SessionStore = null;
|
||||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
// Always mark the auth cookie Secure in prod. The app sits behind a TLS-terminating
|
||||||
|
// proxy and forwarded headers are trusted from any source (proxy IP is unknown inside
|
||||||
|
// the container network), so SameAsRequest would let a spoofed X-Forwarded-Proto: http
|
||||||
|
// — or any direct plaintext hit — emit a non-Secure cookie. Always wins regardless.
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
})
|
})
|
||||||
@@ -133,6 +146,34 @@ else
|
|||||||
options.MapInboundClaims = false;
|
options.MapInboundClaims = false;
|
||||||
options.TokenValidationParameters.NameClaimType = "preferred_username";
|
options.TokenValidationParameters.NameClaimType = "preferred_username";
|
||||||
|
|
||||||
|
// When App__Domain is set, pin the OIDC redirect_uri (and post-logout redirect) to that
|
||||||
|
// public host instead of deriving it from the request scheme/host. Keeps sign-in working
|
||||||
|
// when the app can't see its real external host (no/incorrect forwarded Host header, or
|
||||||
|
// several hostnames reach the same instance). The value must match the /signin-oidc URI
|
||||||
|
// registered on the Oidc app. The authorize request and the code→token redemption MUST
|
||||||
|
// send the identical redirect_uri, so override it in both events.
|
||||||
|
var oidcRedirectUri = appDomain.BuildUrl(options.CallbackPath.Value ?? "/signin-oidc");
|
||||||
|
var postLogoutUri = appDomain.BuildUrl(options.SignedOutCallbackPath.Value ?? "/signout-callback-oidc");
|
||||||
|
if (oidcRedirectUri is not null)
|
||||||
|
{
|
||||||
|
options.Events.OnRedirectToIdentityProvider = ctx =>
|
||||||
|
{
|
||||||
|
ctx.ProtocolMessage.RedirectUri = oidcRedirectUri;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
options.Events.OnAuthorizationCodeReceived = ctx =>
|
||||||
|
{
|
||||||
|
if (ctx.TokenEndpointRequest is not null)
|
||||||
|
ctx.TokenEndpointRequest.RedirectUri = oidcRedirectUri;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
options.Events.OnRedirectToIdentityProviderForSignOut = ctx =>
|
||||||
|
{
|
||||||
|
ctx.ProtocolMessage.PostLogoutRedirectUri = postLogoutUri;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
options.Events.OnTokenValidated = async ctx =>
|
options.Events.OnTokenValidated = async ctx =>
|
||||||
{
|
{
|
||||||
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
||||||
@@ -163,7 +204,32 @@ else
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
// "Admin" policy checks the app_role claim value directly, rather than [Authorize(Roles=…)]
|
||||||
|
// — the local/dev sign-in identities don't set a ClaimTypes.Role claim, so a Roles check would
|
||||||
|
// silently deny local admins. Every identity (OIDC, local, dev) carries app_role.
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("Admin", p => p.RequireClaim("app_role", nameof(UserRole.Admin)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rate limiting ───────────────────────────────────────────────────────────────
|
||||||
|
// Volumetric defence on the sign-in endpoints, partitioned by client IP (RemoteIpAddress
|
||||||
|
// reflects X-Forwarded-For — UseForwardedHeaders runs first). This is the coarse layer;
|
||||||
|
// the per-account lockout in UserService is what holds up when XFF is spoofed/rotated,
|
||||||
|
// since forwarded headers are trusted from any source behind the proxy.
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddPolicy("login", httpContext =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 10,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
|
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
@@ -176,8 +242,6 @@ builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSectio
|
|||||||
// when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a
|
// when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a
|
||||||
// single domain (e.g. sptb.example.com) instead of spelling out the full callback URL.
|
// single domain (e.g. sptb.example.com) instead of spelling out the full callback URL.
|
||||||
// An explicit RedirectUri still wins, so existing configs are unaffected.
|
// An explicit RedirectUri still wins, so existing configs are unaffected.
|
||||||
var appDomain = new AppDomainOptions();
|
|
||||||
builder.Configuration.GetSection("App").Bind(appDomain);
|
|
||||||
builder.Services.PostConfigure<ClientConnectOptions>(opts =>
|
builder.Services.PostConfigure<ClientConnectOptions>(opts =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(opts.RedirectUri) &&
|
if (string.IsNullOrWhiteSpace(opts.RedirectUri) &&
|
||||||
@@ -277,6 +341,23 @@ var app = builder.Build();
|
|||||||
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
|
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
|
// When App__Domain is set, rewrite every request's scheme + host to the public domain. The
|
||||||
|
// framework builds absolute URLs (the cookie login redirect, the OIDC redirect_uri, …) from
|
||||||
|
// Request.Scheme/Host; behind a proxy that doesn't forward the Host header these are the
|
||||||
|
// internal host (server IP:port), so loading https://<domain>/ would 302 to http://<ip>:8080.
|
||||||
|
// Forcing the host here keeps every generated URL on the public domain. Must run before auth.
|
||||||
|
var publicBaseUri = appDomain.GetBaseUri();
|
||||||
|
if (publicBaseUri is not null)
|
||||||
|
{
|
||||||
|
var publicHost = HostString.FromUriComponent(publicBaseUri);
|
||||||
|
app.Use((context, next) =>
|
||||||
|
{
|
||||||
|
context.Request.Scheme = publicBaseUri.Scheme;
|
||||||
|
context.Request.Host = publicHost;
|
||||||
|
return next(context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── First-run bootstrap ───────────────────────────────────────────────────────
|
// ── First-run bootstrap ───────────────────────────────────────────────────────
|
||||||
// Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that
|
// Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that
|
||||||
// can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can
|
// can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can
|
||||||
@@ -302,6 +383,33 @@ app.UseForwardedHeaders();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Security response headers ───────────────────────────────────────────────────
|
||||||
|
// Defence-in-depth on every response. CSP is tuned to this app: all scripts are external
|
||||||
|
// (_framework/blazor.web.js, js/app.js) so script-src can stay 'self' with no unsafe-*;
|
||||||
|
// the login page and the Blazor components use inline <style>/style="" so style-src needs
|
||||||
|
// 'unsafe-inline'. img-src allows data: for the base64 client logos and CSV download links.
|
||||||
|
// connect-src 'self' covers the Blazor Server WebSocket. frame-ancestors blocks clickjacking
|
||||||
|
// of the live circuit (X-Frame-Options for legacy agents).
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var headers = context.Response.Headers;
|
||||||
|
headers["Content-Security-Policy"] =
|
||||||
|
"default-src 'self'; " +
|
||||||
|
"script-src 'self'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"img-src 'self' data:; " +
|
||||||
|
"font-src 'self' data:; " +
|
||||||
|
"connect-src 'self'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"form-action 'self'; " +
|
||||||
|
"frame-ancestors 'none'";
|
||||||
|
headers["X-Frame-Options"] = "DENY";
|
||||||
|
headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
headers["Referrer-Policy"] = "no-referrer";
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
@@ -314,6 +422,7 @@ app.UseStatusCodePagesWithReExecute("/not-found");
|
|||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
// ── Login / Logout endpoints ──────────────────────────────────────────────────
|
// ── Login / Logout endpoints ──────────────────────────────────────────────────
|
||||||
@@ -352,7 +461,7 @@ if (isDev)
|
|||||||
CookieAuthenticationDefaults.AuthenticationScheme));
|
CookieAuthenticationDefaults.AuthenticationScheme));
|
||||||
|
|
||||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
ctx.Response.Redirect(returnUrl.ToLocalReturnUrl());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -362,10 +471,10 @@ else
|
|||||||
{
|
{
|
||||||
var props = new AuthenticationProperties
|
var props = new AuthenticationProperties
|
||||||
{
|
{
|
||||||
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
|
RedirectUri = returnUrl.ToLocalReturnUrl()
|
||||||
};
|
};
|
||||||
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
|
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
|
||||||
});
|
}).RequireRateLimiting("login");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local password sign-in — available in every environment.
|
// Local password sign-in — available in every environment.
|
||||||
@@ -378,7 +487,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
|
|||||||
var email = form["email"].ToString();
|
var email = form["email"].ToString();
|
||||||
var password = form["password"].ToString();
|
var password = form["password"].ToString();
|
||||||
var returnUrl = form["returnUrl"].ToString();
|
var returnUrl = form["returnUrl"].ToString();
|
||||||
var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
|
var safeReturn = returnUrl.ToLocalReturnUrl();
|
||||||
|
|
||||||
var user = await userService.ValidateLocalCredentialsAsync(email, password);
|
var user = await userService.ValidateLocalCredentialsAsync(email, password);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
@@ -396,7 +505,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
|
|||||||
|
|
||||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
return Results.Redirect(safeReturn);
|
return Results.Redirect(safeReturn);
|
||||||
});
|
}).RequireRateLimiting("login");
|
||||||
|
|
||||||
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
@@ -451,13 +560,13 @@ app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =
|
|||||||
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
||||||
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
||||||
{
|
{
|
||||||
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
|
// CsvSanitizer neutralizes spreadsheet formula prefixes (= + - @) on top of RFC 4180
|
||||||
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
// quoting — audited fields (display name, client/site names, details) are user-controlled.
|
||||||
sb.AppendLine(string.Join(",",
|
sb.AppendLine(string.Join(",",
|
||||||
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
|
CsvSanitizer.Escape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||||
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
|
CsvSanitizer.Escape(e.UserEmail), CsvSanitizer.Escape(e.UserDisplay), CsvSanitizer.Escape(e.UserRole.ToString()),
|
||||||
Esc(e.Action), Esc(e.ClientName),
|
CsvSanitizer.Escape(e.Action), CsvSanitizer.Escape(e.ClientName),
|
||||||
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
|
CsvSanitizer.Escape(string.Join("; ", e.Sites)), CsvSanitizer.Escape(e.Details)));
|
||||||
}
|
}
|
||||||
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
|
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ A web admin toolbox for Microsoft 365 / SharePoint Online, built with Blazor Ser
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Authentication uses Microsoft OIDC (interactive sign-in) and, for scheduled reports, app-only certificate auth.
|
Signing into the toolbox itself uses Microsoft OIDC (or a local account). Per-profile SharePoint/Graph access uses **certificate (app-only) auth** as the primary path — the same app identity drives both technician work and scheduled reports, so technicians never sign in per profile. Profiles without a certificate fall back to an interactive per-profile sign-in.
|
||||||
|
|
||||||
Set these as environment variables (or in `appsettings.json` under the `Oidc` section). .NET maps `Section__Key` to `Section:Key`.
|
Set these as environment variables (or in `appsettings.json` under the `Oidc` section). .NET maps `Section__Key` to `Section:Key`.
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se
|
|||||||
| `Oidc__TenantId` | Entra tenant GUID |
|
| `Oidc__TenantId` | Entra tenant GUID |
|
||||||
| `Oidc__ClientId` | App registration client ID |
|
| `Oidc__ClientId` | App registration client ID |
|
||||||
| `Oidc__ClientSecret` | App registration client secret |
|
| `Oidc__ClientSecret` | App registration client secret |
|
||||||
| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). The SharePoint-connect redirect URI is derived from it. |
|
| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). Pins the OIDC sign-in redirect (`/signin-oidc`) and derives the SharePoint-connect redirect URI. |
|
||||||
| `DataFolder` | Persistent data path (default `/data`) |
|
| `DataFolder` | Persistent data path (default `/data`) |
|
||||||
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
|
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
|
||||||
|
|
||||||
@@ -38,14 +38,45 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se
|
|||||||
|
|
||||||
These are separate and registered on **different** Entra apps. Don't conflate them.
|
These are separate and registered on **different** Entra apps. Don't conflate them.
|
||||||
|
|
||||||
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here).
|
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here). When `App__Domain` is set, the redirect is pinned to `<domain>/signin-oidc`; otherwise it's derived from the request host (`X-Forwarded-Host`/`Host`).
|
||||||
→ On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`.
|
→ On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`.
|
||||||
|
|
||||||
2. **SharePoint connect (per-profile).** Getting a delegated SharePoint/Graph token for a client tenant. A PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. The callback for this flow is derived from `App__Domain` as `<domain>/connect/callback`; set `ClientConnect__RedirectUri` to override the full URL directly.
|
2. **SharePoint connect (per-profile).** Getting a SharePoint/Graph token for a client tenant. The primary path is **certificate (app-only) auth** (see below), which needs no redirect URI. Profiles without a certificate fall back to an interactive PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. That fallback's callback is derived from `App__Domain` as `<domain>/connect/callback`; set `ClientConnect__RedirectUri` to override the full URL directly.
|
||||||
→ On **each client-tenant profile's** app registration, add that callback value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform.
|
→ Only when using the interactive fallback: on **each client-tenant profile's** app registration, add that callback value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform. See [Per-profile app registration permissions](#per-profile-app-registration-permissions) below for the API permissions it needs.
|
||||||
|
|
||||||
|
### Per-profile app registration permissions
|
||||||
|
|
||||||
|
The **Register App** action in a profile provisions this registration for you (single tenant, public client) and grants org-wide admin consent automatically. The list below documents what it creates, for manual registration or auditing.
|
||||||
|
|
||||||
|
Per-profile access uses **certificate (app-only) auth** as the primary path: the app identity drives both interactive technician work and unattended features (scheduled reports, tenant-wide audits), so technicians never sign in per profile. The cert is provisioned alongside the registration and attached as a sign-in credential. The registration grants **application permissions** for that app-only auth; it also keeps **delegated scopes** for the interactive connect flow that profiles *without* a certificate fall back to.
|
||||||
|
|
||||||
|
**Application permissions** (app-only certificate auth — primary):
|
||||||
|
|
||||||
|
| API | Permission | Used for |
|
||||||
|
|-----|------------|----------|
|
||||||
|
| Microsoft Graph | `User.Read.All` | Look up users (unattended) |
|
||||||
|
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners (unattended) |
|
||||||
|
| Microsoft Graph | `Directory.Read.All` | Expand M365/AAD group membership in the user-access audit |
|
||||||
|
| Microsoft Graph | `Sites.FullControl.All` | Site operations (unattended) |
|
||||||
|
| Microsoft Graph | `Mail.Send` | Send emailed scheduled reports |
|
||||||
|
| SharePoint | `Sites.FullControl.All` | App-only CSOM |
|
||||||
|
|
||||||
|
**Delegated permissions** (interactive connect flow — only for profiles without a certificate):
|
||||||
|
|
||||||
|
| API | Permission | Used for |
|
||||||
|
|-----|------------|----------|
|
||||||
|
| Microsoft Graph | `User.Read` | Signed-in user's basic profile |
|
||||||
|
| Microsoft Graph | `User.Read.All` | Look up users by email/UPN |
|
||||||
|
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners |
|
||||||
|
| Microsoft Graph | `Sites.Read.All` | Resolve a site's `groupId` from its `siteId` |
|
||||||
|
| SharePoint | `AllSites.FullControl` | CSOM — site permissions, content, admin operations |
|
||||||
|
|
||||||
|
All permissions above require **admin consent**.
|
||||||
|
|
||||||
> **HTTPS note.** The sign-in app is a confidential (Web) client, so Entra requires its `/signin-oidc` redirect URI to be **HTTPS** — plain HTTP is allowed only for `http://localhost`, not a LAN host/IP. To run OIDC on a plain-HTTP LAN deployment, put the app behind an HTTPS-terminating reverse proxy: register `https://your-host/signin-oidc`, and the app honours `X-Forwarded-Proto` (see `UseForwardedHeaders`) to build the correct `https` redirect. Without a proxy, OIDC sign-in won't work over a non-localhost HTTP host — use the local email/password login instead.
|
> **HTTPS note.** The sign-in app is a confidential (Web) client, so Entra requires its `/signin-oidc` redirect URI to be **HTTPS** — plain HTTP is allowed only for `http://localhost`, not a LAN host/IP. To run OIDC on a plain-HTTP LAN deployment, put the app behind an HTTPS-terminating reverse proxy: register `https://your-host/signin-oidc`, and the app honours `X-Forwarded-Proto` (see `UseForwardedHeaders`) to build the correct `https` redirect. Without a proxy, OIDC sign-in won't work over a non-localhost HTTP host — use the local email/password login instead.
|
||||||
|
|
||||||
|
> **Reverse-proxy host.** Set `App__Domain` so the app builds every redirect (cookie login, OIDC) against the public domain regardless of what host the proxy forwards. Without it, a proxy that doesn't forward the `Host` header makes the app 302 to the internal `IP:port` it actually received.
|
||||||
|
|
||||||
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
|
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
|
||||||
|
|
||||||
## Installation — Docker (prebuilt image)
|
## Installation — Docker (prebuilt image)
|
||||||
@@ -130,3 +161,26 @@ Runs in `Development` mode — OIDC off, auto-login as Admin. No Entra config ne
|
|||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
.NET 10 · Blazor Server · Microsoft Graph SDK · PnP.Framework · Serilog · CsvHelper
|
.NET 10 · Blazor Server · Microsoft Graph SDK · PnP.Framework · Serilog · CsvHelper
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
NuGet packages and their pinned versions (see [`SharepointToolbox.Web.csproj`](SharepointToolbox.Web.csproj)). Last reviewed 2026-06-26.
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `Azure.Identity` | 1.21.0 | Entra ID token credentials — app-only certificate and client-secret auth |
|
||||||
|
| `CsvHelper` | 33.1.0 | CSV parsing for bulk import/export (sites, members, folder structures) |
|
||||||
|
| `Microsoft.AspNetCore.Authentication.OpenIdConnect` | 10.0.9 | OIDC "Sign in with Microsoft" app sign-in |
|
||||||
|
| `Microsoft.Graph` | 6.2.0 | Microsoft Graph SDK — users, groups, sites, mail |
|
||||||
|
| `Microsoft.Kiota.Abstractions` | 2.0.0 | Graph SDK request runtime (abstractions) |
|
||||||
|
| `Microsoft.Kiota.Authentication.Azure` | 2.0.0 | Graph SDK Azure auth provider |
|
||||||
|
| `Microsoft.Kiota.Http.HttpClientLibrary` | 2.0.0 | Graph SDK HTTP transport |
|
||||||
|
| `Microsoft.Kiota.Serialization.Form` | 2.0.0 | Graph SDK form serialization |
|
||||||
|
| `Microsoft.Kiota.Serialization.Json` | 2.0.0 | Graph SDK JSON serialization |
|
||||||
|
| `Microsoft.Kiota.Serialization.Multipart` | 2.0.0 | Graph SDK multipart serialization |
|
||||||
|
| `Microsoft.Kiota.Serialization.Text` | 2.0.0 | Graph SDK text serialization |
|
||||||
|
| `PnP.Framework` | 1.19.0 | SharePoint CSOM — site permissions, content, admin operations |
|
||||||
|
| `Serilog.AspNetCore` | 10.0.0 | Structured logging integration |
|
||||||
|
| `Serilog.Sinks.File` | 7.0.0 | Rolling-file log sink |
|
||||||
|
|
||||||
|
To check for newer releases: `dotnet list package --outdated`.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Security findings
|
||||||
|
|
||||||
|
Review date: 2026-06-11. Items 1–4 and 6 fixed the same day; #5 reviewed and accepted by design.
|
||||||
|
|
||||||
|
Second pass (OWASP Top 10), 2026-06-11: items 7–11 below found and fixed the same day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. [HIGH] ✅ FIXED — Open redirect in `/connect/initiate` leaked SharePoint session tokens
|
||||||
|
|
||||||
|
**Was:** `returnUrl` from the query string was stored verbatim in the OAuth flow state and used as the final redirect target after the code exchange — with `token_key` (a 2-minute, redeemable handle to the connected client's refresh token) appended. `…?returnUrl=https://evil.com` leaked that handle off-domain, enabling connection hijack.
|
||||||
|
|
||||||
|
**Fix:** `returnUrl` is now constrained to a site-relative path via the new `string.ToLocalReturnUrl()` helper before it is stored ([Infrastructure/OAuth/OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)). The helper rejects absolute *and* protocol-relative (`//host`, `/\host`) URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. [MEDIUM] ✅ FIXED — CSV formula injection in audit-log exports
|
||||||
|
|
||||||
|
**Was:** Both audit CSV exporters quoted per RFC 4180 but didn't neutralize a leading `=` `+` `-` `@`, so user-controlled fields (display/client/site names, details) could execute as formulas in Excel.
|
||||||
|
|
||||||
|
**Fix:** Both paths now use `CsvSanitizer.Escape` (which neutralizes formula prefixes): the `/audit/export` endpoint ([Program.cs](Program.cs)) and `AuditService.ExportCsvAsync` ([Services/Audit/AuditService.cs](Services/Audit/AuditService.cs)). The local `CsvEscape`/`Esc` duplicates were removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. [LOW-MEDIUM] ✅ FIXED — Spoofable `X-Forwarded-Proto` could yield a non-Secure auth cookie
|
||||||
|
|
||||||
|
**Was:** Forwarded headers are trusted from any source (proxy IP unknown inside the container network), and the prod auth cookie used `SecurePolicy = SameAsRequest` — so a spoofed `X-Forwarded-Proto: http` or a direct plaintext hit could emit a non-Secure cookie.
|
||||||
|
|
||||||
|
**Fix:** Prod auth cookie now uses `CookieSecurePolicy.Always` ([Program.cs](Program.cs)), so the Secure flag is set regardless of the (untrusted) forwarded scheme. The forwarded-headers trust config is unchanged (still required behind the proxy); the residual XFF concern is limited — client IP is not used for auth and is not recorded in the audit log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. [LOW] ✅ FIXED — Open redirect in `/account/login/entra` and `/account/login/dev`
|
||||||
|
|
||||||
|
**Was:** `returnUrl` was used directly as the post-auth redirect on the entra and dev sign-in paths; only local-login validated it (and even that check allowed protocol-relative `//evil.com`).
|
||||||
|
|
||||||
|
**Fix:** All three sign-in redirects now route `returnUrl` through `ToLocalReturnUrl()` ([Program.cs](Program.cs)), which also closes the protocol-relative gap in the old local-login check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. [LOW] ⚪ ACCEPTED (by design) — Authenticated users can download any export / report
|
||||||
|
|
||||||
|
**Where:** `/export/download/{fileName}` and `/reports/download/{id}` ([Program.cs](Program.cs)).
|
||||||
|
|
||||||
|
**Decision:** Not changed. The app has no per-user profile authorization anywhere — any tech may select and use any shared profile (see the "standard techs use profiles without sign-in" design), and `GeneratedReport` carries only a `ProfileId`, no owner. Reports are listed and shared *per client/profile*, not per user, so per-user download scoping would contradict the product model rather than fix a boundary. Both endpoints already require authentication and strip path traversal (`Path.GetFileName`). Revisit only if a per-tech profile ACL is introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. [LOW] ✅ FIXED — Admin pages gated only by a render-time role check
|
||||||
|
|
||||||
|
**Was:** `/admin/users` and `/admin/audit` used `@attribute [Authorize]` (any authenticated user) plus an in-markup `if (Role != Admin) return;`.
|
||||||
|
|
||||||
|
**Fix:** Added an `Admin` authorization policy (`RequireClaim("app_role", "Admin")`) in [Program.cs](Program.cs) and both pages now use `[Authorize(Policy = "Admin")]` ([UserManagement.razor](Components/Pages/Admin/UserManagement.razor), [AuditLogs.razor](Components/Pages/Admin/AuditLogs.razor)). A claim-value policy (not `[Authorize(Roles=…)]`) was used deliberately: the local/dev sign-in identities don't set a `ClaimTypes.Role` claim, so a Roles check would have silently denied local admins. The in-component checks were kept as defense-in-depth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. [MEDIUM] ✅ FIXED — No brute-force protection on local sign-in (OWASP A07)
|
||||||
|
|
||||||
|
**Was:** `/account/local-login` and `UserService.ValidateLocalCredentialsAsync` had no rate limiting and no account lockout, leaving local accounts (incl. the bootstrap admin) open to unlimited password guessing — relevant given the plain-HTTP/LAN bootstrap deployment mode.
|
||||||
|
|
||||||
|
**Fix:** Two layers. (1) A per-account lockout on `AppUser` (`FailedLoginCount`/`LockoutEndUtc`) — 5 consecutive failures → 15-minute lock, counter cleared on success ([UserService.cs](Services/Auth/UserService.cs), [AppUser.cs](Core/Models/AppUser.cs)). A locked account is refused before the password is even checked, and failure stays generic (no enumeration). (2) An IP-partitioned fixed-window rate limiter (`"login"` policy, 10/min) on `/account/local-login` and `/account/login/entra` ([Program.cs](Program.cs)). The account lockout is the control that holds up when `X-Forwarded-For` is spoofed/rotated (forwarded headers are trusted from any source — see #3); the IP limiter is the coarse volumetric layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. [MEDIUM] ✅ FIXED — Missing security response headers (OWASP A05)
|
||||||
|
|
||||||
|
**Was:** No `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, or `Referrer-Policy`. A clickjacked page could drive the victim's live Blazor Server circuit, and there was no CSP backstop against injected script.
|
||||||
|
|
||||||
|
**Fix:** A middleware emits these on every response ([Program.cs](Program.cs)). CSP is tuned to the app: all scripts are external so `script-src 'self'` (no `unsafe-*`); `style-src` keeps `'unsafe-inline'` for the login page's inline `<style>` and the components' `style=""`; `img-src 'self' data:` for base64 client logos / download links; `connect-src 'self'` for the SignalR WebSocket; `frame-ancestors 'none'` (+ `X-Frame-Options: DENY`) blocks clickjacking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. [LOW-MEDIUM] ✅ FIXED — Container ran as root; `/data` world-readable (OWASP A05 / A02)
|
||||||
|
|
||||||
|
**Was:** The [Dockerfile](Dockerfile) had no `USER` directive, so the process ran as root and the `/data` volume (Data Protection keys, app-only certs, user store) had default permissions. The DP keys decrypt the auth cookie, the browser-stored SharePoint refresh tokens, and the on-disk certs — a large blast radius for anyone able to read the volume.
|
||||||
|
|
||||||
|
**Fix:** Final image now creates `/data` owned by the shipped non-root `app` user with mode `0700`, then `USER app` ([Dockerfile](Dockerfile)). Docker seeds a fresh named volume from that ownership/mode, so the running user can write it while other host users can't read the keys/certs at rest. (DP keys themselves remain unencrypted at rest — inherent to file-based keys in a container; the volume permissions are the mitigation.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. [LOW] ✅ FIXED — Raw token-endpoint response reflected into the redirect URL (OWASP A09)
|
||||||
|
|
||||||
|
**Was:** `/connect/callback` put the raw Azure token-endpoint JSON (and the provider's verbose `error_description`) into `?connect_error=…`, landing in browser history and — behind the proxy — proxy access logs. Those bodies can carry correlation/trace ids and claim hints.
|
||||||
|
|
||||||
|
**Fix:** The token-endpoint failure body and the verbose `error_description` are now logged server-side via `ILogger`; the redirect carries only a generic notice (or the short, safe OAuth `error` code) ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. [LOW] ✅ FIXED — `/connect/callback` threw a 500 when no refresh_token was returned
|
||||||
|
|
||||||
|
**Was:** `root.GetProperty("refresh_token").GetString()!` threw `KeyNotFoundException` (→ 500 + stack trace in non-prod) when the tenant/app withheld `offline_access`.
|
||||||
|
|
||||||
|
**Fix:** Uses `TryGetProperty`; a missing/empty refresh token is logged and redirects with a friendly `connect_error` instead of throwing ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
|
||||||
@@ -2,6 +2,7 @@ using System.Text;
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Web.Services.Export;
|
||||||
using SharepointToolbox.Web.Services.Session;
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
namespace SharepointToolbox.Web.Services.Audit;
|
namespace SharepointToolbox.Web.Services.Audit;
|
||||||
@@ -41,23 +42,18 @@ public class AuditService : IAuditService
|
|||||||
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
||||||
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
||||||
{
|
{
|
||||||
|
// CsvSanitizer adds spreadsheet formula-injection guards (= + - @) on top of
|
||||||
|
// RFC 4180 quoting; the user/display/client/site fields are user-controlled.
|
||||||
sb.AppendLine(string.Join(",",
|
sb.AppendLine(string.Join(",",
|
||||||
CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
|
CsvSanitizer.Escape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
|
||||||
CsvEscape(e.UserEmail),
|
CsvSanitizer.Escape(e.UserEmail),
|
||||||
CsvEscape(e.UserDisplay),
|
CsvSanitizer.Escape(e.UserDisplay),
|
||||||
CsvEscape(e.UserRole.ToString()),
|
CsvSanitizer.Escape(e.UserRole.ToString()),
|
||||||
CsvEscape(e.Action),
|
CsvSanitizer.Escape(e.Action),
|
||||||
CsvEscape(e.ClientName),
|
CsvSanitizer.Escape(e.ClientName),
|
||||||
CsvEscape(string.Join("; ", e.Sites)),
|
CsvSanitizer.Escape(string.Join("; ", e.Sites)),
|
||||||
CsvEscape(e.Details)));
|
CsvSanitizer.Escape(e.Details)));
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CsvEscape(string value)
|
|
||||||
{
|
|
||||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
|
||||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ public interface IUserService
|
|||||||
|
|
||||||
Task<AppUser?> GetByEmailAsync(string email);
|
Task<AppUser?> GetByEmailAsync(string email);
|
||||||
Task<IReadOnlyList<AppUser>> GetAllAsync();
|
Task<IReadOnlyList<AppUser>> GetAllAsync();
|
||||||
Task UpdateRoleAsync(string userId, UserRole role);
|
/// <summary>Persist a new role for the user. Returns the previous role (read from the store).</summary>
|
||||||
|
/// <exception cref="KeyNotFoundException">No user matches <paramref name="userId"/>.</exception>
|
||||||
|
Task<UserRole> UpdateRoleAsync(string userId, UserRole role);
|
||||||
Task DeleteAsync(string userId);
|
Task DeleteAsync(string userId);
|
||||||
|
|
||||||
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
|
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -9,11 +10,13 @@ public class UserService : IUserService
|
|||||||
{
|
{
|
||||||
private readonly UserRepository _repo;
|
private readonly UserRepository _repo;
|
||||||
private readonly IPasswordHasher<AppUser> _hasher;
|
private readonly IPasswordHasher<AppUser> _hasher;
|
||||||
|
private readonly ILogger<UserService> _logger;
|
||||||
|
|
||||||
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher)
|
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
|
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
|
||||||
@@ -56,13 +59,23 @@ public class UserService : IUserService
|
|||||||
|
|
||||||
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
|
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
|
||||||
|
|
||||||
public async Task UpdateRoleAsync(string userId, UserRole role)
|
public async Task<UserRole> UpdateRoleAsync(string userId, UserRole role)
|
||||||
{
|
{
|
||||||
var users = (await _repo.LoadAsync()).ToList();
|
var users = (await _repo.LoadAsync()).ToList();
|
||||||
var user = users.FirstOrDefault(u => u.Id == userId)
|
var user = users.FirstOrDefault(u => u.Id == userId)
|
||||||
?? throw new KeyNotFoundException($"User {userId} not found.");
|
?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users.");
|
||||||
|
|
||||||
|
var oldRole = user.Role;
|
||||||
user.Role = role;
|
user.Role = role;
|
||||||
await _repo.UpsertAsync(user);
|
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 Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
|
||||||
@@ -96,21 +109,58 @@ public class UserService : IUserService
|
|||||||
return 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)
|
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
|
||||||
{
|
{
|
||||||
var user = await _repo.FindByEmailAsync(email);
|
var user = await _repo.FindByEmailAsync(email);
|
||||||
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
|
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
|
||||||
return null;
|
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);
|
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||||
if (result == PasswordVerificationResult.Failed)
|
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;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Transparently upgrade the hash if the algorithm parameters changed
|
// Transparently upgrade the hash if the algorithm parameters changed
|
||||||
if (result == PasswordVerificationResult.SuccessRehashNeeded)
|
if (result == PasswordVerificationResult.SuccessRehashNeeded)
|
||||||
user.PasswordHash = _hasher.HashPassword(user, password);
|
user.PasswordHash = _hasher.HashPassword(user, password);
|
||||||
|
|
||||||
user.LastLogin = DateTimeOffset.UtcNow;
|
// Success → clear the failure trail.
|
||||||
|
user.FailedLoginCount = 0;
|
||||||
|
user.LockoutEndUtc = null;
|
||||||
|
user.LastLogin = now;
|
||||||
await _repo.UpsertAsync(user);
|
await _repo.UpsertAsync(user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,19 +26,19 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.Identity" Version="1.14.1" />
|
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
|
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
|
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="2.0.0" />
|
||||||
<PackageReference Include="PnP.Framework" Version="1.18.0" />
|
<PackageReference Include="PnP.Framework" Version="1.19.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user