Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df2ef0d1e | |||
| e5785fd769 | |||
| 22370ca4da | |||
| e0a3fbf35b | |||
| 161ed6f80d | |||
| e57d864ab2 |
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ the affected stream while the MXAccess session remains active.
|
|||||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||||
|
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
||||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
# GLAuth — LDAP authn reference for mxaccessgw
|
# GLAuth — LDAP authn reference for mxaccessgw
|
||||||
|
|
||||||
GLAuth is a lightweight LDAP server installed on this dev box at
|
> **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
|
||||||
`C:\publish\glauth\` and run as a Windows service via NSSM. It already
|
> Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
|
||||||
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa
|
> the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
|
||||||
Admin UI's cookie login; this doc captures everything mxaccessgw needs
|
> The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
|
||||||
to consume the same directory so a single set of dev credentials covers
|
|
||||||
both stacks.
|
|
||||||
|
|
||||||
The authoritative copy of LmxOpcUa's reference lives at
|
GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
|
||||||
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to
|
OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
|
||||||
mxaccessgw — what users + groups are already provisioned, how to bind
|
docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
|
||||||
against them, and what's needed to add a gw-specific role.
|
that directory so a single set of dev credentials covers all stacks.
|
||||||
|
|
||||||
|
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
|
||||||
|
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
|
||||||
|
kept only as a rollback option. Do not edit or restart it for new work.)*
|
||||||
|
|
||||||
|
The single source of truth for the shared GLAuth is
|
||||||
|
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
|
||||||
|
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
|
||||||
|
what users + groups are provisioned, how to bind against them, and what's needed to add a
|
||||||
|
gw-specific role.
|
||||||
|
|
||||||
## Connection details
|
## Connection details
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Protocol | LDAP (unencrypted) |
|
| Protocol | LDAP (unencrypted) |
|
||||||
| Host | `localhost` |
|
| Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
|
||||||
| Port | `3893` |
|
| Port | `3893` |
|
||||||
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
|
||||||
| Base DN | `dc=zb,dc=local` |
|
| Base DN | `dc=zb,dc=local` |
|
||||||
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||||
|
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
|
||||||
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||||
|
|
||||||
@@ -59,13 +68,13 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
|
|||||||
`readonly` is the right "negative" case for proving Browse-OK /
|
`readonly` is the right "negative" case for proving Browse-OK /
|
||||||
Write-denied.
|
Write-denied.
|
||||||
|
|
||||||
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy:
|
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
|
||||||
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the
|
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
|
||||||
dashboard login and `DashboardLdapLiveTests` require `admin` to be a
|
These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
|
||||||
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline
|
The dashboard test users are **`multi-role`/`password`** (Administrator) and
|
||||||
GLAuth config — it must be provisioned before dashboard authn or the
|
**`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
|
||||||
LDAP live tests work. See [Provisioning the GwAdmin
|
See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
|
||||||
group](#provisioning-the-gwadmin-group) below.
|
(now-retired) per-box procedure and for the shared-config equivalent.
|
||||||
|
|
||||||
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
||||||
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
||||||
@@ -118,7 +127,7 @@ record:
|
|||||||
```yaml
|
```yaml
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
server: localhost
|
server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
|
||||||
port: 3893
|
port: 3893
|
||||||
useTls: false
|
useTls: false
|
||||||
allowInsecureLdap: true # dev only
|
allowInsecureLdap: true # dev only
|
||||||
@@ -143,13 +152,29 @@ look that up in `groupToRole`.
|
|||||||
|
|
||||||
## Provisioning the GwAdmin group
|
## Provisioning the GwAdmin group
|
||||||
|
|
||||||
|
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
|
||||||
|
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
|
||||||
|
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose up -d --force-recreate
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
|
||||||
|
> rollback reference only — do not use it for new provisioning.
|
||||||
|
|
||||||
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
||||||
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
||||||
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
||||||
`admin` until a `GwAdmin` group exists and `admin` is a member.
|
logins unless the user is a member of `GwAdmin`.
|
||||||
GLAuth's baseline config ships only the five LmxOpcUa role groups, so
|
The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
|
||||||
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP
|
config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
|
||||||
server:
|
`multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**RETIRED — per-box provisioning (reference/rollback only):**
|
||||||
|
|
||||||
1. Edit `C:\publish\glauth\glauth.cfg`
|
1. Edit `C:\publish\glauth\glauth.cfg`
|
||||||
2. Append the group:
|
2. Append the group:
|
||||||
@@ -199,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
|
|||||||
|
|
||||||
## Quick verification
|
## Quick verification
|
||||||
|
|
||||||
From mxaccessgw's dev box, prove the directory is reachable:
|
From mxaccessgw's dev box, prove the shared directory is reachable:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
||||||
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893")
|
# (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
|
||||||
|
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
|
||||||
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
||||||
$ldap.SessionOptions.ProtocolVersion = 3
|
$ldap.SessionOptions.ProtocolVersion = 3
|
||||||
$ldap.SessionOptions.SecureSocketLayer = $false
|
$ldap.SessionOptions.SecureSocketLayer = $false
|
||||||
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=zb,dc=local","admin123")
|
$cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
|
||||||
$ldap.Bind($cred)
|
$ldap.Bind($cred)
|
||||||
"Bind OK"
|
"Bind OK"
|
||||||
```
|
```
|
||||||
@@ -215,17 +241,32 @@ $ldap.Bind($cred)
|
|||||||
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldap://localhost:3893 \
|
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
-D "cn=admin,dc=zb,dc=local" -w admin123 \
|
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
||||||
-b "dc=zb,dc=local" "(uid=admin)"
|
-b "dc=zb,dc=local" "(uid=multi-role)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The response should list `admin`'s entry with `memberOf` populated for
|
The response should list `multi-role`'s entry with `memberOf` including
|
||||||
all five role groups — plus `GwAdmin` once the gateway-specific group
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`.
|
||||||
is provisioned.
|
|
||||||
|
|
||||||
## Service management
|
## Service management
|
||||||
|
|
||||||
|
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
|
||||||
|
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
|
||||||
|
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
|
||||||
|
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
|
||||||
|
>
|
||||||
|
> **Active (shared) management:**
|
||||||
|
> ```bash
|
||||||
|
> ssh 10.100.0.35
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose ps # check container status
|
||||||
|
> docker compose up -d --force-recreate # apply config.toml changes
|
||||||
|
> docker compose logs -f # tail logs
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**RETIRED — per-box NSSM commands (rollback reference):**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Status / start / stop / restart
|
# Status / start / stop / restart
|
||||||
nssm status GLAuth
|
nssm status GLAuth
|
||||||
@@ -259,7 +300,7 @@ applies to mxaccessgw verbatim. Keys that change:
|
|||||||
|
|
||||||
| Field | GLAuth dev value | AD production value |
|
| Field | GLAuth dev value | AD production value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Server` | `localhost` | a domain controller FQDN, or the domain itself |
|
| `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
|
||||||
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
||||||
| `UseTls` | `false` | `true` |
|
| `UseTls` | `false` | `true` |
|
||||||
| `AllowInsecureLdap` | `true` | `false` |
|
| `AllowInsecureLdap` | `true` | `false` |
|
||||||
@@ -275,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
|
|||||||
|
|
||||||
## Security notes for production
|
## Security notes for production
|
||||||
|
|
||||||
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is
|
- **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
|
||||||
unencrypted on disk; anyone with read access to `C:\publish\glauth\`
|
`scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
|
||||||
can SHA256-rainbow-table the entries. Treat the dev creds as
|
`10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
|
||||||
throwaway. Production LDAP is Active Directory.
|
Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
|
||||||
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
||||||
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
||||||
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
||||||
wire. Fine for `localhost`, never expose port 3893 off-box without
|
wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
|
||||||
enabling TLS first.
|
expose port 3893 externally without enabling TLS first.
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public sealed class DashboardOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireHttpsCookie { get; init; } = true;
|
public bool RequireHttpsCookie { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard auth cookie name. When null/blank (the default) the canonical
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
|
||||||
|
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) to give a distinct name to a
|
||||||
|
/// gateway that shares a hostname with another gateway instance — browser cookies are scoped
|
||||||
|
/// by host+path but NOT by port, so two instances on the same host would otherwise clobber
|
||||||
|
/// each other's dashboard session under a shared cookie name. Changing this signs out
|
||||||
|
/// existing dashboard sessions on next deploy.
|
||||||
|
/// </summary>
|
||||||
|
public string? CookieName { get; init; }
|
||||||
|
|
||||||
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
||||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,19 @@
|
|||||||
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
<form method="post" action="/login"> (username/password + hidden returnUrl). A native
|
<form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
|
||||||
form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint
|
form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
|
||||||
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
||||||
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
|
||||||
|
|
||||||
|
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
|
||||||
|
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
|
||||||
|
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
|
||||||
|
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
|
||||||
|
sharing a route. *@
|
||||||
@attribute [AllowAnonymous]
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
<LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
<AntiforgeryToken />
|
<AntiforgeryToken />
|
||||||
</LoginCard>
|
</LoginCard>
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
|
//
|
||||||
|
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
|
||||||
|
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
|
||||||
|
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
|
||||||
|
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
|
||||||
|
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/login",
|
"/auth/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
||||||
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
||||||
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
||||||
|
// This is the canonical default; it is overridden per-environment from
|
||||||
|
// DashboardOptions.CookieName by the PostConfigure below.
|
||||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||||
cookieOptions.Cookie.Path = "/";
|
cookieOptions.Cookie.Path = "/";
|
||||||
cookieOptions.LoginPath = "/login";
|
cookieOptions.LoginPath = "/login";
|
||||||
@@ -77,13 +79,22 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
_ => { });
|
_ => { });
|
||||||
|
|
||||||
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
||||||
// HTTP deployments → SameAsRequest). This overrides the Apply default above.
|
// HTTP deployments → SameAsRequest) and the optional per-environment cookie-name
|
||||||
|
// override. Both run after the inline AddCookie config above, so they win.
|
||||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||||
{
|
{
|
||||||
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
||||||
? CookieSecurePolicy.Always
|
? CookieSecurePolicy.Always
|
||||||
: CookieSecurePolicy.SameAsRequest;
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
|
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
|
||||||
|
// the canonical default set above, so a misconfiguration cannot unname the cookie.
|
||||||
|
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(cookieName))
|
||||||
|
{
|
||||||
|
cookieOptions.Cookie.Name = cookieName;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization(authorization =>
|
services.AddAuthorization(authorization =>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.2.0" />
|
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
|
|||||||
@@ -49,4 +49,23 @@ public sealed class DashboardCookieOptionsTests
|
|||||||
|
|
||||||
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <c>MxGateway:Dashboard:CookieName</c> overrides the dashboard auth
|
||||||
|
/// cookie name, so a gateway instance sharing a hostname with another can be given a
|
||||||
|
/// distinct name (browser cookies are scoped by host+path, not port).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Build_WithCookieNameOverride_UsesConfiguredName()
|
||||||
|
{
|
||||||
|
await using WebApplication app = GatewayApplication.Build(
|
||||||
|
["--MxGateway:Dashboard:CookieName=MxGatewayDashboard.env2"]);
|
||||||
|
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||||
|
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||||
|
|
||||||
|
CookieAuthenticationOptions options = optionsMonitor.Get(
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
Assert.Equal("MxGatewayDashboard.env2", options.Cookie.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,18 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
||||||
|
|
||||||
// GET /login is now served by the [AllowAnonymous] Blazor <Login> component
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
|
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
|
||||||
// endpoint. The form still POSTs to the minimal-API DashboardLoginPost endpoint.
|
// endpoint. The credential POST goes to the DashboardLoginPost endpoint at
|
||||||
|
// /auth/login — a DISTINCT route. The Blazor component endpoint matches all HTTP
|
||||||
|
// methods, so sharing the "/login" route with MapPost previously made POST /login
|
||||||
|
// ambiguous (AmbiguousMatchException → HTTP 500). Pinning the POST to /auth/login
|
||||||
|
// is the regression guard for that fix.
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
|
||||||
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"
|
||||||
|
&& endpoint.RoutePattern.RawText == "/auth/login");
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user