diff --git a/auth/provider_common.go b/auth/provider_common.go index 8aef896b..b989680f 100644 --- a/auth/provider_common.go +++ b/auth/provider_common.go @@ -86,6 +86,11 @@ func (p *commonProvider) GetUser(c echo.Context) (*users.User, error) { func (p *commonProvider) GetSession(c echo.Context) (*Session, error) { cookie, err := c.Cookie(AuthCookieName) if err != nil { + if err == http.ErrNoCookie { + // No session cookie at all is the normal, unauthenticated case + // (e.g. a fresh visit) — not a login failure worth reporting. + return nil, p.renderer.renderLoginPage(c, "") + } return nil, p.renderer.renderLoginPage(c, err.Error()) } else if len(cookie.Value) == 0 { return nil, p.renderer.renderLoginPage(c, "") diff --git a/auth/provider_oauthbase.go b/auth/provider_oauthbase.go index ea710a13..e309f218 100644 --- a/auth/provider_oauthbase.go +++ b/auth/provider_oauthbase.go @@ -83,12 +83,16 @@ func (p oauth2BaseProvider) renderLoginPage(c echo.Context, reason string) error pagectx.Base LoginTip string Name string - Reason string + // ProviderKey selects which provider's icon/copy oauth2-login.html + // renders; it is not used for any auth decision. + ProviderKey string + Reason string }{ - Base: p.pageCtx.Base(c, "Login", ""), - LoginTip: p.loginTip, - Name: p.displayName, - Reason: reason, + Base: p.pageCtx.Base(c, "Login", ""), + LoginTip: p.loginTip, + Name: p.displayName, + ProviderKey: p.name, + Reason: reason, } return templates.Templates.ExecuteTemplate(c.Response(), "oauth2-login.html", context) } diff --git a/contrib/e2e/Makefile b/contrib/e2e/Makefile index f1ae7828..49e4848a 100644 --- a/contrib/e2e/Makefile +++ b/contrib/e2e/Makefile @@ -39,6 +39,7 @@ run: build $(CACHE)/venv/bin/pytest -s -v test_remote_actions.py $(CACHE)/venv/bin/pytest -s -v test_updates.py $(CACHE)/venv/bin/pytest -s -v test_webui.py + $(CACHE)/venv/bin/pytest -s -v test_login.py $(CACHE)/venv/bin/pytest -s -v test_webui_settings.py $(CACHE)/venv/bin/pytest -s -v test_e2e_update_flow.py diff --git a/contrib/e2e/README.md b/contrib/e2e/README.md index de755515..e923f770 100644 --- a/contrib/e2e/README.md +++ b/contrib/e2e/README.md @@ -76,6 +76,7 @@ make clean | `test_remote_actions.py` | `fioup run-and-report`; verifying the result via CLI and the web UI, plus artifact download. | | `test_updates.py` | Uploading an OTA update artifact and finding it in `updates list`. | | `test_webui.py` | Web UI smoke tests and device-table rendering. | +| `test_login.py` | Local username/password login: page render, invalid-credential error, and successful-login redirect/cookie. | | `test_webui_settings.py` | Creating an API token via the settings dialog and checking the audit log. | | `test_e2e_update_flow.py` | Full flow: upload an update, create a rollout, install on the device, and verify events plus the running container. | @@ -87,6 +88,9 @@ and device are set up once per run: - `update_server` — generates PKI (`add_device.sh` signs a device cert against the generated device CA), initializes TUF and test-mode auth, and starts `fioserver serve`, yielding its data directory. +- `update_server_local_auth` — a separate `fioserver` instance (own PKI/TUF, + no device registration) initialized with local username/password auth and + a seeded `admin`/`admin` user, for `test_login.py`'s login-flow coverage. - `fioup_device` / `docker` — launch the `fioup` container and wait for its inner `dockerd` to be ready. - `registered_device` — copies the generated device credentials plus a diff --git a/contrib/e2e/conftest.py b/contrib/e2e/conftest.py index 52fd14d1..a18fbc60 100644 --- a/contrib/e2e/conftest.py +++ b/contrib/e2e/conftest.py @@ -320,6 +320,77 @@ def update_server(request, fioserver_bin): shutil.rmtree(datadir, ignore_errors=True) +@pytest.fixture(scope="session") +def update_server_local_auth(request, fioserver_bin): + """Generate PKI, start update-server with local username/password auth. + + Kept separate from `update_server` (rather than parameterizing it) so + switching auth modes here doesn't change behavior for the many other + tests that already depend on `update_server`'s noauth/test config. + """ + datadir = Path(tempfile.mkdtemp(prefix="fioserver-local-auth-")) + + print("[setup] Initialising auth (local username/password mode) ...", flush=True) + subprocess.run( + [str(fioserver_bin), "--datadir", str(datadir), "auth-init", "--local"], + check=True, + capture_output=True, + ) + subprocess.run( + [ + str(fioserver_bin), "--datadir", str(datadir), + "user-add", "--username", "admin", "--password", "admin", + ], + check=True, + capture_output=True, + ) + + print("\n[setup] Generating PKI ...", flush=True) + subprocess.run( + [str(fioserver_bin), "--datadir", str(datadir), "pki-init", "--dnsname", "update-server", "--factory", "e2e-factory"], + check=True, + capture_output=True, + ) + subprocess.run( + [str(fioserver_bin), "--datadir", str(datadir), "tuf-init"], + check=True, + capture_output=True, + ) + + print("[setup] Starting update-server server (local auth) ...", flush=True) + log_path = datadir / "server.log" + log_file = open(log_path, "w") + proc = subprocess.Popen( + [str(fioserver_bin), "serve", "--datadir", str(datadir)], + stdout=log_file, + stderr=log_file, + ) + + deadline = time.time() + 30 + while time.time() < deadline: + try: + requests.get(f"http://localhost:{SERVER_UI_PORT}", timeout=2) + break + except requests.exceptions.ConnectionError: + time.sleep(1) + else: + proc.kill() + log_file.close() + print(log_path.read_text()) + raise RuntimeError("update-server (local auth) did not start within 30s") + + print(f"[setup] update-server (local auth) running (pid={proc.pid})", flush=True) + + yield datadir + + proc.terminate() + proc.wait(timeout=10) + log_file.close() + if request.session.testsfailed: + print("\n[teardown] update-server (local auth) log:\n" + log_path.read_text(), flush=True) + shutil.rmtree(datadir, ignore_errors=True) + + def _run_fiocli(fiocli_bin: Path, home: Path, *args) -> str: try: result = subprocess.run( diff --git a/contrib/e2e/test_login.py b/contrib/e2e/test_login.py new file mode 100644 index 00000000..3f22172a --- /dev/null +++ b/contrib/e2e/test_login.py @@ -0,0 +1,38 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause-Clear + +"""Local username/password login flow tests for update-server using Playwright.""" + +SERVER_URL = "http://localhost:8080" + + +def test_local_login_page_renders(page, update_server_local_auth): + """The login page renders with the username/password form visible.""" + page.goto(f"{SERVER_URL}/devices") + assert "Login" in page.title() + assert page.locator("#username").is_visible() + assert page.locator("#password").is_visible() + assert page.get_by_role("button", name="Sign in").is_visible() + + +def test_local_login_invalid_credentials_shows_error(page, update_server_local_auth): + """Submitting the wrong password redisplays the form with an error banner.""" + page.goto(f"{SERVER_URL}/devices") + page.fill("#username", "admin") + page.fill("#password", "wrong-password") + page.get_by_role("button", name="Sign in").click() + error = page.locator(".login-error") + error.wait_for(state="visible") + assert "Invalid username or password" in error.inner_text() + + +def test_local_login_valid_credentials_redirects_to_devices(page, update_server_local_auth): + """Submitting valid credentials redirects to /devices with a session cookie set.""" + page.goto(f"{SERVER_URL}/devices") + page.fill("#username", "admin") + page.fill("#password", "admin") + page.get_by_role("button", name="Sign in").click() + page.wait_for_url(f"{SERVER_URL}/devices") + assert page.title() == "Devices - Foundries Update Server" + cookies = page.context.cookies() + assert any(c["name"] == "fioserver-session" for c in cookies) diff --git a/server/ui/web/templates/base.html b/server/ui/web/templates/base.html index bbd8f2e8..1a0c472f 100644 --- a/server/ui/web/templates/base.html +++ b/server/ui/web/templates/base.html @@ -12,6 +12,9 @@ + {{/* Keep in sync with the "csrf-fetch-script" block below — duplicated + (not called) so standalone pages like local-login.html don't need + to wrap in this "header" block just to get the CSRF fetch patch. */}} +{{end}} + {{define "footer"}} diff --git a/server/ui/web/templates/local-login.html b/server/ui/web/templates/local-login.html index 9ba828ae..0a2fa3e0 100644 --- a/server/ui/web/templates/local-login.html +++ b/server/ui/web/templates/local-login.html @@ -1,28 +1,119 @@ {{/* Used by the auth package's localProvider implementation */}} -{{ template "header" .}} -
-

{{.Title}}

- -
- {{ if .CsrfToken }}{{ end }} -
- Username - -
- -
- Password - -
- - -
- - {{ if .Reason }} -
- Reason: {{.Reason}} + + + + + + + {{ if .CsrfToken }}{{ end }} + + {{.Title}} - {{.BrandName}} + + + + + + {{ template "csrf-fetch-script" . }} + + + +
-{{ template "footer"}} +
+ + + +
+ + + + + diff --git a/server/ui/web/templates/login.css b/server/ui/web/templates/login.css new file mode 100644 index 00000000..029b0101 --- /dev/null +++ b/server/ui/web/templates/login.css @@ -0,0 +1,302 @@ +/* Go-templated like style.css — {{.Primary}} etc. resolve from the Branding + struct (server/ui/web/branding.go) at request time via /css/:filename. */ + +/* ── Tokens ────────────────────────────────────────────────────────────── + Brand + surface + text values come from the Branding struct. The + accent-tinted hairlines and the code-wash tint are DERIVED from + --brand-accent via color-mix() so they track an operator's custom Accent + (branding.json) instead of being frozen to the default lavender. The + neutral greys and danger accents have no Branding field and stay literal. + + style.css declares --brand-primary / --surface-* / --text-1 for LIGHT and for + OS-auto dark (@media prefers-color-scheme). The login pages add a *manual* + theme toggle, which style.css deliberately does not implement + ("Toggle-ready: duplicate under [data-theme=dark]"). So login.css owns a + self-contained palette keyed off [data-theme="dark"] — the exact mechanism + the mockups use — rather than depending on style.css load order. */ +:root { + --brand-primary: {{ .Primary }}; + --brand-accent: {{ .Accent }}; + --surface-2: {{ .SurfaceAlt }}; + --text-1: {{ .Text }}; + + --text-2: #555555; + --text-3: #707070; + --border-1: #e2e2e2; + --border-2: #cfcfcf; + --focus-ring: var(--brand-primary); + /* Faint accent hairline (was rgba(163,180,255,.2)); same in both themes. */ + --brand-accent-hairline: color-mix(in srgb, var(--brand-accent) 20%, transparent); + /* Static accent wash behind inline (was rgba(163,180,255,.08)). */ + --surface-hover: color-mix(in srgb, var(--brand-accent) 8%, transparent); + --danger: #dc2626; + --danger-tint: rgba(220, 38, 38, 0.08); +} + +/* Manual dark mode. Beyond the local palette this must re-bridge Pico's own + --pico-* button/focus tokens to the brand accent: pico_min_211.css ships an + unconditional [data-theme=dark]{--pico-primary:#01aaff;...} block, and + style.css's bridges explicitly exclude [data-theme=dark], so without this + override the native "Sign in" / OAuth buttons render in Pico's stock blue. + The vars below mirror the primary-family + surface/color set that Pico's + own dark block defines. */ +[data-theme="dark"] { + --surface-2: {{ .SurfaceAltDark }}; + --text-1: {{ .TextDark }}; + + --text-2: #a9b4c2; + --text-3: #8b96a3; + --border-1: #2a3441; + --border-2: #3a4657; + --focus-ring: var(--brand-accent); + --surface-hover: color-mix(in srgb, var(--brand-accent) 12%, transparent); + --danger: #f87171; + --danger-tint: rgba(248, 113, 113, 0.14); + + /* A navy primary fails contrast on the dark surface, so the accent becomes + the interactive fill and the primary becomes its inverse (a legible dark + label on the light-lavender button). */ + --pico-primary: var(--brand-accent); + --pico-primary-background: var(--brand-accent); + --pico-primary-border: var(--brand-accent); + --pico-primary-underline: var(--brand-accent-hairline); + --pico-primary-hover: var(--brand-accent); + --pico-primary-hover-background: var(--brand-accent); + --pico-primary-hover-border: var(--brand-accent); + --pico-primary-hover-underline: var(--brand-accent); + --pico-primary-focus: var(--brand-accent-hairline); + --pico-primary-inverse: var(--brand-primary); + --pico-background-color: var(--surface-2); + --pico-card-background-color: var(--surface-2); + --pico-color: var(--text-1); +} + +/* ── Layout: full-height two-column split ──────────────────────────────── + Left = navy brand panel; right = the form/action on the flat surface. + Collapses to a single column (brand panel becomes a slim header bar) at + 860px, matching the mockups. */ +.login-split { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + min-height: 100vh; +} + +/* ── Left brand panel ──────────────────────────────────────────────────── + Internal padding is derived from Pico's --pico-spacing (1rem) rather than + raw pixels, per the redesign brief. */ +.login-brand { + margin: 0; + padding: calc(var(--pico-spacing) * 2.5) calc(var(--pico-spacing) * 2.75); + display: flex; + flex-direction: column; + background: var(--brand-primary); + color: #fff; + border-right: 1px solid var(--brand-accent-hairline); +} + +/* Optional operator logo above the wordmark (sized like the topbar logo). */ +.login-brand-logo { + height: 1.8rem; + width: auto; + margin-bottom: var(--pico-spacing); +} + +.login-brand-lockup { + font-family: var(--pico-font-family-monospace); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #fff; +} + +/* Pushes the heading + tagline block down so the lockup stays pinned to the + top and the footer to the bottom of the flex column. */ +.login-panel-body { + margin-top: auto; +} + +.login-panel-body h1 { + margin: 0 0 0.875rem; + padding: 0; + border: none; + font-size: 1.875rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; + color: #fff; +} + +.login-tagline { + margin: 0; + max-width: 34ch; + font-size: 0.9375rem; + line-height: 1.55; + color: rgba(255, 255, 255, 0.72); +} + +.login-panel-footer { + margin-top: calc(var(--pico-spacing) * 2); + font-family: var(--pico-font-family-monospace); + font-size: 0.6875rem; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.45); +} + +/* ── Right content panel ───────────────────────────────────────────────── + position: relative anchors the floating theme toggle. */ +.login-form-panel { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--pico-spacing) * 2.5) calc(var(--pico-spacing) * 2.75); + background: var(--surface-2); +} + +/* Ghost icon button, floated top-right — deliberately overrides Pico's filled + primary button styling because it must read as chrome, not an action. */ +#theme-toggle { + position: absolute; + top: 1.25rem; + right: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + padding: 7px; + width: auto; + background: none; + color: var(--text-2); + border: 1px solid var(--border-1); + border-radius: 4px; + cursor: pointer; +} +#theme-toggle:hover { + color: var(--text-1); + border-color: var(--border-2); +} +#theme-toggle:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} +#theme-toggle svg { display: block; } +/* Icon swap keyed off the manual [data-theme] attribute on . */ +#theme-toggle .icon-dark { display: none; } +[data-theme="dark"] #theme-toggle .icon-light { display: none; } +[data-theme="dark"] #theme-toggle .icon-dark { display: block; } + +.login-form-wrap { + width: 100%; + max-width: 360px; +} + +.login-eyebrow { + margin: 0 0 6px; + font-family: var(--pico-font-family-monospace); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-3); +} + +.login-title { + margin: 0 0 6px; + font-size: 1.375rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text-1); +} + +.login-sub { + margin: 0 0 calc(var(--pico-spacing) * 1.5); + font-size: 0.875rem; + line-height: 1.5; + color: var(--text-2); +} + +/* ── Error banner ──────────────────────────────────────────────────────── + Pico's --pico-form-element-invalid-* tokens only restyle form controls, so + a standalone alert banner needs its own rule. */ +.login-error { + display: flex; + gap: 9px; + align-items: flex-start; + margin: 0 0 calc(var(--pico-spacing) * 1.25); + padding: 11px 14px; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--danger); + background: var(--danger-tint); + border: 1px solid var(--danger); + border-radius: 4px; +} +.login-error svg { + flex-shrink: 0; + margin-top: 1px; +} + +/* ── OAuth "Continue with {provider}" action ───────────────────────────── + Rendered as , which Pico already styles as a filled + primary button — we only add the full-width + flex layout Pico's inline + a[role=button] lacks so the provider icon and label stay centred together. + Colours/padding/border come from Pico (bridged to the brand palette by + style.css in light/OS-dark and by the [data-theme=dark] block above under + the manual toggle); we intentionally do NOT restate them. */ +.login-oauth-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + width: 100%; +} +.login-oauth-btn svg { flex-shrink: 0; } + +/* ── Restriction line (optional org/domain constraint) ───────────────────*/ +.login-restriction { + display: flex; + gap: 9px; + align-items: flex-start; + margin: calc(var(--pico-spacing) * 1.25) 0 0; + padding-top: 18px; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--text-2); + border-top: 1px solid var(--border-1); +} +.login-restriction svg { + flex-shrink: 0; + margin-top: 1px; + color: var(--text-3); +} +.login-restriction code { + font-family: var(--pico-font-family-monospace); + font-size: 0.75rem; + color: var(--text-1); + background: var(--surface-hover); + padding: 1px 5px; + border-radius: 3px; +} + +/* ── Responsive: collapse to a single column ─────────────────────────────*/ +@media (max-width: 860px) { + .login-split { + grid-template-columns: 1fr; + } + .login-brand { + flex-direction: row; + align-items: center; + gap: 12px; + padding: calc(var(--pico-spacing) * 1.375) var(--pico-spacing); + border-right: none; + border-bottom: 1px solid var(--brand-accent-hairline); + } + .login-panel-body, + .login-panel-footer { + display: none; + } + .login-form-panel { + align-items: flex-start; + padding: calc(var(--pico-spacing) * 2.75) var(--pico-spacing); + } +} diff --git a/server/ui/web/templates/oauth2-login.html b/server/ui/web/templates/oauth2-login.html index 7c16bcd9..9b1ad990 100644 --- a/server/ui/web/templates/oauth2-login.html +++ b/server/ui/web/templates/oauth2-login.html @@ -1,14 +1,132 @@ -{{ template "header" .}} -
-

{{.Title}}

-

Please login with your SSO provider. {{if .LoginTip}}{{.LoginTip}}{{end}}

- -
{{.Name}} - {{ if .Reason }} -
- Reason: {{.Reason}} +{{/* Used by the auth package's oauth2BaseProvider implementation, shared by GitHub and Google */}} + + + + + + + {{ if .CsrfToken }}{{ end }} + + {{.Title}} - {{.BrandName}} + + + + + + {{ template "csrf-fetch-script" . }} + + + +
-{{ template "footer"}} +
+ + + +
+ + + + + diff --git a/server/ui/web/templates/style.css b/server/ui/web/templates/style.css index 7250b0bc..ecb4d641 100644 --- a/server/ui/web/templates/style.css +++ b/server/ui/web/templates/style.css @@ -1,6 +1,6 @@ :root { color-scheme: light dark; - font-size: 15px; + font-size: 16px; /* Brand tokens — the operator-customizable design system (colors only) */ --brand-primary: {{ .Primary }}; @@ -26,14 +26,57 @@ html, body { :root:not([data-theme=dark]) (specificity 0,1,0); we must match that selector so our bridge wins by later source order. A plain :root (0,0,1) loses to Pico. This selector also matches in OS dark mode (no data-theme attribute is set), - so the bridge automatically picks up the dark --surface-*/--text-1 values that - the @media block re-themes below. */ + so the bridge automatically picks up the dark --surface-* / --text-1 values that + the @media block re-themes below. + + Button/link fills are driven by the whole --pico-primary-* family, not just + --pico-primary — --pico-primary-background/-border paint a filled button, + so leaving them unset meant buttons kept rendering in Pico's stock blue + regardless of --brand-primary. */ :root:not([data-theme=dark]) { - --pico-primary: var(--brand-primary); - --pico-primary-hover: var(--brand-accent); - --pico-background-color: var(--surface-1); - --pico-card-background-color: var(--surface-2); - --pico-color: var(--text-1); + --pico-primary: var(--brand-primary); + --pico-primary-background: var(--brand-primary); + --pico-primary-border: var(--brand-primary); + --pico-primary-underline: var(--brand-primary); + --pico-primary-hover: var(--brand-accent); + --pico-primary-hover-background: var(--brand-accent); + --pico-primary-hover-border: var(--brand-accent); + --pico-primary-hover-underline: var(--brand-accent); + --pico-primary-focus: var(--brand-accent); + --pico-primary-inverse: #fff; + --pico-background-color: var(--surface-1); + --pico-card-background-color: var(--surface-2); + --pico-color: var(--text-1); +} + +/* ── Type scale — overrides Pico's rem-based heading defaults ──────────── + Fluid h1–h3 via clamp() (min @ 320 px viewport → max @ 1280 px). + h4–h6 are fixed; weight/case/tracking carry the hierarchy at those sizes. + Override --pico-font-size (not font-size directly) so Pico's own rule + reads the value cleanly without a specificity fight. */ +h1 { + --pico-font-size: clamp(22px, calc(20px + 0.625vw), 28px); + --pico-line-height: 1.15; +} +h2 { + --pico-font-size: clamp(18px, calc(16.67px + 0.417vw), 22px); + --pico-line-height: 1.2; +} +h3 { + --pico-font-size: clamp(16px, calc(15px + 0.313vw), 19px); + --pico-line-height: 1.25; +} +h4 { + --pico-font-size: 17px; + --pico-line-height: 1.3; +} +h5 { + --pico-font-size: 13px; + --pico-line-height: 1.4; +} +h6 { + --pico-font-size: 11px; + --pico-line-height: 1.4; } nav#topbar { @@ -395,12 +438,23 @@ dialog.file-content > article > section > ul { query (specificity 0,1,0); the outside-media bridge above cannot override those, so the surface/text mappings must be repeated here to win. The accent replaces the primary as the interactive color — a navy primary - fails contrast on a dark surface. */ + fails contrast on a dark surface. Same primary-family caveat as the + light-mode bridge above: --pico-primary-background/-border must be set + too, or filled buttons stay Pico's stock blue. */ :root:not([data-theme]) { - --pico-primary: var(--brand-accent); - --pico-background-color: var(--surface-1); - --pico-card-background-color: var(--surface-2); - --pico-color: var(--text-1); + --pico-primary: var(--brand-accent); + --pico-primary-background: var(--brand-accent); + --pico-primary-border: var(--brand-accent); + --pico-primary-underline: var(--brand-accent); + --pico-primary-hover: var(--brand-accent); + --pico-primary-hover-background: var(--brand-accent); + --pico-primary-hover-border: var(--brand-accent); + --pico-primary-hover-underline: var(--brand-accent); + --pico-primary-focus: var(--brand-accent); + --pico-primary-inverse: var(--brand-primary); + --pico-background-color: var(--surface-1); + --pico-card-background-color: var(--surface-2); + --pico-color: var(--text-1); } nav#subnav a {