Commit:
a1a5491Parent:
abca893Add cookie-based admin auth for the dashboard.
Protect the app when Admin:Hash is configured, using BCrypt verification and ASP.NET cookie authentication without a user database. Co-authored-by: Cursor <cursoragent@cursor.com>
AdminAuthService.cs
+192
-0
diff --git a/AdminAuthService.cs b/AdminAuthService.cs
new file mode 100644
index 0000000..4b68607
@@ -0,0 +1,192 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
namespace BfiMonitor;
public sealed class AdminAuthService(
ILogger<AdminAuthService> logger,
IOptions<AdminAuthOptions> options,
TimeProvider timeProvider,
IHttpContextAccessor httpContextAccessor
)
{
private readonly AdminAuthOptions authOptions = options.Value;
public bool IsEnabled => !string.IsNullOrEmpty(authOptions.Hash);
public bool ValidatePassword(string password) => BCrypt.Net.BCrypt.Verify(password, authOptions.Hash);
public async Task SignInAsync()
{
if (httpContextAccessor.HttpContext is not { } httpContext)
{
logger.LogError("No HttpContext when signing in.");
return;
}
var now = timeProvider.GetUtcNow();
var authProperties = new AuthenticationProperties
{
IsPersistent = true,
AllowRefresh = true,
IssuedUtc = now,
ExpiresUtc = now + authOptions.LoginTime,
};
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(
new ClaimsIdentity(
[new Claim(ClaimTypes.Role, "Admin")],
CookieAuthenticationDefaults.AuthenticationScheme
)
),
authProperties
);
}
public async Task SignOutAsync()
{
if (httpContextAccessor.HttpContext is not { } httpContext)
{
logger.LogError("No HttpContext when signing out.");
return;
}
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
public sealed class AdminAuthOptions
{
public string Hash { get; set; } = "";
public TimeSpan LoginTime { get; set; } = TimeSpan.FromDays(14);
}
public static class AdminAuthServiceCollectionExtensions
{
public static IServiceCollection AddAdminAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<AdminAuthOptions>().BindConfiguration("Admin");
services.AddHttpContextAccessor();
services.AddTransient<AdminAuthService>();
var authOptions = configuration.GetSection("Admin").Get<AdminAuthOptions>() ?? new AdminAuthOptions();
if (string.IsNullOrEmpty(authOptions.Hash))
{
return services;
}
services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login.html";
options.LogoutPath = "/api/admin/logout";
options.AccessDeniedPath = "/login.html";
options.Cookie.Name = "BfiMonitor.AdminAuth";
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.Equals(LoginPath, StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
});
return services;
}
public static IApplicationBuilder UseAdminAuth(this IApplicationBuilder app)
{
var authOptions = app.ApplicationServices.GetRequiredService<IOptions<AdminAuthOptions>>().Value;
if (string.IsNullOrEmpty(authOptions.Hash))
{
return app;
}
app.UseAuthentication();
app.Use(RequireAdminForApi);
return app;
}
public static IApplicationBuilder UseRequireAdminForApp(this IApplicationBuilder app)
{
var authOptions = app.ApplicationServices.GetRequiredService<IOptions<AdminAuthOptions>>().Value;
if (string.IsNullOrEmpty(authOptions.Hash))
{
return app;
}
app.Use(RequireAdminForAppContent);
return app;
}
private static readonly PathString LoginPath = "/login.html";
private static bool IsPublicPath(PathString path)
{
if (path.Equals(LoginPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return path.Equals("/styles.css", StringComparison.OrdinalIgnoreCase);
}
private static bool IsAnonymousApiPath(PathString path) =>
path.StartsWithSegments("/api/auth", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/api/admin/login", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/api/admin/logout", StringComparison.OrdinalIgnoreCase);
private static async Task RequireAdminForApi(HttpContext context, RequestDelegate next)
{
var path = context.Request.Path;
if (!path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || IsAnonymousApiPath(path))
{
await next(context);
return;
}
if (!context.User.IsInRole("Admin"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
}
private static Task RequireAdminForAppContent(HttpContext context, RequestDelegate next)
{
var path = context.Request.Path;
if (IsPublicPath(path) || path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
{
return next(context);
}
if (!context.User.IsInRole("Admin"))
{
context.Response.Redirect(LoginPath);
return Task.CompletedTask;
}
return next(context);
}
}
internal sealed record AdminLoginRequest(string Password);
BfiMonitor.csproj
+1
-0
diff --git a/BfiMonitor.csproj b/BfiMonitor.csproj
index b38ec04..887c5c7 100644
@@ -23,6 +23,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.4.26230.115" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
Program.cs
+41
-0
diff --git a/Program.cs b/Program.cs
index 4fd4c5b..99e43ed 100644
@@ -8,6 +8,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
builder.Services.AddOptions<MonitorOptions>().BindConfiguration("Monitor");
builder.Services.AddAdminAuth(builder.Configuration);
builder.Services.AddGrpcClient<SmsSender.SmsSenderClient>(
(sp, o) =>
@@ -47,10 +48,50 @@ var app = builder.Build();
await app.Services.GetRequiredService<IntervalScheduler>().ApplyStoredIntervalAsync();
app.UseAdminAuth();
app.UseRequireAdminForApp();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet(
"/api/auth/status",
(AdminAuthService auth, HttpContext context) =>
Results.Json(new { Required = auth.IsEnabled, Authenticated = context.User.IsInRole("Admin") })
)
.AllowAnonymous();
app.MapPost(
"/api/admin/login",
async (AdminLoginRequest request, AdminAuthService auth) =>
{
if (!auth.IsEnabled)
{
return Results.Ok();
}
if (string.IsNullOrWhiteSpace(request.Password) || !auth.ValidatePassword(request.Password))
{
return Results.Unauthorized();
}
await auth.SignInAsync();
return Results.Ok();
}
)
.AllowAnonymous();
app.MapPost(
"/api/admin/logout",
async (AdminAuthService auth) =>
{
await auth.SignOutAsync();
return Results.NoContent();
}
)
.AllowAnonymous();
app.MapGet(
"/api/status",
async (IOptions<MonitorOptions> options, ScreeningRepository repository, CancellationToken ct) =>
{
appsettings.Development.json
+4
-0
diff --git a/appsettings.Development.json b/appsettings.Development.json
index 1c33198..52b0684 100644
@@ -4,6 +4,10 @@
"IntervalSeconds": 3600,
"SmsSenderAddress": "http://localhost:50051"
},
"Admin": {
"Hash": "$2a$12$8L/dvZAqknR9Ku2k5D8El.tuJfzZCbXGZoWbB2RHy/MRNeD0yW5CG",
"LoginTime": "14.00:00:00"
},
"Logging": {
"LogLevel": {
"Default": "Debug",
appsettings.json
+4
-0
diff --git a/appsettings.json b/appsettings.json
index 144c68a..778134f 100644
@@ -7,6 +7,10 @@
"Url": "https://whatson.bfi.org.uk/imax/Online/default.asp?BOparam::WScontent::loadArticle::permalink=odyssey-the-film-imax-70mm-2026",
"PhoneNumbers": ["+46722177038"]
},
"Admin": {
"Hash": "",
"LoginTime": "14.00:00:00"
},
"Logging": {
"LogLevel": {
"Default": "Information",
packages.lock.json
+6
-0
diff --git a/packages.lock.json b/packages.lock.json
index 651a915..f06790c 100644
@@ -2,6 +2,12 @@
"version": 1,
"dependencies": {
"net11.0": {
"BCrypt.Net-Next": {
"type": "Direct",
"requested": "[4.0.3, )",
"resolved": "4.0.3",
"contentHash": "W+U9WvmZQgi5cX6FS5GDtDoPzUCV4LkBLkywq/kRZhuDwcbavOzcDAr3LXJFqHUi952Yj3LEYoWW0jbEUQChsA=="
},
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.2.6, )",
wwwroot/app.js
+53
-14
diff --git a/wwwroot/app.js b/wwwroot/app.js
index 83d1cac..a753417 100644
@@ -42,6 +42,7 @@ const saveSettingsBtn = document.getElementById("save-settings-btn");
const openSettingsBtn = document.getElementById("open-settings-btn");
const closeSettingsDialogBtn = document.getElementById("close-settings-dialog");
const cancelSettingsDialogBtn = document.getElementById("cancel-settings-dialog");
const logoutBtn = document.getElementById("logout-btn");
let selectedMonitorId = null;
let editingMonitorId = null;
@@ -106,15 +107,36 @@ function formatPhoneNumbersSummary(numbers) {
return `${numbers[0]} +${numbers.length - 1} more`;
}
async function apiFetch(url, options) {
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login.html";
throw new Error("Unauthorized");
}
return res;
}
async function loadAuthStatus() {
const res = await fetch("/api/auth/status");
if (!res.ok) throw new Error("Failed to load auth status");
return res.json();
}
async function logout() {
await fetch("/api/admin/logout", { method: "POST" });
window.location.href = "/login.html";
}
async function loadStatus() {
const res = await fetch("/api/status");
const res = await apiFetch("/api/status");
if (!res.ok) throw new Error("Failed to load status");
return res.json();
}
async function loadMonitorings() {
const includeArchived = showArchivedToggle.checked ? "true" : "false";
const res = await fetch(`/api/monitorings?includeArchived=${includeArchived}`);
const res = await apiFetch(`/api/monitorings?includeArchived=${includeArchived}`);
if (!res.ok) throw new Error("Failed to load monitorings");
return res.json();
}
@@ -124,7 +146,7 @@ async function loadDetections() {
if (selectedMonitorId !== null) {
params.set("monitorId", String(selectedMonitorId));
}
const res = await fetch(`/api/detections?${params}`);
const res = await apiFetch(`/api/detections?${params}`);
if (!res.ok) throw new Error("Failed to load detections");
return res.json();
}
@@ -313,7 +335,7 @@ function renderDetections(detections) {
}
async function openDetection(id) {
const res = await fetch(`/api/detections/${id}`);
const res = await apiFetch(`/api/detections/${id}`);
if (!res.ok) return;
const detection = await res.json();
@@ -402,7 +424,7 @@ async function updateMonitoring(event) {
phoneNumbers: parsePhoneNumbers(editPhonesInput.value),
};
const res = await fetch(`/api/monitorings/${editingMonitorId}`, {
const res = await apiFetch(`/api/monitorings/${editingMonitorId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -432,7 +454,7 @@ async function createMonitoring(event) {
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers : null,
};
const res = await fetch("/api/monitorings", {
const res = await apiFetch("/api/monitorings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -450,7 +472,7 @@ async function createMonitoring(event) {
}
async function pauseMonitoring(id) {
const res = await fetch(`/api/monitorings/${id}/pause`, { method: "POST" });
const res = await apiFetch(`/api/monitorings/${id}/pause`, { method: "POST" });
if (!res.ok) {
handleError();
return;
@@ -460,7 +482,7 @@ async function pauseMonitoring(id) {
}
async function playMonitoring(id) {
const res = await fetch(`/api/monitorings/${id}/play`, { method: "POST" });
const res = await apiFetch(`/api/monitorings/${id}/play`, { method: "POST" });
if (!res.ok) {
handleError();
return;
@@ -472,7 +494,7 @@ async function playMonitoring(id) {
async function triggerMonitoring(id, button) {
button.disabled = true;
try {
const res = await fetch(`/api/monitorings/${id}/trigger`, { method: "POST" });
const res = await apiFetch(`/api/monitorings/${id}/trigger`, { method: "POST" });
if (!res.ok) {
handleError();
return;
@@ -487,7 +509,7 @@ async function triggerMonitoring(id, button) {
async function triggerAllMonitorings() {
triggerAllBtn.disabled = true;
try {
const res = await fetch("/api/monitorings/trigger-all", { method: "POST" });
const res = await apiFetch("/api/monitorings/trigger-all", { method: "POST" });
if (!res.ok) {
handleError();
return;
@@ -508,7 +530,7 @@ async function archiveMonitoring(id) {
return;
}
const res = await fetch(`/api/monitorings/${id}/archive`, { method: "POST" });
const res = await apiFetch(`/api/monitorings/${id}/archive`, { method: "POST" });
if (!res.ok) {
handleError();
return;
@@ -537,7 +559,7 @@ async function saveSettings(event) {
saveSettingsBtn.disabled = true;
try {
const res = await fetch("/api/settings", {
const res = await apiFetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -593,5 +615,22 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeDrawer();
});
refresh();
setInterval(refresh, 30_000);
logoutBtn.addEventListener("click", logout);
async function init() {
try {
const status = await loadAuthStatus();
if (status.required && !status.authenticated) {
window.location.href = "/login.html";
return;
}
logoutBtn.classList.toggle("hidden", !status.required);
await refresh();
setInterval(refresh, 30_000);
} catch {
handleError();
}
}
init();
wwwroot/index.html
+1
-0
diff --git a/wwwroot/index.html b/wwwroot/index.html
index 156d8f0..9b3877b 100644
@@ -21,6 +21,7 @@
<span class="pulse"></span>
<span>Loading…</span>
</div>
<button type="button" class="btn btn--ghost hidden" id="logout-btn">Sign out</button>
<button type="button" class="btn btn--ghost" id="open-settings-btn">Settings</button>
</div>
</header>
wwwroot/login.html
+101
-0
diff --git a/wwwroot/login.html b/wwwroot/login.html
new file mode 100644
index 0000000..7e3dbee
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign in · BFI Monitor</title>
<link rel="stylesheet" href="styles.css" />
<style>
.login {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.login__card {
width: min(100%, 24rem);
padding: 2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
}
.login__card h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.login__card p {
margin: 0 0 1.5rem;
color: var(--muted);
}
.login__error {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: var(--radius);
background: rgba(220, 38, 38, 0.1);
color: #fca5a5;
}
.login__field {
display: grid;
gap: 0.5rem;
margin-bottom: 1rem;
}
.login__field label {
font-weight: 600;
}
.login__field input {
width: 100%;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: inherit;
}
</style>
</head>
<body>
<main class="login">
<form class="login__card" id="login-form">
<h1>BFI Monitor</h1>
<p>Sign in to access the dashboard.</p>
<div class="login__error hidden" id="login-error">Incorrect password.</div>
<div class="login__field">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required />
</div>
<button type="submit" class="btn btn--primary" style="width: 100%">Sign in</button>
</form>
</main>
<script>
const form = document.getElementById("login-form");
const error = document.getElementById("login-error");
const passwordInput = document.getElementById("password");
form.addEventListener("submit", async (event) => {
event.preventDefault();
error.classList.add("hidden");
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: passwordInput.value }),
});
if (res.ok) {
window.location.href = "/";
return;
}
error.classList.remove("hidden");
passwordInput.select();
});
</script>
</body>
</html>