Removed auth
All checks were successful
Publish Container / publish (push) Successful in 5m35s

This commit is contained in:
Marco 2026-04-20 14:11:18 +02:00
commit 08e573d63c
17 changed files with 348 additions and 26 deletions

40
.vscode/tasks.json vendored
View file

@ -68,6 +68,46 @@
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
}, },
"problemMatcher": [] "problemMatcher": []
},
{
"label": "WorkTracker: Docker Playwright Tests",
"type": "shell",
"command": "docker",
"args": [
"compose",
"-f",
"docker-compose.yml",
"-f",
"docker-compose.tests.yml",
"up",
"--build",
"--abort-on-container-exit",
"--exit-code-from",
"playwright",
"playwright"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "WorkTracker: Docker Playwright Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"-f",
"docker-compose.yml",
"-f",
"docker-compose.tests.yml",
"down",
"--volumes"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
} }
] ]
} }

View file

@ -1,4 +1,8 @@
<div class="top-row ps-3 navbar navbar-dark"> @using Microsoft.Extensions.Options
@using WorkTracker.Configuration
@inject IOptions<AppAuthOptions> AppAuthOptions
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="">WorkTracker</a> <a class="navbar-brand" href="">WorkTracker</a>
</div> </div>
@ -51,6 +55,8 @@
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name <span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
</NavLink> </NavLink>
</div> </div>
@if (AppAuthOptions.Value.Enabled)
{
<div class="nav-item px-3"> <div class="nav-item px-3">
<form action="/api/logout" method="post"> <form action="/api/logout" method="post">
<button type="submit" class="nav-link"> <button type="submit" class="nav-link">
@ -58,6 +64,7 @@
</button> </button>
</form> </form>
</div> </div>
}
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<div class="nav-item px-3"> <div class="nav-item px-3">

View file

@ -3,9 +3,20 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.Extensions.Options
@using WorkTracker.Configuration
@inject IOptions<AppAuthOptions> AppAuthOptions
@inject NavigationManager Navigation
<PageTitle>Change Password</PageTitle> <PageTitle>Change Password</PageTitle>
@if (!AppAuthOptions.Value.Enabled)
{
<p>Redirecting...</p>
}
else
{
<h1>Change password</h1> <h1>Change password</h1>
@if (!string.IsNullOrWhiteSpace(Error)) @if (!string.IsNullOrWhiteSpace(Error))
@ -26,8 +37,17 @@
<button type="submit" class="btn btn-primary">Change password</button> <button type="submit" class="btn btn-primary">Change password</button>
</form> </form>
}
@code { @code {
[SupplyParameterFromQuery] [SupplyParameterFromQuery]
public string? Error { get; set; } public string? Error { get; set; }
protected override void OnInitialized()
{
if (!AppAuthOptions.Value.Enabled)
{
Navigation.NavigateTo("/", forceLoad: true);
}
}
} }

View file

@ -3,9 +3,20 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.Extensions.Options
@using WorkTracker.Configuration
@inject IOptions<AppAuthOptions> AppAuthOptions
@inject NavigationManager Navigation
<PageTitle>Login</PageTitle> <PageTitle>Login</PageTitle>
@if (!AppAuthOptions.Value.Enabled)
{
<p>Redirecting...</p>
}
else
{
<h1>Login</h1> <h1>Login</h1>
@if (!string.IsNullOrWhiteSpace(Error)) @if (!string.IsNullOrWhiteSpace(Error))
@ -28,6 +39,7 @@
<button type="submit" class="btn btn-primary">Sign in</button> <button type="submit" class="btn btn-primary">Sign in</button>
</form> </form>
}
@code { @code {
[SupplyParameterFromQuery] [SupplyParameterFromQuery]
@ -39,6 +51,14 @@
[SupplyParameterFromQuery] [SupplyParameterFromQuery]
public string? Username { get; set; } public string? Username { get; set; }
protected override void OnInitialized()
{
if (!AppAuthOptions.Value.Enabled)
{
Navigation.NavigateTo(SafeReturnUrl, forceLoad: true);
}
}
private string SafeReturnUrl => private string SafeReturnUrl =>
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative) string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
? "/" ? "/"

View file

@ -0,0 +1,12 @@
namespace WorkTracker.Configuration;
public sealed class AppAuthOptions
{
public const string SectionName = "AppAuth";
public bool Enabled { get; init; } = false;
public string DefaultUsername { get; init; } = "Admin";
public string DefaultUserId { get; init; } = "ADMIN";
}

View file

@ -36,6 +36,6 @@ EXPOSE 8080
VOLUME ["/data/couchbase"] VOLUME ["/data/couchbase"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1 CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1
ENTRYPOINT ["dotnet", "WorkTracker.dll"] ENTRYPOINT ["dotnet", "WorkTracker.dll"]

10
Dockerfile.playwright Normal file
View file

@ -0,0 +1,10 @@
FROM mcr.microsoft.com/playwright:v1.59.1-noble
WORKDIR /workspace
COPY package.json playwright.config.ts ./
COPY tests ./tests
RUN npm install --no-fund --no-audit
CMD ["npx", "playwright", "test"]

View file

@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Security.Claims; using System.Security.Claims;
using NLog.Web; using NLog.Web;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
@ -25,10 +26,16 @@ builder.Host.UseNLog();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.Configure<AppAuthOptions>(builder.Configuration.GetSection(AppAuthOptions.SectionName));
var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get<AppAuthOptions>() ?? new AppAuthOptions();
var authenticationEnabled = appAuthOptions.Enabled;
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) if (authenticationEnabled)
{
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => .AddCookie(options =>
{ {
options.LoginPath = "/login"; options.LoginPath = "/login";
@ -37,6 +44,14 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
options.SlidingExpiration = true; options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(14); options.ExpireTimeSpan = TimeSpan.FromDays(14);
}); });
}
else
{
builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, DefaultAdminAuthenticationHandler>(
DefaultAdminAuthenticationHandler.SchemeName,
static _ => { });
}
builder.Services.AddLocalization(); builder.Services.AddLocalization();
@ -86,14 +101,29 @@ app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
static string GetSafeReturnUrl(string? returnUrl)
{
return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
? "/"
: returnUrl;
}
app.MapPost("/api/login", async (HttpContext context) => app.MapPost("/api/login", async (HttpContext context) =>
{ {
var form = await context.Request.ReadFormAsync();
var returnUrl = form["returnUrl"].ToString();
var safeReturnUrl = GetSafeReturnUrl(returnUrl);
if (!authenticationEnabled)
{
context.Response.Redirect(safeReturnUrl);
return;
}
var authService = context.RequestServices.GetRequiredService<IAuthService>(); var authService = context.RequestServices.GetRequiredService<IAuthService>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login"); var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString(); var username = form["username"].ToString();
var password = form["password"].ToString(); var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{ {
@ -125,16 +155,18 @@ app.MapPost("/api/login", async (HttpContext context) =>
return; return;
} }
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
? "/"
: returnUrl;
context.Response.Redirect(safeReturnUrl); context.Response.Redirect(safeReturnUrl);
return; return;
}).DisableAntiforgery(); }).DisableAntiforgery();
app.MapPost("/api/logout", async (HttpContext context) => app.MapPost("/api/logout", async (HttpContext context) =>
{ {
if (!authenticationEnabled)
{
context.Response.Redirect("/");
return;
}
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Response.Redirect("/login"); context.Response.Redirect("/login");
return; return;
@ -142,6 +174,12 @@ app.MapPost("/api/logout", async (HttpContext context) =>
app.MapPost("/api/change-password", async (HttpContext context) => app.MapPost("/api/change-password", async (HttpContext context) =>
{ {
if (!authenticationEnabled)
{
context.Response.Redirect("/");
return;
}
var authService = context.RequestServices.GetRequiredService<IAuthService>(); var authService = context.RequestServices.GetRequiredService<IAuthService>();
if (!context.User?.Identity?.IsAuthenticated ?? true) if (!context.User?.Identity?.IsAuthenticated ?? true)
{ {
@ -159,7 +197,7 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
return; return;
} }
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
{ {
await context.ChallengeAsync(); await context.ChallengeAsync();
@ -177,6 +215,24 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
return; return;
}).DisableAntiforgery(); }).DisableAntiforgery();
app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions<AppAuthOptions> authOptions, CouchbaseLiteDatabaseProvider databaseProvider) =>
{
return Results.Json(new
{
status = "Healthy",
authenticationEnabled = authOptions.Value.Enabled,
currentUser = context.User.Identity?.Name,
environment = app.Environment.EnvironmentName,
storage = new
{
appSettingsReady = databaseProvider.AppSettings is not null,
usersReady = databaseProvider.Users is not null,
workDaysReady = databaseProvider.WorkDays is not null
},
timestampUtc = DateTimeOffset.UtcNow
});
});
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL) // Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {

View file

@ -8,6 +8,16 @@ Quick run (Docker Engine required):
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002). 2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
Authentication mode:
- Authentication is disabled by default and every request runs as the configured default admin user. This is intended for deployments fronted by Cloudflare Zero Trust.
- Re-enable the built-in login flow by setting `AppAuth__Enabled=true` or changing `AppAuth:Enabled` to `true` in appsettings.json.
- The default admin identity used while auth is disabled is configured under `AppAuth:DefaultUsername` and `AppAuth:DefaultUserId`.
Health endpoint:
- `GET /healthz` returns JSON health information, including whether built-in authentication is enabled and whether the Couchbase Lite collections initialized correctly.
Docker persistence: Docker persistence:
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container. - The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
@ -41,6 +51,11 @@ Manual shutdown:
- `docker compose down` - `docker compose down`
Docker Playwright smoke tests:
- Run `docker compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit --exit-code-from playwright playwright`
- The suite starts the app in Docker, waits for `/healthz`, and verifies that protected pages load without a login screen.
Notes: Notes:
- The base compose file remains production-oriented; the override file is the optional containerized development layer. - The base compose file remains production-oriented; the override file is the optional containerized development layer.

View file

@ -0,0 +1,48 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using WorkTracker.Configuration;
namespace WorkTracker.Services.Auth;
public sealed class DefaultAdminAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "DefaultAdmin";
private readonly IOptions<AppAuthOptions> appAuthOptions;
public DefaultAdminAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IOptions<AppAuthOptions> appAuthOptions)
: base(options, logger, encoder)
{
this.appAuthOptions = appAuthOptions;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var configuredOptions = appAuthOptions.Value;
var username = string.IsNullOrWhiteSpace(configuredOptions.DefaultUsername)
? "Admin"
: configuredOptions.DefaultUsername.Trim();
var userId = string.IsNullOrWhiteSpace(configuredOptions.DefaultUserId)
? username.ToUpperInvariant()
: configuredOptions.DefaultUserId.Trim();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Name, username),
new(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View file

@ -8,6 +8,11 @@
"Username": "Admin", "Username": "Admin",
"Password": "Disagio" "Password": "Disagio"
}, },
"AppAuth": {
"Enabled": false,
"DefaultUsername": "Admin",
"DefaultUserId": "ADMIN"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

10
docker-compose.tests.yml Normal file
View file

@ -0,0 +1,10 @@
services:
playwright:
build:
context: .
dockerfile: Dockerfile.playwright
depends_on:
worktracker:
condition: service_healthy
environment:
PLAYWRIGHT_BASE_URL: http://worktracker:8080

View file

@ -14,7 +14,7 @@ services:
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase - ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

10
package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "worktracker-playwright",
"private": true,
"scripts": {
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.59.1"
}
}

15
playwright.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8002';
export default defineConfig({
testDir: './tests/playwright',
timeout: 30_000,
fullyParallel: true,
retries: 0,
use: {
baseURL,
trace: 'retain-on-failure'
},
reporter: [['list']]
});

View file

@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
test('home loads without a login screen', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'WorkTracker' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Login' })).toHaveCount(0);
});
test('protected pages are directly available without redirecting to login', async ({ page }) => {
const pages = [
{ path: '/grid', heading: 'Grid View' },
{ path: '/calendar', heading: 'Calendar' },
{ path: '/summary', heading: 'Monthly Summary' },
{ path: '/settings', heading: 'Settings' },
{ path: '/workday', heading: 'Work Day Entry' },
{ path: '/auth', heading: 'You are authenticated' }
];
for (const target of pages) {
await page.goto(target.path);
await expect(page).not.toHaveURL(/\/login/i);
await expect(page.getByRole('heading', { name: target.heading })).toBeVisible();
}
});
test('login route redirects away when built-in authentication is disabled', async ({ page }) => {
await page.goto('/login');
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'WorkTracker' })).toBeVisible();
});

View file

@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
test('health endpoint reports healthy zero-trust mode', async ({ request }) => {
const response = await request.get('/healthz');
expect(response.ok()).toBeTruthy();
const payload = await response.json();
expect(payload.status).toBe('Healthy');
expect(payload.authenticationEnabled).toBe(false);
expect(payload.storage.appSettingsReady).toBe(true);
expect(payload.storage.usersReady).toBe(true);
expect(payload.storage.workDaysReady).toBe(true);
});
test('authenticated identity is the default admin user', async ({ page }) => {
await page.goto('/auth');
await expect(page.getByText('Hello Admin!')).toBeVisible();
});