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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user