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
|
*.log
|
||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
#.vscode/
|
||||||
!.vscode/extensions.json
|
#!.vscode/extensions.json
|
||||||
|
.vscode/extensions.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/tasks.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">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="">WorkTracker</a>
|
<a class="navbar-brand" href="">WorkTracker</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,13 +55,16 @@
|
||||||
<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>
|
||||||
<div class="nav-item px-3">
|
@if (AppAuthOptions.Value.Enabled)
|
||||||
<form action="/api/logout" method="post">
|
{
|
||||||
<button type="submit" class="nav-link">
|
<div class="nav-item px-3">
|
||||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
<form action="/api/logout" method="post">
|
||||||
</button>
|
<button type="submit" class="nav-link">
|
||||||
</form>
|
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||||
</div>
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
? "/"
|
? "/"
|
||||||
|
|
|
||||||
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"]
|
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
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.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,18 +26,32 @@ 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)
|
||||||
.AddCookie(options =>
|
{
|
||||||
{
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
options.LoginPath = "/login";
|
.AddCookie(options =>
|
||||||
options.LogoutPath = "/logout";
|
{
|
||||||
options.AccessDeniedPath = "/login";
|
options.LoginPath = "/login";
|
||||||
options.SlidingExpiration = true;
|
options.LogoutPath = "/logout";
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
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();
|
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())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -25,6 +35,14 @@ Development in Docker:
|
||||||
- The override file keeps a containerized `dotnet watch` flow for cases where you still want 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.
|
- 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:
|
Manual development start:
|
||||||
|
|
||||||
- `docker compose up --build`
|
- `docker compose up --build`
|
||||||
|
|
@ -33,8 +51,13 @@ 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.
|
||||||
- The first container build takes longer because the dev image installs the .NET debugger.
|
- 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",
|
"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
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
|
- ${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
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