Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions auth/provider_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
14 changes: 9 additions & 5 deletions auth/provider_oauthbase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions contrib/e2e/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions contrib/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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
Expand Down
71 changes: 71 additions & 0 deletions contrib/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions contrib/e2e/test_login.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions server/ui/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

<link rel="stylesheet" href="/css/pico_min_211.css">
<link rel="stylesheet" href="/css/style.css">
{{/* 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. */}}
<script>
(function() {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
Expand Down Expand Up @@ -70,6 +73,28 @@

{{end}}

{{define "csrf-fetch-script"}}
{{/* Keep in sync with the inline script in the "header" block above. */}}
<script>
(function() {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
if (token) {
const origFetch = window.fetch;
window.fetch = function(url, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
if (opts.headers instanceof Headers) {
if (!opts.headers.has('X-CSRF-Token')) opts.headers.set('X-CSRF-Token', token);
} else {
if (!opts.headers['X-CSRF-Token']) opts.headers['X-CSRF-Token'] = token;
}
return origFetch.call(this, url, opts);
};
}
})();
</script>
{{end}}

{{define "footer"}}
</main>
</body>
Expand Down
141 changes: 116 additions & 25 deletions server/ui/web/templates/local-login.html
Original file line number Diff line number Diff line change
@@ -1,28 +1,119 @@
{{/* Used by the auth package's localProvider implementation */}}
{{ template "header" .}}
<section class="content-section">
<h2>{{.Title}}</h2>

<form method="post" action="/auth/login">
{{ if .CsrfToken }}<input type="hidden" name="_csrf" value="{{.CsrfToken}}">{{ end }}
<fieldset>
<legend><strong>Username</strong></legend>
<input type="text" name="username" required />
</fieldset>

<fieldset>
<legend><strong>Password</strong></legend>
<input type="password" name="password" required />
</fieldset>

<button type="submit">Login</button>
</form>

{{ if .Reason }}
<section>
<i><small>Reason: {{.Reason}}</small></i>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
{{ if .CsrfToken }}<meta name="csrf-token" content="{{.CsrfToken}}">{{ end }}

<title>{{.Title}} - {{.BrandName}}</title>
<link rel="icon" href="/favicon">

<link rel="stylesheet" href="/css/pico_min_211.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/login.css">
{{ template "csrf-fetch-script" . }}
</head>

<body>
<div class="login-split">
<section class="login-brand" aria-label="{{.BrandName}}">
{{ if .LogoPath }}<img src="{{.LogoPath}}" alt="{{.BrandName}}" class="login-brand-logo">{{ end }}
<span class="login-brand-lockup">{{.BrandName}}</span>
<div class="login-panel-body">
<h1>Fleet updates,<br>under control.</h1>
<p class="login-tagline">The management console for over-the-air updates across your embedded and IoT device fleet.</p>
</div>
<span class="login-panel-footer">{{.Version}}</span>
</section>
{{ end }}
</section>

{{ template "footer"}}
<main id="login" class="login-form-panel">
<button id="theme-toggle" type="button" aria-label="Switch to dark mode" title="Switch to dark mode">
<svg class="icon-light" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="3.2" stroke="currentColor" stroke-width="1.3"/>
<g stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
<line x1="8" y1="0.8" x2="8" y2="2.6"/>
<line x1="8" y1="13.4" x2="8" y2="15.2"/>
<line x1="0.8" y1="8" x2="2.6" y2="8"/>
<line x1="13.4" y1="8" x2="15.2" y2="8"/>
<line x1="2.7" y1="2.7" x2="4" y2="4"/>
<line x1="12" y1="12" x2="13.3" y2="13.3"/>
<line x1="2.7" y1="13.3" x2="4" y2="12"/>
<line x1="12" y1="4" x2="13.3" y2="2.7"/>
</g>
</svg>
<svg class="icon-dark" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M13.5 9.3A6 6 0 1 1 6.7 2.5a5 5 0 0 0 6.8 6.8Z" fill="currentColor"/>
</svg>
</button>

<div class="login-form-wrap">
<p class="login-eyebrow">Account access</p>
<h2 class="login-title">Sign in</h2>
<p class="login-sub">Use your {{.BrandName}} credentials.</p>

{{ if .Reason }}
<div class="login-error" role="alert">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6.6" stroke="currentColor" stroke-width="1.3"/>
<line x1="8" y1="4.6" x2="8" y2="8.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<circle cx="8" cy="11" r="0.9" fill="currentColor"/>
</svg>
<span>{{.Reason}}</span>
</div>
{{ end }}

<form method="post" action="/auth/login">
{{ if .CsrfToken }}<input type="hidden" name="_csrf" value="{{.CsrfToken}}">{{ end }}
<fieldset>
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</fieldset>
<fieldset>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</fieldset>
<button type="submit">Sign in</button>
</form>
Comment on lines +67 to +78
</div>
</main>
</div>

<script>
(function () {
var THEME_STORAGE_KEY = 'login-theme';
var themeToggle = document.getElementById('theme-toggle');
var html = document.documentElement;

function setTheme(theme) {
if (theme === 'dark') {
html.setAttribute('data-theme', 'dark');
themeToggle.setAttribute('aria-label', 'Switch to light mode');
themeToggle.setAttribute('title', 'Switch to light mode');
} else {
html.removeAttribute('data-theme');
themeToggle.setAttribute('aria-label', 'Switch to dark mode');
themeToggle.setAttribute('title', 'Switch to dark mode');
}
}

var savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === 'dark' || savedTheme === 'light') {
setTheme(savedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark');
} else {
setTheme('light');
}

themeToggle.addEventListener('click', function () {
var isDark = html.getAttribute('data-theme') === 'dark';
var next = isDark ? 'light' : 'dark';
setTheme(next);
localStorage.setItem(THEME_STORAGE_KEY, next);
});
})();
</script>
</body>
</html>
Loading
Loading