From abef7973d97eedfa8abd9841fb46b71d99777100 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Fri, 3 Jul 2026 19:30:53 +0800 Subject: [PATCH] feat(webui): auto-login via ?token= URL query param (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录页每次手输密码很繁琐。支持在 URL 带 `?token=` 直接免密进入: API client 构造时读取该参数作为 bearer token,交由既有 /api/status 门校验—— 有效则直达主页,无效/过期则照常落登录页(无安全降级,服务端仍校验)。 安全处理:token 读取后**立即从 URL 剥离**(history.replaceState)——凭据留在地址栏 会经浏览器历史 / 服务端访问日志 / Referer 头泄露。同时持久化到 tokenStore, 使随后刷新(URL 已无参数)仍保持登录。URL token 优先于已存 token。 只做 token(不做 password)——token 就是实际 bearer 凭据,无需额外登录往返、 也比明文密码更适合放链接。 --- packages/webui/src/lib/api/client.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/lib/api/client.ts b/packages/webui/src/lib/api/client.ts index 1219a085..7249081d 100644 --- a/packages/webui/src/lib/api/client.ts +++ b/packages/webui/src/lib/api/client.ts @@ -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 = {