Compare commits
26 Commits
8dfbf7c18a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9480e9537a | |||
| 181c82d310 | |||
| c4a1775d7d | |||
| 0adc2d4300 | |||
| 17f6010a93 | |||
| fe33960c0e | |||
| 84b77d99f6 | |||
| 08dd27d91d | |||
| 38ffe714a2 | |||
| def8647de1 | |||
| f6a36f3bd9 | |||
| c41abc0ea5 | |||
| fe0fcdb7da | |||
| cdc93d041a | |||
| 98683bbd5e | |||
| e190e40b07 | |||
| 5f51e9d16d | |||
| 582cc54189 | |||
| 0ded1af6bc | |||
| ad7d20021d | |||
| 415ec7152f | |||
| 5333a3888e | |||
| 82b7640f31 | |||
| 4c2605b532 | |||
| e3926804a9 | |||
| 80f660053d |
@@ -0,0 +1,21 @@
|
||||
# Copy to `.env` beside docker-compose.prebuilt.yml and fill in real values.
|
||||
# IMPORTANT: do NOT wrap values in quotes — the compose `environment:` list form
|
||||
# embeds the literal quotes, producing a malformed Authority that fails OIDC
|
||||
# metadata discovery (IDX20803).
|
||||
|
||||
# Image tag to run (default: latest)
|
||||
SPTB_TAG=latest
|
||||
|
||||
# Public domain the app is reached at (e.g. sptb.example.com or https://sptb.example.com).
|
||||
# Scheme defaults to https when omitted. The SharePoint-connect redirect URI is derived
|
||||
# from this as <domain>/connect/callback — register that on each client profile's app.
|
||||
# App__Domain=sptb.example.com
|
||||
|
||||
# OIDC app sign-in (required in Production). Authority is derived from TenantId.
|
||||
Oidc__TenantId=00000000-0000-0000-0000-000000000000
|
||||
Oidc__ClientId=00000000-0000-0000-0000-000000000000
|
||||
Oidc__ClientSecret=your-client-secret
|
||||
|
||||
# Optional: seed the first admin while the user store is empty (local form login).
|
||||
# Bootstrap__AdminEmail=admin@example.com
|
||||
# Bootstrap__AdminPassword=change-me
|
||||
@@ -65,3 +65,7 @@ data/exports/
|
||||
data/templates/
|
||||
data/audit.jsonl
|
||||
data/appcerts/
|
||||
|
||||
# Local secrets
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
@@ -146,15 +146,15 @@
|
||||
new("/permissions", "🔐", "tab.permissions", "", "profile"),
|
||||
new("/storage", "💾", "tab.storage", "", "profile"),
|
||||
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
|
||||
new("/versions", "🗂️", "versions.tab", "", "profile"),
|
||||
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
|
||||
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
|
||||
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
|
||||
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
|
||||
new("/versions", "🗂️", "versions.tab", "", "write"),
|
||||
new("/transfer", "📦", "nav.fileTransfer", "", "write"),
|
||||
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "write"),
|
||||
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "write"),
|
||||
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "write"),
|
||||
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
|
||||
new("/user-directory", "📖", "nav.userDirectory", "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("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
|
||||
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
|
||||
@@ -170,6 +170,7 @@
|
||||
.Where(i => i.Scope switch
|
||||
{
|
||||
"profile" => Session.HasProfile,
|
||||
"write" => Session.HasProfile && UserContext.Role >= UserRole.TechN1,
|
||||
"admin" => UserContext.Role == UserRole.Admin,
|
||||
"auth" => UserContext.IsAuthenticated,
|
||||
_ => true
|
||||
@@ -226,8 +227,8 @@
|
||||
}
|
||||
|
||||
// If profile selected but no credentials → show modal (cert profiles never prompt)
|
||||
if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
|
||||
await _credModal.ShowAsync();
|
||||
if (ShouldPromptForCredentials)
|
||||
await _credModal!.ShowAsync();
|
||||
}
|
||||
|
||||
// True when the selected profile authenticates app-only via a stored certificate —
|
||||
@@ -235,6 +236,15 @@
|
||||
private bool CurrentProfileUsesCert =>
|
||||
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()
|
||||
{
|
||||
var uri = new Uri(Nav.Uri);
|
||||
@@ -320,8 +330,9 @@
|
||||
// operating on the old connection.
|
||||
await RefreshCredentialState();
|
||||
// New profile selected and no valid credentials for it → prompt to connect.
|
||||
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
|
||||
await _credModal.ShowAsync();
|
||||
// Standard technicians are never prompted (see ShouldPromptForCredentials).
|
||||
if (ShouldPromptForCredentials)
|
||||
await _credModal!.ShowAsync();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/admin/audit"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
|
||||
@inject IAuditService AuditService
|
||||
@inject IUserContextAccessor UserContext
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/admin/users"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
|
||||
@inject IUserService UserService
|
||||
@inject IUserContextAccessor UserContext
|
||||
@inject IAuditService Audit
|
||||
@@ -87,7 +87,7 @@ else
|
||||
<td style="padding:8px">
|
||||
<select class="form-input" style="width:130px"
|
||||
value="@user.Role"
|
||||
@onchange="e => OnRoleChange(user, e)"
|
||||
@onchange="@(e => OnRoleChange(user, e))"
|
||||
disabled="@(user.Email == UserContext.Email)">
|
||||
@foreach (var role in Enum.GetValues<UserRole>())
|
||||
{
|
||||
@@ -198,9 +198,8 @@ else
|
||||
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
|
||||
try
|
||||
{
|
||||
var oldRole = user.Role;
|
||||
await UserService.UpdateRoleAsync(user.Id, newRole);
|
||||
user.Role = newRole;
|
||||
var oldRole = await UserService.UpdateRoleAsync(user.Id, newRole);
|
||||
user.Role = newRole;
|
||||
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
|
||||
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
|
||||
_message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName);
|
||||
|
||||
@@ -79,6 +79,13 @@
|
||||
{
|
||||
<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)">
|
||||
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
||||
</button>
|
||||
@@ -393,7 +400,10 @@
|
||||
|
||||
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);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
@inject IUserService UserService
|
||||
@inject IUserContextAccessor UserContext
|
||||
@inject NavigationManager Nav
|
||||
@inject ILogger<AppInitializer> Logger
|
||||
@implements IDisposable
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using SharepointToolbox.Web.Services.Auth
|
||||
@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 {
|
||||
private string? _email;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var state = await AuthProvider.GetAuthenticationStateAsync();
|
||||
@@ -20,23 +26,51 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var email = principal.FindFirst("preferred_username")?.Value
|
||||
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
|
||||
if (string.IsNullOrEmpty(email))
|
||||
_email = principal.FindFirst("preferred_username")?.Value
|
||||
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
|
||||
if (string.IsNullOrEmpty(_email))
|
||||
{
|
||||
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);
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace SharepointToolbox.Web.Core.Config;
|
||||
|
||||
/// <summary>
|
||||
/// The app's public domain (e.g. <c>sptb.example.com</c> or <c>https://sptb.example.com</c>),
|
||||
/// configured via <c>App__Domain</c>. Used to derive the SharePoint-connect redirect URI when
|
||||
/// <see cref="ClientConnectOptions.RedirectUri"/> isn't set explicitly.
|
||||
/// </summary>
|
||||
public class AppDomainOptions
|
||||
{
|
||||
public string Domain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an absolute URL for <paramref name="path"/> rooted at the configured domain, or
|
||||
/// <c>null</c> when no domain is set. Defaults to <c>https</c> when the domain has no scheme,
|
||||
/// and tolerates accidental surrounding quotes / trailing slashes (docker-compose's list-form
|
||||
/// env values can embed literal quotes).
|
||||
/// </summary>
|
||||
public string? BuildUrl(string path)
|
||||
{
|
||||
var domain = Domain?.Trim().Trim('"', '\'').TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(domain))
|
||||
return null;
|
||||
|
||||
if (!domain.Contains("://", StringComparison.Ordinal))
|
||||
domain = "https://" + domain;
|
||||
|
||||
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)
|
||||
=> 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? 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
|
||||
# drift; a stale/pre-GA SDK base silently drops the Blazor framework static assets
|
||||
# (blazor.web.js) from the publish manifest → 404 in production. Bump deliberately.
|
||||
# drift between machines; bump deliberately. (SDK 10.0.203 + runtime 10.0.8.)
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
@@ -9,17 +8,33 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& 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
|
||||
COPY ["SharepointToolbox.Web.csproj", "."]
|
||||
RUN dotnet restore
|
||||
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
|
||||
WORKDIR /app
|
||||
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 ["/data"]
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SharepointToolbox.Web.Core.Config;
|
||||
using SharepointToolbox.Web.Core.Helpers;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
using SharepointToolbox.Web.Services.Auth;
|
||||
@@ -51,7 +52,10 @@ public static class OAuthEndpoints
|
||||
TenantId = profile.TenantId,
|
||||
ClientId = profile.ClientId,
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -68,12 +72,18 @@ public static class OAuthEndpoints
|
||||
string? error_description,
|
||||
IOAuthFlowCache flowCache,
|
||||
IOptions<ClientConnectOptions> opts,
|
||||
IHttpClientFactory httpClientFactory) =>
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var log = loggerFactory.CreateLogger("SharepointToolbox.Web.OAuth.Connect");
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
var errMsg = Uri.EscapeDataString(error_description ?? error);
|
||||
return Results.Redirect($"/?connect_error={errMsg}");
|
||||
// The provider's verbose error_description can carry correlation/trace ids and
|
||||
// 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))
|
||||
@@ -103,14 +113,24 @@ public static class OAuthEndpoints
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
|
||||
return Results.Redirect($"/?connect_error={msg}");
|
||||
// The raw token-endpoint body can contain trace ids / claim hints — keep it out of
|
||||
// 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);
|
||||
var root = doc.RootElement;
|
||||
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
|
||||
{
|
||||
|
||||
@@ -1564,7 +1564,7 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
|
||||
<value>Sélectionné</value>
|
||||
</data>
|
||||
<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 name="profiles.tenantid.label" xml:space="preserve">
|
||||
<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>
|
||||
</data>
|
||||
<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 name="profiles.tenantid.label" xml:space="preserve">
|
||||
<value>Tenant ID:</value>
|
||||
|
||||
+145
-16
@@ -6,10 +6,13 @@ using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Serilog;
|
||||
using SharepointToolbox.Web.Core.Config;
|
||||
using SharepointToolbox.Web.Core.Helpers;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Infrastructure.Auth;
|
||||
using SharepointToolbox.Web.Infrastructure.OAuth;
|
||||
@@ -69,6 +72,12 @@ builder.Services.AddDataProtection()
|
||||
// Localization string source — Scoped: one per circuit, with its own explicit culture.
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -103,16 +112,25 @@ else
|
||||
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
|
||||
options.SessionStore = null;
|
||||
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.SlidingExpiration = true;
|
||||
})
|
||||
.AddOpenIdConnect(options =>
|
||||
{
|
||||
var oidc = builder.Configuration.GetSection("Oidc");
|
||||
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0";
|
||||
options.ClientId = oidc["ClientId"];
|
||||
options.ClientSecret = oidc["ClientSecret"];
|
||||
// Strip accidental surrounding quotes/whitespace. docker-compose's `environment` list form
|
||||
// (`- Oidc__TenantId="<guid>"`) embeds the literal quotes in the value, producing a malformed
|
||||
// Authority (…/"<tenant>"/v2.0) that fails metadata discovery with IDX20803. Same trap on the
|
||||
// secret would silently break the token exchange. Trim defensively.
|
||||
static string Clean(string? v) => v?.Trim().Trim('"', '\'') ?? string.Empty;
|
||||
options.Authority = $"https://login.microsoftonline.com/{Clean(oidc["TenantId"])}/v2.0";
|
||||
options.ClientId = Clean(oidc["ClientId"]);
|
||||
options.ClientSecret = Clean(oidc["ClientSecret"]);
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
// Do NOT persist the OIDC access/id/refresh tokens in the auth cookie. They are
|
||||
// never read (SharePoint/Graph auth runs through the separate connect flow +
|
||||
@@ -128,6 +146,34 @@ else
|
||||
options.MapInboundClaims = false;
|
||||
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 =>
|
||||
{
|
||||
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
||||
@@ -158,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) ───────────────────────────────────
|
||||
builder.Services.AddMemoryCache();
|
||||
@@ -167,6 +238,19 @@ builder.Services.AddHttpClient("oauth");
|
||||
// ── ClientConnect options ─────────────────────────────────────────────────────
|
||||
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
|
||||
|
||||
// Derive the SharePoint-connect redirect URI from the app's public domain (App__Domain)
|
||||
// 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.
|
||||
// An explicit RedirectUri still wins, so existing configs are unaffected.
|
||||
builder.Services.PostConfigure<ClientConnectOptions>(opts =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(opts.RedirectUri) &&
|
||||
appDomain.BuildUrl("/connect/callback") is { } callback)
|
||||
{
|
||||
opts.RedirectUri = callback;
|
||||
}
|
||||
});
|
||||
|
||||
// ── App config ────────────────────────────────────────────────────────────────
|
||||
var certsFolder = Path.Combine(dataFolder, "appcerts");
|
||||
builder.Services.Configure<AppConfiguration>(opt =>
|
||||
@@ -257,6 +341,23 @@ var app = builder.Build();
|
||||
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
|
||||
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 ───────────────────────────────────────────────────────
|
||||
// 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
|
||||
@@ -282,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())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
@@ -294,6 +422,7 @@ app.UseStatusCodePagesWithReExecute("/not-found");
|
||||
app.MapStaticAssets();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// ── Login / Logout endpoints ──────────────────────────────────────────────────
|
||||
@@ -332,7 +461,7 @@ if (isDev)
|
||||
CookieAuthenticationDefaults.AuthenticationScheme));
|
||||
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
||||
ctx.Response.Redirect(returnUrl.ToLocalReturnUrl());
|
||||
});
|
||||
}
|
||||
else
|
||||
@@ -342,10 +471,10 @@ else
|
||||
{
|
||||
var props = new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
|
||||
RedirectUri = returnUrl.ToLocalReturnUrl()
|
||||
};
|
||||
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
|
||||
});
|
||||
}).RequireRateLimiting("login");
|
||||
}
|
||||
|
||||
// Local password sign-in — available in every environment.
|
||||
@@ -358,7 +487,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
|
||||
var email = form["email"].ToString();
|
||||
var password = form["password"].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);
|
||||
if (user is null)
|
||||
@@ -376,7 +505,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
|
||||
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
return Results.Redirect(safeReturn);
|
||||
});
|
||||
}).RequireRateLimiting("login");
|
||||
|
||||
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
@@ -431,13 +560,13 @@ app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =
|
||||
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
||||
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
||||
{
|
||||
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
|
||||
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
||||
// CsvSanitizer neutralizes spreadsheet formula prefixes (= + - @) on top of RFC 4180
|
||||
// quoting — audited fields (display name, client/site names, details) are user-controlled.
|
||||
sb.AppendLine(string.Join(",",
|
||||
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
|
||||
Esc(e.Action), Esc(e.ClientName),
|
||||
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
|
||||
CsvSanitizer.Escape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
CsvSanitizer.Escape(e.UserEmail), CsvSanitizer.Escape(e.UserDisplay), CsvSanitizer.Escape(e.UserRole.ToString()),
|
||||
CsvSanitizer.Escape(e.Action), CsvSanitizer.Escape(e.ClientName),
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ A web admin toolbox for Microsoft 365 / SharePoint Online, built with Blazor Ser
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -28,17 +28,72 @@ Set these as environment variables (or in `appsettings.json` under the `Oidc` se
|
||||
| `Oidc__TenantId` | Entra tenant GUID |
|
||||
| `Oidc__ClientId` | App registration client ID |
|
||||
| `Oidc__ClientSecret` | App registration client secret |
|
||||
| `ClientConnect__RedirectUri` | Public callback URL, e.g. `https://your-host/connect/callback` |
|
||||
| `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`) |
|
||||
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
|
||||
|
||||
> In `Development`, OIDC is disabled — the app uses a cookie-only auto-login (hardcoded Admin) for local work.
|
||||
|
||||
**Entra app registration** must include redirect URI `https://your-host/signin-oidc` and the Graph permissions required by the audit/reporting features (`GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`).
|
||||
### Two distinct OAuth flows — two redirect URIs
|
||||
|
||||
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). 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`.
|
||||
|
||||
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.
|
||||
→ 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.
|
||||
|
||||
> **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`.
|
||||
|
||||
## Installation — Docker
|
||||
## Installation — Docker (prebuilt image)
|
||||
|
||||
Pulls the published image from the Gitea registry — no local build needed.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then edit .env with your OIDC values
|
||||
docker compose -f docker-compose.prebuilt.yml pull
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
```
|
||||
|
||||
The compose file reads config from `.env` (see [`.env.example`](.env.example)). Pin a
|
||||
version with `SPTB_TAG`, e.g. `SPTB_TAG=v1.2.0` in `.env`. Don't quote values — the
|
||||
list form embeds literal quotes and breaks OIDC discovery.
|
||||
|
||||
## Installation — Docker (build locally)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -106,3 +161,26 @@ Runs in `Development` mode — OIDC off, auto-login as Admin. No Entra config ne
|
||||
## Tech stack
|
||||
|
||||
.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 SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
using SharepointToolbox.Web.Services.Export;
|
||||
using SharepointToolbox.Web.Services.Session;
|
||||
|
||||
namespace SharepointToolbox.Web.Services.Audit;
|
||||
@@ -41,23 +42,18 @@ public class AuditService : IAuditService
|
||||
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
||||
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(",",
|
||||
CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
CsvEscape(e.UserEmail),
|
||||
CsvEscape(e.UserDisplay),
|
||||
CsvEscape(e.UserRole.ToString()),
|
||||
CsvEscape(e.Action),
|
||||
CsvEscape(e.ClientName),
|
||||
CsvEscape(string.Join("; ", e.Sites)),
|
||||
CsvEscape(e.Details)));
|
||||
CsvSanitizer.Escape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
CsvSanitizer.Escape(e.UserEmail),
|
||||
CsvSanitizer.Escape(e.UserDisplay),
|
||||
CsvSanitizer.Escape(e.UserRole.ToString()),
|
||||
CsvSanitizer.Escape(e.Action),
|
||||
CsvSanitizer.Escape(e.ClientName),
|
||||
CsvSanitizer.Escape(string.Join("; ", e.Sites)),
|
||||
CsvSanitizer.Escape(e.Details)));
|
||||
}
|
||||
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<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);
|
||||
|
||||
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharepointToolbox.Web.Core.Models;
|
||||
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||
|
||||
@@ -9,11 +10,13 @@ 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)
|
||||
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_hasher = hasher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
|
||||
@@ -56,13 +59,23 @@ public class UserService : IUserService
|
||||
|
||||
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 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;
|
||||
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);
|
||||
@@ -96,21 +109,58 @@ public class UserService : IUserService
|
||||
return user;
|
||||
}
|
||||
|
||||
// Brute-force lockout: after this many consecutive failures the account is locked
|
||||
// for the window below. Tuned to stop guessing while staying usable for a fat-fingered
|
||||
// admin. The counter resets on any successful sign-in.
|
||||
private const int LockoutThreshold = 5;
|
||||
private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15);
|
||||
|
||||
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
|
||||
{
|
||||
var user = await _repo.FindByEmailAsync(email);
|
||||
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
|
||||
return null;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Account currently locked → refuse without even checking the password, so a locked
|
||||
// account can't be probed. Returning null (generic failure) avoids account enumeration.
|
||||
if (user.LockoutEndUtc is { } until && until > now)
|
||||
{
|
||||
_logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lock window elapsed → clear it before re-evaluating.
|
||||
if (user.LockoutEndUtc is not null)
|
||||
{
|
||||
user.LockoutEndUtc = null;
|
||||
user.FailedLoginCount = 0;
|
||||
}
|
||||
|
||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
{
|
||||
user.FailedLoginCount++;
|
||||
if (user.FailedLoginCount >= LockoutThreshold)
|
||||
{
|
||||
user.LockoutEndUtc = now + LockoutWindow;
|
||||
_logger.LogWarning(
|
||||
"Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.",
|
||||
user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount);
|
||||
}
|
||||
await _repo.UpsertAsync(user);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transparently upgrade the hash if the algorithm parameters changed
|
||||
if (result == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
user.PasswordHash = _hasher.HashPassword(user, password);
|
||||
|
||||
user.LastLogin = DateTimeOffset.UtcNow;
|
||||
// Success → clear the failure trail.
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockoutEndUtc = null;
|
||||
user.LastLogin = now;
|
||||
await _repo.UpsertAsync(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -26,19 +26,19 @@
|
||||
</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="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
|
||||
<PackageReference Include="PnP.Framework" Version="1.18.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="2.0.0" />
|
||||
<PackageReference Include="PnP.Framework" Version="1.19.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"DataFolder": "/data",
|
||||
"App": {
|
||||
"Domain": ""
|
||||
},
|
||||
"Oidc": {
|
||||
"TenantId": "YOUR_ENTRA_TENANT_ID",
|
||||
"ClientId": "YOUR_SPTB_APP_CLIENT_ID",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build the SharepointToolbox.Web Docker image and push it to the Gitea
|
||||
container registry at git.azuze.fr.
|
||||
|
||||
.DESCRIPTION
|
||||
Builds the image from the local Dockerfile, tags it for the Gitea registry
|
||||
(both the given tag and :latest), logs in, and pushes.
|
||||
|
||||
Login uses a Gitea access token, NOT your account password. Create one at:
|
||||
git.azuze.fr -> Settings -> Applications -> Generate New Token
|
||||
(scope: write:package — read:package too if you also pull)
|
||||
|
||||
Provide credentials via -Username / -Token, or set env vars
|
||||
GITEA_USER / GITEA_TOKEN, or you'll be prompted.
|
||||
|
||||
.EXAMPLE
|
||||
.\build-and-push.ps1
|
||||
Builds and pushes :latest (prompts for token if not cached).
|
||||
|
||||
.EXAMPLE
|
||||
.\build-and-push.ps1 -Tag v1.2.0
|
||||
Builds and pushes both :v1.2.0 and :latest.
|
||||
|
||||
.EXAMPLE
|
||||
$env:GITEA_USER='kawa'; $env:GITEA_TOKEN='xxxx'; .\build-and-push.ps1 -Tag v1.2.0
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Tag = 'latest',
|
||||
[string]$Registry = 'git.azuze.fr',
|
||||
[string]$Owner = 'kawa',
|
||||
[string]$Image = 'sptb-web',
|
||||
[string]$Username = $env:GITEA_USER,
|
||||
[string]$Token = $env:GITEA_TOKEN,
|
||||
[switch]$SkipLatest, # don't also tag/push :latest when -Tag is something else
|
||||
[switch]$NoCache # build with --no-cache
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -LiteralPath $PSScriptRoot
|
||||
|
||||
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
|
||||
function Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Fail 'docker not on PATH. Start Docker Desktop / install docker CLI.'
|
||||
}
|
||||
try { docker info *> $null } catch { Fail 'docker daemon not reachable. Is Docker Desktop running?' }
|
||||
if (-not (Test-Path .\Dockerfile)) { Fail "Dockerfile not found in $PSScriptRoot" }
|
||||
|
||||
$repo = "$Registry/$Owner/$Image"
|
||||
$primary = "${repo}:$Tag"
|
||||
$pushLatest = (-not $SkipLatest) -and ($Tag -ne 'latest')
|
||||
|
||||
# --- build -------------------------------------------------------------------
|
||||
Step "Building $primary"
|
||||
$buildArgs = @('build', '-t', $primary)
|
||||
if ($pushLatest) { $buildArgs += @('-t', "${repo}:latest") }
|
||||
if ($NoCache) { $buildArgs += '--no-cache' }
|
||||
$buildArgs += '.'
|
||||
docker @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'docker build failed.' }
|
||||
|
||||
# --- login -------------------------------------------------------------------
|
||||
if (-not $Username) { $Username = Read-Host "Gitea username for $Registry" }
|
||||
if (-not $Token) {
|
||||
$sec = Read-Host "Gitea access token for $Registry (input hidden)" -AsSecureString
|
||||
$Token = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
|
||||
}
|
||||
if (-not $Username -or -not $Token) { Fail 'Username/token required to push.' }
|
||||
|
||||
Step "Logging in to $Registry as $Username"
|
||||
$Token | docker login $Registry --username $Username --password-stdin
|
||||
if ($LASTEXITCODE -ne 0) { Fail 'docker login failed (bad token? wrong scope?).' }
|
||||
|
||||
# --- push --------------------------------------------------------------------
|
||||
Step "Pushing $primary"
|
||||
docker push $primary
|
||||
if ($LASTEXITCODE -ne 0) { Fail "docker push failed for $primary" }
|
||||
|
||||
if ($pushLatest) {
|
||||
Step "Pushing ${repo}:latest"
|
||||
docker push "${repo}:latest"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "docker push failed for ${repo}:latest" }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Pushed:" -ForegroundColor Green
|
||||
Write-Host " $primary"
|
||||
if ($pushLatest) { Write-Host " ${repo}:latest" }
|
||||
Write-Host ""
|
||||
Write-Host "Pull with: docker pull $primary"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Runs the prebuilt image from the Gitea registry (no local build).
|
||||
# docker compose -f docker-compose.prebuilt.yml pull
|
||||
# docker compose -f docker-compose.prebuilt.yml up -d
|
||||
#
|
||||
# Pin a version by overriding the tag: SPTB_TAG=v1.2.0 docker compose ...
|
||||
# Set the OIDC secrets via a .env file next to this compose file (see below).
|
||||
services:
|
||||
sptb-web:
|
||||
image: git.azuze.fr/kawa/sptb-web:${SPTB_TAG:-latest}
|
||||
container_name: sptb-web
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- sptb-data:/data
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- DataFolder=/data
|
||||
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
|
||||
# redirect URI is derived from it as <domain>/connect/callback.
|
||||
- App__Domain=${App__Domain:-}
|
||||
# OIDC config — overrides the placeholder values baked into appsettings.json.
|
||||
# Authority is derived from TenantId in code; do NOT set an Authority key.
|
||||
# Put real values in a .env file beside this compose file (NO quotes around
|
||||
# values — the list form embeds literal quotes and breaks discovery):
|
||||
# Oidc__TenantId=<entra-tenant-guid>
|
||||
# Oidc__ClientId=<app-client-id>
|
||||
# Oidc__ClientSecret=<app-client-secret>
|
||||
- Oidc__TenantId=${Oidc__TenantId:-}
|
||||
- Oidc__ClientId=${Oidc__ClientId:-}
|
||||
- Oidc__ClientSecret=${Oidc__ClientSecret:-}
|
||||
# Optional: seed first admin while the user store is empty (local form login).
|
||||
- Bootstrap__AdminEmail=${Bootstrap__AdminEmail:-}
|
||||
- Bootstrap__AdminPassword=${Bootstrap__AdminPassword:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# /account/login is anonymous and returns 200; -f fails on >=400.
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/account/login"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
sptb-data:
|
||||
driver: local
|
||||
@@ -12,6 +12,10 @@ services:
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- DataFolder=/data
|
||||
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
|
||||
# redirect URI (<domain>/connect/callback) is derived from it. Set your OIDC values
|
||||
# here too, or pass an env file.
|
||||
- App__Domain=${App__Domain:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# /account/login is anonymous and returns 200 (the app root now 302-redirects
|
||||
|
||||
Reference in New Issue
Block a user