Compare commits
2 commits
b39d607d85
...
08e573d63c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e573d63c | ||
|
|
325e2f1ee9 |
20 changed files with 503 additions and 29 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -11,8 +11,9 @@ obj/
|
|||
*.log
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
#.vscode/
|
||||
#!.vscode/extensions.json
|
||||
.vscode/extensions.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
|
|
|
|||
9
.vscode/docker-compose.debug.yml
vendored
Normal file
9
.vscode/docker-compose.debug.yml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
worktracker:
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
command:
|
||||
- while sleep 1000; do :; done
|
||||
healthcheck:
|
||||
disable: true
|
||||
61
.vscode/launch.json
vendored
Normal file
61
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"compounds": [
|
||||
{
|
||||
"name": "WorkTracker: Debug in Docker",
|
||||
"configurations": [
|
||||
"WorkTracker: Debug App in Docker",
|
||||
"WorkTracker: Debug Edge"
|
||||
],
|
||||
"stopAll": true
|
||||
}
|
||||
],
|
||||
"configurations": [
|
||||
{
|
||||
"name": "WorkTracker: Debug App in Docker",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "WorkTracker: Docker Debug Prepare",
|
||||
"postDebugTask": "WorkTracker: Docker Debug Down",
|
||||
"program": "/workspace/bin/Debug/net10.0/WorkTracker.dll",
|
||||
"args": [
|
||||
"--urls",
|
||||
"http://+:8080"
|
||||
],
|
||||
"cwd": "/workspace",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "http://+:8080",
|
||||
"DOTNET_USE_POLLING_FILE_WATCHER": "1",
|
||||
"UseHttpsRedirection": "false"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/workspace": "${workspaceFolder}"
|
||||
},
|
||||
"pipeTransport": {
|
||||
"pipeProgram": "docker",
|
||||
"pipeArgs": [
|
||||
"exec",
|
||||
"-i",
|
||||
"worktracker-dev",
|
||||
"sh",
|
||||
"-c"
|
||||
],
|
||||
"debuggerPath": "/vsdbg/vsdbg",
|
||||
"pipeCwd": "${workspaceFolder}",
|
||||
"quoteArgs": false
|
||||
},
|
||||
"justMyCode": true,
|
||||
"requireExactSource": false,
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
"name": "WorkTracker: Debug Edge",
|
||||
"type": "msedge",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:8002",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
}
|
||||
113
.vscode/tasks.json
vendored
Normal file
113
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "WorkTracker: Docker Debug Up",
|
||||
"type": "shell",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"compose",
|
||||
"-f",
|
||||
"docker-compose.yml",
|
||||
"-f",
|
||||
"docker-compose.override.yml",
|
||||
"-f",
|
||||
".vscode/docker-compose.debug.yml",
|
||||
"up",
|
||||
"-d",
|
||||
"--build",
|
||||
"worktracker"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "WorkTracker: Docker Debug Build",
|
||||
"type": "shell",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec",
|
||||
"worktracker-dev",
|
||||
"dotnet",
|
||||
"build",
|
||||
"WorkTracker.csproj",
|
||||
"-c",
|
||||
"Debug"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "WorkTracker: Docker Debug Prepare",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"WorkTracker: Docker Debug Up",
|
||||
"WorkTracker: Docker Debug Build"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "WorkTracker: Docker Debug Down",
|
||||
"type": "shell",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"compose",
|
||||
"-f",
|
||||
"docker-compose.yml",
|
||||
"-f",
|
||||
"docker-compose.override.yml",
|
||||
"-f",
|
||||
".vscode/docker-compose.debug.yml",
|
||||
"down"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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">
|
||||
<a class="navbar-brand" href="">WorkTracker</a>
|
||||
</div>
|
||||
|
|
@ -51,13 +55,16 @@
|
|||
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<form action="/api/logout" method="post">
|
||||
<button type="submit" class="nav-link">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@if (AppAuthOptions.Value.Enabled)
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<form action="/api/logout" method="post">
|
||||
<button type="submit" class="nav-link">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="nav-item px-3">
|
||||
|
|
|
|||
|
|
@ -3,9 +3,20 @@
|
|||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.Extensions.Options
|
||||
@using WorkTracker.Configuration
|
||||
|
||||
@inject IOptions<AppAuthOptions> AppAuthOptions
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Change Password</PageTitle>
|
||||
|
||||
@if (!AppAuthOptions.Value.Enabled)
|
||||
{
|
||||
<p>Redirecting...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Change password</h1>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Error))
|
||||
|
|
@ -26,8 +37,17 @@
|
|||
|
||||
<button type="submit" class="btn btn-primary">Change password</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery]
|
||||
public string? Error { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (!AppAuthOptions.Value.Enabled)
|
||||
{
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,20 @@
|
|||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.Extensions.Options
|
||||
@using WorkTracker.Configuration
|
||||
|
||||
@inject IOptions<AppAuthOptions> AppAuthOptions
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Login</PageTitle>
|
||||
|
||||
@if (!AppAuthOptions.Value.Enabled)
|
||||
{
|
||||
<p>Redirecting...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Login</h1>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Error))
|
||||
|
|
@ -28,6 +39,7 @@
|
|||
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery]
|
||||
|
|
@ -39,6 +51,14 @@
|
|||
[SupplyParameterFromQuery]
|
||||
public string? Username { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (!AppAuthOptions.Value.Enabled)
|
||||
{
|
||||
Navigation.NavigateTo(SafeReturnUrl, forceLoad: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string SafeReturnUrl =>
|
||||
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
|
||||
? "/"
|
||||
|
|
|
|||
12
Configuration/AppAuthOptions.cs
Normal file
12
Configuration/AppAuthOptions.cs
Normal 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";
|
||||
}
|
||||
|
|
@ -36,6 +36,6 @@ EXPOSE 8080
|
|||
VOLUME ["/data/couchbase"]
|
||||
|
||||
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"]
|
||||
|
|
|
|||
10
Dockerfile.playwright
Normal file
10
Dockerfile.playwright
Normal 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"]
|
||||
88
Program.cs
88
Program.cs
|
|
@ -1,6 +1,7 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using NLog.Web;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
|
@ -25,18 +26,32 @@ builder.Host.UseNLog();
|
|||
builder.Services.AddRazorComponents()
|
||||
.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.AddAuthorization();
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/logout";
|
||||
options.AccessDeniedPath = "/login";
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
||||
});
|
||||
if (authenticationEnabled)
|
||||
{
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/logout";
|
||||
options.AccessDeniedPath = "/login";
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, DefaultAdminAuthenticationHandler>(
|
||||
DefaultAdminAuthenticationHandler.SchemeName,
|
||||
static _ => { });
|
||||
}
|
||||
|
||||
builder.Services.AddLocalization();
|
||||
|
||||
|
|
@ -86,14 +101,29 @@ app.UseAuthorization();
|
|||
|
||||
app.UseAntiforgery();
|
||||
|
||||
static string GetSafeReturnUrl(string? returnUrl)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
|
||||
? "/"
|
||||
: returnUrl;
|
||||
}
|
||||
|
||||
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 logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var returnUrl = form["returnUrl"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
|
|
@ -125,16 +155,18 @@ app.MapPost("/api/login", async (HttpContext context) =>
|
|||
return;
|
||||
}
|
||||
|
||||
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
|
||||
? "/"
|
||||
: returnUrl;
|
||||
|
||||
context.Response.Redirect(safeReturnUrl);
|
||||
return;
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPost("/api/logout", async (HttpContext context) =>
|
||||
{
|
||||
if (!authenticationEnabled)
|
||||
{
|
||||
context.Response.Redirect("/");
|
||||
return;
|
||||
}
|
||||
|
||||
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
context.Response.Redirect("/login");
|
||||
return;
|
||||
|
|
@ -142,6 +174,12 @@ app.MapPost("/api/logout", async (HttpContext context) =>
|
|||
|
||||
app.MapPost("/api/change-password", async (HttpContext context) =>
|
||||
{
|
||||
if (!authenticationEnabled)
|
||||
{
|
||||
context.Response.Redirect("/");
|
||||
return;
|
||||
}
|
||||
|
||||
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
||||
if (!context.User?.Identity?.IsAuthenticated ?? true)
|
||||
{
|
||||
|
|
@ -159,7 +197,7 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
|
|||
return;
|
||||
}
|
||||
|
||||
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await context.ChallengeAsync();
|
||||
|
|
@ -177,6 +215,24 @@ app.MapPost("/api/change-password", async (HttpContext context) =>
|
|||
return;
|
||||
}).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)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ Quick run (Docker Engine required):
|
|||
|
||||
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:
|
||||
|
||||
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
|
||||
|
|
@ -25,6 +35,14 @@ Development in Docker:
|
|||
- The override file keeps a containerized `dotnet watch` flow for cases where you still want Docker.
|
||||
- The development container mounts the workspace into `/workspace` and stores its Couchbase Lite files under `./.docker-data/couchbase-dev` on the host.
|
||||
|
||||
Debugging in Docker from VS Code:
|
||||
|
||||
- Use the `WorkTracker: Debug in Docker` launch configuration.
|
||||
- VS Code brings up the development container with `docker compose`, builds the app in `Debug`, and launches `WorkTracker.dll` under `vsdbg` inside the container.
|
||||
- When the app reports that it is listening, VS Code automatically opens Microsoft Edge in browser debug mode against http://localhost:8002.
|
||||
- The app remains available at http://localhost:8002 while the debugger is attached.
|
||||
- Stopping the debug session runs `docker compose down` for the debug stack.
|
||||
|
||||
Manual development start:
|
||||
|
||||
- `docker compose up --build`
|
||||
|
|
@ -33,8 +51,13 @@ Manual shutdown:
|
|||
|
||||
- `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:
|
||||
|
||||
- The base compose file remains production-oriented; the override file is the optional containerized development layer.
|
||||
- The first container build takes longer because the dev image installs the .NET debugger.
|
||||
- The Dockerfile uses the .NET 9 `*-noble` images so local builds and container builds stay aligned with the SDK available in VS Code.
|
||||
- The Dockerfile uses the .NET 10 `*-noble` images so local builds and container builds stay aligned with the SDK available in VS Code.
|
||||
|
|
|
|||
48
Services/Auth/DefaultAdminAuthenticationHandler.cs
Normal file
48
Services/Auth/DefaultAdminAuthenticationHandler.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@
|
|||
"Username": "Admin",
|
||||
"Password": "Disagio"
|
||||
},
|
||||
"AppAuth": {
|
||||
"Enabled": false,
|
||||
"DefaultUsername": "Admin",
|
||||
"DefaultUserId": "ADMIN"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
|
|
|||
10
docker-compose.tests.yml
Normal file
10
docker-compose.tests.yml
Normal 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
|
||||
|
|
@ -14,7 +14,7 @@ services:
|
|||
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
|
||||
restart: unless-stopped
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
10
package.json
Normal file
10
package.json
Normal 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
15
playwright.config.ts
Normal 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']]
|
||||
});
|
||||
33
tests/playwright/auth-bypass.spec.ts
Normal file
33
tests/playwright/auth-bypass.spec.ts
Normal 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();
|
||||
});
|
||||
21
tests/playwright/health.spec.ts
Normal file
21
tests/playwright/health.spec.ts
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue