Admin login¶
Starting in v3.2.0, the web admin (/admin/*.php) authenticates through a
server-rendered HTML login form instead of HTTP Basic Auth. The change exists
purely to fix one user-facing problem: browser password managers
(1Password, Bitwarden, Chrome built-in, Safari Keychain) cannot save or
autofill credentials on the native Basic Auth dialog. The form gives them
two ordinary <input> fields they can introspect.
Sign-in flow¶
- Visit any protected page (
/admin/keys.php,/admin/audit.php,/admin/totp.php). Without a validsc_admin_sidcookie you receive a303redirect to/admin/login.php?next=<requested-path>. - The form posts back to itself with Username, Password, and a hidden CSRF token bound to a placeholder session row that was minted on the GET.
- On success the row is upgraded to a real session and the
sc_admin_sidcookie is set withHttpOnly; Secure; SameSite=Lax. You are redirected to the original?next=target (whitelisted to/admin/*). - If
$admin_totp_secretis configured the new session row carriestotp_pending = 1. Every subsequent web admin request redirects to/admin/totp-verify.php?next=<path>until you complete the second factor, at which pointadmin_session_promote()clears the flag and rotates the CSRF token.
Cookie semantics¶
| Attribute | Value | Why |
|---|---|---|
| Name | sc_admin_sid |
Avoids colliding with PHP's default PHPSESSID. |
| Lifetime | session cookie (no Max-Age) |
Server-side sliding 8h TTL backs the actual freshness check. |
| Path | / |
The cookie is sent to the API endpoint too, but /api/v1/admin/* ignores it (see boundary below). |
| Secure | yes (when the request is HTTPS or X-Forwarded-Proto: https) |
Prevents leaking the session id over plaintext. |
| HttpOnly | yes | JS cannot read the session id. |
| SameSite | Lax |
Form submissions from a same-site context still send the cookie; cross-site GETs (the worst case) do not. |
Rate limiting¶
Failed login attempts are counted per (ip, user) pair in the
auth_rate_limit SQLite table. The first three failures are free; the
fourth and beyond escalate the wait time:
| Attempt # past threshold | Wait |
|---|---|
| 4 | 1 second |
| 5 | 5 seconds |
| 6 | 30 seconds |
| 7 | 5 minutes |
| 8 | 30 minutes |
| 9+ | 60 minutes |
A successful login deletes the row, clearing the counter. The lockout
banner appears in the same <form> slot as the error message, with the
submit button disabled while you are still inside the wait window.
CSRF protection¶
The login form uses the synchroniser-token pattern:
GET /admin/login.phpmints a placeholder row inadmin_sessions(user = '',totp_pending = 1) and embeds the row'scsrfvalue in a hidden form field.POST /admin/login.phplooks up the row via thesc_admin_sidcookie, runshash_equalson the submitted CSRF token, and only then proceeds to the password check.- A successful POST destroys the placeholder row and replaces it with a fresh row scoped to the verified user.
CSRF tokens for post-login admin pages (key creation, key revocation,
TOTP enrollment, settings save) are bound to the live session row. Each
page passes the token through _admin_layout.php's $admin_csrf_token
slot. admin_session_promote() rotates the token when TOTP succeeds, so
a token captured during the pending window cannot survive past the
privilege boundary.
API boundary — /api/v1/admin/* keeps Basic Auth¶
The cookie-session flow ONLY applies to the web admin pages under
/admin/*.php. The JSON admin API at /api/v1/admin/*
(api/v1/handlers/admin_keys.php) continues to authenticate via HTTP
Basic Auth. This is by design — API clients (CLIs, CI scripts, the
official PHP client at clients/php/SubnetCalculatorClient.php) cannot
drive an HTML form. Both paths share the same $admin_user and
$admin_pass_hash; the only difference is how the credentials are
submitted.
The boundary lives in two places:
Subnet-Calculator/api/v1/handlers/admin_keys.phpcallsadmin_authenticate()directly with a JSON-envelope failure callback.Subnet-Calculator/includes/functions-admin-auth.phpretains theadmin_authenticate()helper unchanged for the API; the newadmin_session_require()helper is what every web admin page calls.
TOTP integration¶
/admin/totp-verify.php is reachable while the session row has
totp_pending = 1. On successful verification:
admin_session_promote()flips the flag to 0 and rotates the CSRF.admin_auth_rate_limit_clear()drops any pending lockout for the user.- An
auth.totp.successaudit row is written. - The user is redirected to the
?next=target (default/admin/).
Recovery codes substitute for the TOTP code on the same page; both paths
go through admin_totp_consume_attempt().
Audit-log entries¶
| Event | When | Actor field |
|---|---|---|
auth.login.success |
Password verified, session minted. | $admin_user |
auth.login.fail |
Bad password / CSRF / rate-limited. | submitted username (or null if blank) |
auth.totp.success |
TOTP or recovery code accepted. | session user |
auth.totp.fail |
Bad TOTP / CSRF / rate-limited. | session user |
The meta column carries a JSON object with the reason (one of
password, csrf, rate_limit) and any other context the call site
chose to record (e.g. via: 'header' for API callers).
Operator setup¶
No new configuration knobs ship in v3.2.0 — the same $admin_user and
$admin_pass_hash keys you already set in config.php (or
config-admin.php if you completed the first-run wizard) drive the new
form. Generate the bcrypt hash the same way as before:
Paste the resulting hash into $admin_pass_hash. Existing TOTP secrets
($admin_totp_secret) and recovery codes are reused without
modification.
Logout (v3.2.0, #343)¶
Every signed-in admin page renders a user chip + Sign out button
inside the persistent left sidebar (see Admin navigation sidebar below).
The chip carries aria-label="Signed in as <user>" so screen readers
always identify the active account. The sidebar collapses to a hamburger
drawer on viewports <= 768px; the chip + button remain reachable inside
the open drawer.
The
v3.2.0 #343interim layout placed the chip + Sign out in the top-right of the header, between the breadcrumb and the theme toggle.v3.2.0 #344moved both into the sidebar; the header no longer hosts them.
The button submits a CSRF-protected POST to admin/logout.php:
- Method gate. Anything other than
POSTis rejected with405 Method Not Allowedand anAllow: POSTheader — opening the URL directly in the browser cannot end a session. - CSRF check. The form posts the active session row's token in a
hidden
csrfinput. The handler looks up the row keyed by thesc_admin_sidcookie and compares withhash_equals(). A mismatch or missing cookie returns403 Forbidden. This blocks login-CSRF — a third-party page cannot force a logout via a cross-site form. - Session teardown. On a valid POST,
admin_session_end()deletes the row fromadmin_sessions. - Cookie clear. A fresh
Set-Cookie: sc_admin_sid=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Laxheader invalidates the client cookie. (TheSecureattribute is omitted when the request was not served over HTTPS so local-dev browsers honour the deletion.) - Audit.
auth.logoutis written toadmin_auditwith the user as actor and the session-id prefix in the meta column. - Redirect.
302toadmin/login.php?logged_out=1. The login page renders aYou have been signed out.status banner.
After a successful logout, every other admin URL (admin/keys.php,
admin/audit.php, admin/totp.php) resolves through
admin_session_require() which now sees no cookie row and 303-redirects
back to the login form with a ?next= target.
The legacy Basic-Auth path used by /api/v1/admin/* is untouched — there
is no server-side session to end on the API surface, so clients simply
stop sending the Authorization header.
Test rig drain¶
admin/_test-drain.php already truncates admin_sessions and
auth_rate_limit; no change was required for the logout flow. The audit
table is also drained on each test-suite start, so auth.logout rows
written during a Playwright run do not bleed into subsequent runs.
Admin navigation sidebar (v3.2.0 #344)¶
Signed-in admin pages render a persistent left sidebar that hosts the
admin destination list, the active-user chip, and the Sign out form.
The sidebar replaces the interim .admin-footer-links cluster (#345)
and the header-resident user chip that briefly shipped in v3.2.0 #343.
Markup lives in Subnet-Calculator/admin/_sidebar.php; the surrounding
shell is Subnet-Calculator/templates/_admin_layout.php.
Destinations¶
| Label | Slug | Notes |
|---|---|---|
| API Keys | keys.php |
|
| Audit Log | audit.php |
|
| TOTP / 2FA | totp.php |
|
| Settings | settings.php |
Live link; landing page arrives in #349, until then it 404s. |
Active-page marker¶
The active page is marked twice so the cue is unambiguous to both sighted users and assistive tech:
- The matching link carries
aria-current="page". .admin-sidebar-link.is-activepaints a teal left border plus a tinted background, both resolved throughvar(--color-accent)so light and dark themes share one source of truth.
Mobile collapse¶
At viewports <= 768px the two-column grid collapses to a single
column and the sidebar moves offscreen via
transform: translateX(-100%). A fixed-position hamburger button
(.admin-sidebar-toggle) appears in the top-left; clicking it adds
.open to the sidebar and flips aria-expanded on the button. The
drawer closes on:
- a second click on the hamburger,
- the
Escapekey (focus returns to the toggle), and - a click anywhere outside the sidebar.
The toggle is a real <button>, so Enter and Space activate it
natively with no JS keyboard handling.
Keyboard order¶
The skip-link (Skip to main content) is emitted first in the DOM and
still targets #main-content, giving keyboard users a one-press bypass.
After the skip-link, the sidebar <nav> precedes
<main id="main-content">, so Tab lands on the first sidebar link
before any control inside the page card.
Login page¶
admin/login.php does not set $admin_user_signed_in or
$admin_csrf_token, which suppresses the entire sidebar + hamburger
in _admin_layout.php. The body element falls back to
<body class="admin-shell no-sidebar"> and renders a single-column
shell.