移除前端自动游客凭证残留
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
|
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
|
||||||
- [SPACETIME_DEV_URI_HOTFIX_2026-04-20.md](./SPACETIME_DEV_URI_HOTFIX_2026-04-20.md):修复开发默认配置把 Spacetime 连接误指向 Vite `3000` 端口的问题。
|
- [SPACETIME_DEV_URI_HOTFIX_2026-04-20.md](./SPACETIME_DEV_URI_HOTFIX_2026-04-20.md):修复开发默认配置把 Spacetime 连接误指向 Vite `3000` 端口的问题。
|
||||||
- [SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md](./SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md):本地 token 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。
|
- [SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md](./SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md):本地 token 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。
|
||||||
|
- [STDB_AUTH_TAIL_PHASE1_AUTO_GUEST_CREDENTIAL_REMOVAL_2026-04-20.md](./STDB_AUTH_TAIL_PHASE1_AUTO_GUEST_CREDENTIAL_REMOVAL_2026-04-20.md):Auth 尾巴清理第一段,删除前端自动游客用户名/密码残留。
|
||||||
- [TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md](./TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md):任务完成后按文件边界自动提交的脚本与协作约定。
|
- [TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md](./TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md):任务完成后按文件边界自动提交的脚本与协作约定。
|
||||||
- [NODE_DEV_STARTUP_HOTFIX_2026-04-20.md](./NODE_DEV_STARTUP_HOTFIX_2026-04-20.md):`npm run dev` 启动失败的热修记录、根因与验证结果。
|
- [NODE_DEV_STARTUP_HOTFIX_2026-04-20.md](./NODE_DEV_STARTUP_HOTFIX_2026-04-20.md):`npm run dev` 启动失败的热修记录、根因与验证结果。
|
||||||
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# STDB Auth 尾巴清理 Phase 1:移除前端自动游客凭证残留(2026-04-20)
|
||||||
|
|
||||||
|
## 1. 本轮目标
|
||||||
|
|
||||||
|
这轮只处理认证迁移里最安全、且不会打断现有 `/api/runtime/story/*` 的一段尾巴:
|
||||||
|
|
||||||
|
1. 删除前端本地自动游客用户名/密码残留
|
||||||
|
2. 保留当前 `ACCESS_TOKEN_KEY`,因为它仍同时承载:
|
||||||
|
- Spacetime token
|
||||||
|
- 旧 `/api/runtime/story/*` Bearer token
|
||||||
|
3. 不处理 Node JWT middleware 本身
|
||||||
|
4. 不处理 runtime story 向 STDB 的正式迁移
|
||||||
|
|
||||||
|
## 2. 背景问题
|
||||||
|
|
||||||
|
当前前端虽然已经用 `ensureAutoAuthUser()` 走 STDB 匿名连接,但代码里仍保留一套旧时代残留:
|
||||||
|
|
||||||
|
1. `AutoAuthCredentials`
|
||||||
|
2. `createAutoAuthCredentials()`
|
||||||
|
3. `authEntryWithStoredCredentials()`
|
||||||
|
4. `apiClient.ts` 中的 `AUTO_AUTH_USERNAME_KEY / AUTO_AUTH_PASSWORD_KEY`
|
||||||
|
|
||||||
|
这些内容已经不再承担真实登录能力,只会继续制造两个误导:
|
||||||
|
|
||||||
|
1. 让人误以为当前游客登录仍依赖浏览器本地用户名/密码恢复
|
||||||
|
2. 让 STDB token 收口与旧 JWT 清理边界持续混在一起
|
||||||
|
|
||||||
|
## 3. 本轮落地
|
||||||
|
|
||||||
|
代码调整:
|
||||||
|
|
||||||
|
1. `src/services/authService.ts`
|
||||||
|
- 删除 `AutoAuthCredentials`
|
||||||
|
- 删除 `createAutoAuthCredentials()`
|
||||||
|
- 删除 `authEntryWithStoredCredentials()`
|
||||||
|
- `ensureAutoAuthUser()` 改为只返回 `AuthUser`
|
||||||
|
- `clearAuthSession()` / `logoutAuthUser()` / `logoutAllAuthSessions()` 不再清理自动游客凭证
|
||||||
|
2. `src/services/apiClient.ts`
|
||||||
|
- 删除自动游客用户名/密码的 localStorage key 和相关 helper
|
||||||
|
3. `src/components/auth/AuthGate.tsx`
|
||||||
|
- 匿名建连成功后直接消费 `AuthUser`
|
||||||
|
4. 测试同步调整:
|
||||||
|
- `src/components/auth/AuthGate.test.tsx`
|
||||||
|
- `src/services/authService.test.ts`
|
||||||
|
|
||||||
|
## 4. 当前边界
|
||||||
|
|
||||||
|
这轮刻意不动:
|
||||||
|
|
||||||
|
1. `ACCESS_TOKEN_KEY`
|
||||||
|
2. `/api/auth/refresh`
|
||||||
|
3. `fetchWithApiAuth()` 的 Bearer 注入
|
||||||
|
4. `server-node/src/middleware/auth.ts`
|
||||||
|
|
||||||
|
原因很简单:
|
||||||
|
|
||||||
|
1. 当前 `runtimeStoryService.ts` 仍在访问 `/api/runtime/story/*`
|
||||||
|
2. Express 这条链路仍要求旧 JWT Bearer
|
||||||
|
3. 如果现在直接删 `ACCESS_TOKEN_KEY` 语义,会把 STDB token 与 runtime story 旧链路一起打断
|
||||||
|
|
||||||
|
因此这轮是 Auth 尾巴清理的第一段,不是最终收口。
|
||||||
|
|
||||||
|
## 5. 下一步建议
|
||||||
|
|
||||||
|
下一步应继续处理:
|
||||||
|
|
||||||
|
1. 为 STDB token 与旧 HTTP bearer 拆出独立存储语义
|
||||||
|
2. 或直接推进 `runtimeStoryService.ts` 迁到 STDB,随后删除 `/api/runtime/story/*` 对旧 JWT 的依赖
|
||||||
@@ -99,11 +99,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||||
user: mockUser,
|
...mockUser,
|
||||||
credentials: {
|
|
||||||
username: 'guest_tester',
|
|
||||||
password: 'auto_password',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setStatus('recovering');
|
setStatus('recovering');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { user: nextUser } = await ensureAutoAuthUser();
|
const nextUser = await ensureAutoAuthUser();
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
} from '../../packages/shared/src/http';
|
} from '../../packages/shared/src/http';
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
|
||||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
|
||||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||||
const REQUEST_ID_HEADER = 'x-request-id';
|
const REQUEST_ID_HEADER = 'x-request-id';
|
||||||
const API_VERSION_HEADER = 'x-api-version';
|
const API_VERSION_HEADER = 'x-api-version';
|
||||||
@@ -376,46 +374,6 @@ export function clearStoredAccessToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredAutoAuthCredentials() {
|
|
||||||
if (!canUseLocalStorage()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
|
|
||||||
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setStoredAutoAuthCredentials(credentials: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}) {
|
|
||||||
if (!canUseLocalStorage()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
|
|
||||||
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearStoredAutoAuthCredentials() {
|
|
||||||
if (!canUseLocalStorage()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
|
|
||||||
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
|
|
||||||
emitAuthStateChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function withAuthorizationHeaders(
|
function withAuthorizationHeaders(
|
||||||
headers?: HeadersInit,
|
headers?: HeadersInit,
|
||||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
ApiClientError,
|
ApiClientError,
|
||||||
clearStoredAccessToken,
|
clearStoredAccessToken,
|
||||||
clearStoredAutoAuthCredentials,
|
|
||||||
setStoredAccessToken,
|
setStoredAccessToken,
|
||||||
} from './apiClient';
|
} from './apiClient';
|
||||||
import {
|
import {
|
||||||
createAutoAuthCredentials,
|
|
||||||
getAuthRiskBlocks,
|
getAuthRiskBlocks,
|
||||||
getAuthSessions,
|
getAuthSessions,
|
||||||
getCaptchaChallengeFromError,
|
getCaptchaChallengeFromError,
|
||||||
@@ -185,14 +183,6 @@ describe('authService with SpacetimeDB', () => {
|
|||||||
spacetimeMocks.ensureSpacetimeConnection.mockReset();
|
spacetimeMocks.ensureSpacetimeConnection.mockReset();
|
||||||
spacetimeMocks.disconnectSpacetimeConnection.mockReset();
|
spacetimeMocks.disconnectSpacetimeConnection.mockReset();
|
||||||
clearStoredAccessToken();
|
clearStoredAccessToken();
|
||||||
clearStoredAutoAuthCredentials();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates credentials that match current guest username/password constraints', () => {
|
|
||||||
const credentials = createAutoAuthCredentials();
|
|
||||||
|
|
||||||
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
|
||||||
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts captcha challenge details from api errors', () => {
|
it('extracts captcha challenge details from api errors', () => {
|
||||||
|
|||||||
@@ -34,18 +34,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
ApiClientError,
|
ApiClientError,
|
||||||
clearStoredAccessToken,
|
clearStoredAccessToken,
|
||||||
clearStoredAutoAuthCredentials,
|
|
||||||
getStoredAccessToken,
|
getStoredAccessToken,
|
||||||
} from './apiClient';
|
} from './apiClient';
|
||||||
|
|
||||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||||
|
|
||||||
export type AutoAuthCredentials = {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuthSessionSnapshot = {
|
export type AuthSessionSnapshot = {
|
||||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||||
availableLoginMethods: AuthLoginMethod[];
|
availableLoginMethods: AuthLoginMethod[];
|
||||||
@@ -65,30 +59,10 @@ export type ConsumedAuthCallback = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let pendingAutoAuthUser: Promise<{
|
let pendingAutoAuthUser: Promise<AuthUser> | null = null;
|
||||||
user: AuthUser;
|
|
||||||
credentials: AutoAuthCredentials;
|
|
||||||
}> | null = null;
|
|
||||||
|
|
||||||
const TOKEN_RECOVERY_TIMEOUT_MS = 3500;
|
const TOKEN_RECOVERY_TIMEOUT_MS = 3500;
|
||||||
|
|
||||||
function buildRandomSegment(length: number) {
|
|
||||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
||||||
const cryptoApi = globalThis.crypto;
|
|
||||||
|
|
||||||
if (!cryptoApi?.getRandomValues) {
|
|
||||||
return Array.from(
|
|
||||||
{ length },
|
|
||||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
|
||||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
window.setTimeout(resolve, ms);
|
window.setTimeout(resolve, ms);
|
||||||
@@ -255,17 +229,9 @@ export function getCaptchaChallengeFromError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAutoAuthCredentials(): AutoAuthCredentials {
|
|
||||||
return {
|
|
||||||
username: `guest_${buildRandomSegment(12)}`,
|
|
||||||
password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAuthSession() {
|
export function clearAuthSession() {
|
||||||
disconnectSpacetimeConnection();
|
disconnectSpacetimeConnection();
|
||||||
clearStoredAccessToken();
|
clearStoredAccessToken();
|
||||||
clearStoredAutoAuthCredentials();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPhoneLoginCode(
|
export async function sendPhoneLoginCode(
|
||||||
@@ -346,20 +312,9 @@ export async function authEntry(_username: string, _password: string) {
|
|||||||
return session.user;
|
return session.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authEntryWithStoredCredentials(
|
|
||||||
credentials: AutoAuthCredentials,
|
|
||||||
) {
|
|
||||||
const user = await authEntry(credentials.username, credentials.password);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureAutoAuthUser() {
|
export async function ensureAutoAuthUser() {
|
||||||
pendingAutoAuthUser ??= (async () => {
|
pendingAutoAuthUser ??= (async () => {
|
||||||
const user = await authEntry('guest', 'guest');
|
return authEntry('guest', 'guest');
|
||||||
return {
|
|
||||||
user,
|
|
||||||
credentials: createAutoAuthCredentials(),
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -460,7 +415,6 @@ export async function liftAuthRiskBlock(_scopeType: 'phone' | 'ip') {
|
|||||||
|
|
||||||
export async function logoutAuthUser() {
|
export async function logoutAuthUser() {
|
||||||
disconnectSpacetimeConnection({ clearToken: true });
|
disconnectSpacetimeConnection({ clearToken: true });
|
||||||
clearStoredAutoAuthCredentials();
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
} satisfies LogoutResponse;
|
} satisfies LogoutResponse;
|
||||||
@@ -475,7 +429,6 @@ export async function logoutAllAuthSessions() {
|
|||||||
throw new Error(result.message || '退出全部设备失败');
|
throw new Error(result.message || '退出全部设备失败');
|
||||||
}
|
}
|
||||||
disconnectSpacetimeConnection({ clearToken: true });
|
disconnectSpacetimeConnection({ clearToken: true });
|
||||||
clearStoredAutoAuthCredentials();
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
} satisfies AuthLogoutAllResponse;
|
} satisfies AuthLogoutAllResponse;
|
||||||
|
|||||||
Reference in New Issue
Block a user