Fix stuck-on-loading after sign-in; enable HTTP/LAN local login #3
@@ -1,6 +1,7 @@
|
|||||||
@inject AuthenticationStateProvider AuthProvider
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
@inject IUserService UserService
|
@inject IUserService UserService
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ILogger<AppInitializer> Logger
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using SharepointToolbox.Web.Services.Auth
|
@using SharepointToolbox.Web.Services.Auth
|
||||||
@using SharepointToolbox.Web.Services.Session
|
@using SharepointToolbox.Web.Services.Session
|
||||||
@@ -12,13 +13,30 @@
|
|||||||
{
|
{
|
||||||
var state = await AuthProvider.GetAuthenticationStateAsync();
|
var state = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
var principal = state.User;
|
var principal = state.User;
|
||||||
if (principal.Identity?.IsAuthenticated != true) return;
|
|
||||||
|
if (principal.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("AppInitializer: circuit principal NOT authenticated; UserContext left unseeded → page stays on loading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var email = principal.FindFirst("preferred_username")?.Value
|
var 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)) return;
|
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);
|
var user = await UserService.GetByEmailAsync(email);
|
||||||
if (user is not null) UserContext.Initialize(user);
|
if (user is null)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
UserContext.Initialize(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-1
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
|||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
@@ -43,6 +44,19 @@ builder.Services.AddRazorComponents()
|
|||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
// ── Forwarded headers ─────────────────────────────────────────────────────────
|
||||||
|
// Behind a TLS-terminating reverse proxy the app receives plain HTTP; without this
|
||||||
|
// it would see scheme=http and build an http:// OIDC redirect_uri (which Entra
|
||||||
|
// rejects for non-localhost hosts) and set the auth cookie non-Secure. Honour
|
||||||
|
// X-Forwarded-Proto/For so the app sees the real https scheme + client IP. Proxy IP
|
||||||
|
// is unknown inside the container network, so don't restrict to known proxies.
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Data Protection ───────────────────────────────────────────────────────────
|
// ── Data Protection ───────────────────────────────────────────────────────────
|
||||||
// Keys MUST persist across container recreates: they encrypt the auth cookie AND
|
// Keys MUST persist across container recreates: they encrypt the auth cookie AND
|
||||||
// the app-only certs on disk (/data/appcerts). Default storage is the container's
|
// the app-only certs on disk (/data/appcerts). Default storage is the container's
|
||||||
@@ -100,7 +114,13 @@ else
|
|||||||
options.ClientId = oidc["ClientId"];
|
options.ClientId = oidc["ClientId"];
|
||||||
options.ClientSecret = oidc["ClientSecret"];
|
options.ClientSecret = oidc["ClientSecret"];
|
||||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||||
options.SaveTokens = true;
|
// 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 +
|
||||||
|
// app-only cert paths), and storing them bloats the cookie past ~4 KB so it gets
|
||||||
|
// chunked. The chunked cookie survives the prerender GET but is dropped on the
|
||||||
|
// WebSocket upgrade that establishes the interactive circuit → the circuit comes
|
||||||
|
// up anonymous and the app sticks on "Chargement…". Keeping the cookie small fixes it.
|
||||||
|
options.SaveTokens = false;
|
||||||
options.Scope.Add("openid");
|
options.Scope.Add("openid");
|
||||||
options.Scope.Add("profile");
|
options.Scope.Add("profile");
|
||||||
options.Scope.Add("email");
|
options.Scope.Add("email");
|
||||||
@@ -212,6 +232,34 @@ builder.Services.AddHostedService<ScheduledReportHostedService>();
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
// still sign in via the local email/password form. Only fires while the user store is
|
||||||
|
// empty; set Bootstrap__AdminEmail and Bootstrap__AdminPassword to enable.
|
||||||
|
{
|
||||||
|
var bootEmail = app.Configuration["Bootstrap:AdminEmail"];
|
||||||
|
var bootPass = app.Configuration["Bootstrap:AdminPassword"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(bootEmail) && !string.IsNullOrWhiteSpace(bootPass))
|
||||||
|
{
|
||||||
|
var users = app.Services.GetRequiredService<IUserService>();
|
||||||
|
// Seed if this email has no account yet — covers both an empty store and a store
|
||||||
|
// that already holds an Entra-provisioned user from a failed sign-in attempt.
|
||||||
|
if (await users.GetByEmailAsync(bootEmail) is null)
|
||||||
|
{
|
||||||
|
await users.CreateLocalUserAsync(bootEmail, "Administrator", UserRole.Admin, bootPass);
|
||||||
|
Log.Information("Bootstrap: created local admin {Email}.", bootEmail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("Bootstrap: local admin {Email} already present; skipping seed.", bootEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
|||||||
Reference in New Issue
Block a user