feat(webui): auto-login via ?token= URL query param (#194)

登录页每次手输密码很繁琐。支持在 URL 带 `?token=<accessToken>` 直接免密进入:
API client 构造时读取该参数作为 bearer token,交由既有 /api/status 门校验——
有效则直达主页,无效/过期则照常落登录页(无安全降级,服务端仍校验)。

安全处理:token 读取后**立即从 URL 剥离**(history.replaceState)——凭据留在地址栏
会经浏览器历史 / 服务端访问日志 / Referer 头泄露。同时持久化到 tokenStore,
使随后刷新(URL 已无参数)仍保持登录。URL token 优先于已存 token。

只做 token(不做 password)——token 就是实际 bearer 凭据,无需额外登录往返、
也比明文密码更适合放链接。
This commit is contained in:
DrSmoothl
2026-07-03 19:30:53 +08:00
parent 7744e24af2
commit abef7973d9
+27 -1
View File
@@ -37,6 +37,27 @@ function extractErrorMessage(payload: ErrorPayload, fallback: string): string {
return payload.message || payload.error || fallback;
}
// #194: seed the bearer token from a `?token=…` URL query param so an operator
// can bookmark / share a one-click auto-login link instead of typing the
// password every visit. The token is consumed once and IMMEDIATELY stripped
// from the URL (via replaceState) — a credential left in the address bar leaks
// through browser history, server access logs, and the Referer header. It's
// still validated server-side by the normal /api/status check, so a stale or
// forged value just falls through to the login page.
function consumeUrlToken(): string | null {
if (typeof window === 'undefined') return null;
try {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
if (!token) return null;
url.searchParams.delete('token');
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
return token;
} catch {
return null;
}
}
class HttpApiClient implements ApiClient {
private tokenStore: TokenStore;
private currentToken: string | null;
@@ -56,7 +77,12 @@ class HttpApiClient implements ApiClient {
constructor(opts: CreateApiClientOptions = {}) {
this.tokenStore = opts.tokenStore ?? localStorageTokenStore(DEFAULT_TOKEN_KEY);
this.currentToken = this.tokenStore.load();
// A `?token=…` link wins over any previously stored token, and is persisted
// so a later reload (URL now stripped) stays signed in. Validation happens
// in the normal status() gate, so an invalid link just shows the login page.
const urlToken = consumeUrlToken();
this.currentToken = urlToken ?? this.tokenStore.load();
if (urlToken) this.tokenStore.save(urlToken);
this.onUnauthorized = opts.onUnauthorized;
this.processes = {