feat: 实现前端认证系统,WebSocket 连接携带 JWT token
- 新增 AuthContext/AuthProvider:登录/注册状态管理,JWT 自动刷新 - 新增 AuthPage 组件:登录/注册表单,支持模式切换和前端校验 - 新增 api.ts:封装 auth REST API 客户端(register/login/refresh/logout) - WebSocket 连接时拼接 ?token=<jwt>,重连自动携带 - useVisionSession 接受 accessToken 参数并透传 - App.tsx 包裹 AuthProvider,未登录时显示登录页 - storage.ts 新增 token/user 的 localStorage 存储 - i18n 新增中/英/日三语 auth 翻译 - App.css 新增 auth 页面和用户 badge 样式
This commit is contained in:
106
frontend/src/lib/api.ts
Normal file
106
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// ============================================================
|
||||
// HTTP API 客户端
|
||||
// 职责:封装 REST API 请求(auth、conversations 等)
|
||||
// ============================================================
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
status: number;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const status = res.status;
|
||||
|
||||
if (res.status === 204) {
|
||||
return { status };
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: body as T, status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: { code: "NETWORK_ERROR", message: "网络连接失败,请检查网络" },
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
// ---- Auth API ----
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: AuthUser;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
return request<AuthResponse>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
return request<AuthResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshToken(
|
||||
refresh_token: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
return request<AuthResponse>("/auth/refresh", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refresh_token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
): Promise<ApiResponse<{ message: string }>> {
|
||||
return request<{ message: string }>("/auth/logout", {
|
||||
method: "POST",
|
||||
headers: authHeaders(accessToken),
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
}
|
||||
209
frontend/src/lib/auth.tsx
Normal file
209
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// ============================================================
|
||||
// Auth Context — 认证状态管理
|
||||
// 职责:登录/注册/登出/token 刷新,为子组件提供 auth 状态
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as api from "./api";
|
||||
import type { AuthUser } from "./api";
|
||||
import {
|
||||
clearAuth,
|
||||
loadAccessToken,
|
||||
loadRefreshToken,
|
||||
loadUser,
|
||||
saveAccessToken,
|
||||
saveRefreshToken,
|
||||
saveUser,
|
||||
} from "./storage";
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
login: (username: string, password: string) => Promise<{ error?: string }>;
|
||||
register: (username: string, password: string) => Promise<{ error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/** Access token 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SEC = 60;
|
||||
|
||||
/** 解析 JWT payload(不做签名验证) */
|
||||
function parseJwtPayload(token: string): { exp?: number } | null {
|
||||
try {
|
||||
const base64 = token.split(".")[1];
|
||||
const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(loadUser);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(loadAccessToken);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 清除定时器
|
||||
const clearRefreshTimer = useCallback(() => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 持久化 token + user
|
||||
const persistAuth = useCallback(
|
||||
(authUser: AuthUser, access: string, refresh: string) => {
|
||||
setUser(authUser);
|
||||
setAccessToken(access);
|
||||
saveAccessToken(access);
|
||||
saveRefreshToken(refresh);
|
||||
saveUser(authUser);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 安排自动刷新
|
||||
const scheduleRefresh = useCallback(
|
||||
(access: string) => {
|
||||
clearRefreshTimer();
|
||||
const payload = parseJwtPayload(access);
|
||||
if (!payload?.exp) return;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const delayMs = Math.max((payload.exp - nowSec - REFRESH_BUFFER_SEC) * 1000, 5000);
|
||||
|
||||
refreshTimerRef.current = setTimeout(async () => {
|
||||
const rt = loadRefreshToken();
|
||||
if (!rt) return;
|
||||
const res = await api.refreshToken(rt);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
} else {
|
||||
// 刷新失败,清除 auth
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}
|
||||
}, delayMs);
|
||||
},
|
||||
[clearRefreshTimer, persistAuth]
|
||||
);
|
||||
|
||||
// 初始化:检查已有 token 并尝试刷新
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const storedAccess = loadAccessToken();
|
||||
const storedRefresh = loadRefreshToken();
|
||||
const storedUser = loadUser();
|
||||
|
||||
if (!storedAccess || !storedRefresh || !storedUser) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 access token 是否过期
|
||||
const payload = parseJwtPayload(storedAccess);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (payload?.exp && payload.exp > nowSec) {
|
||||
// access token 仍然有效
|
||||
setUser(storedUser);
|
||||
setAccessToken(storedAccess);
|
||||
scheduleRefresh(storedAccess);
|
||||
} else {
|
||||
// access token 过期,尝试 refresh
|
||||
const res = await api.refreshToken(storedRefresh);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
} else {
|
||||
clearAuth();
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => clearRefreshTimer();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string): Promise<{ error?: string }> => {
|
||||
const res = await api.login(username, password);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
return {};
|
||||
}
|
||||
return { error: res.error?.message || "登录失败" };
|
||||
},
|
||||
[persistAuth, scheduleRefresh]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string): Promise<{ error?: string }> => {
|
||||
const res = await api.register(username, password);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
return {};
|
||||
}
|
||||
return { error: res.error?.message || "注册失败" };
|
||||
},
|
||||
[persistAuth, scheduleRefresh]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const at = accessToken;
|
||||
const rt = loadRefreshToken();
|
||||
if (at && rt) {
|
||||
await api.logout(at, rt);
|
||||
}
|
||||
clearRefreshTimer();
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}, [accessToken, clearRefreshTimer]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isAuthenticated: !!user && !!accessToken,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
accessToken,
|
||||
}),
|
||||
[user, accessToken, isLoading, login, register, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -130,4 +130,19 @@ export const enUS: TranslationMap = {
|
||||
"error.vadInit": "VAD initialization failed",
|
||||
"error.cameraAccess": "Cannot access camera",
|
||||
"error.micAccess": "Cannot access microphone",
|
||||
|
||||
// Auth
|
||||
"auth.subtitle": "AI Vision Assistant",
|
||||
"auth.login": "Sign In",
|
||||
"auth.register": "Sign Up",
|
||||
"auth.username": "Username",
|
||||
"auth.username.placeholder": "3-64 characters",
|
||||
"auth.password": "Password",
|
||||
"auth.password.placeholder": "8-72 characters",
|
||||
"auth.submitting": "Please wait...",
|
||||
"auth.noAccount": "Don't have an account?",
|
||||
"auth.hasAccount": "Already have an account?",
|
||||
"auth.error.usernameLength": "Username must be 3-64 characters",
|
||||
"auth.error.passwordLength": "Password must be 8-72 characters",
|
||||
"auth.logout": "Sign Out",
|
||||
};
|
||||
|
||||
@@ -130,4 +130,19 @@ export const jaJP: TranslationMap = {
|
||||
"error.vadInit": "VAD初期化に失敗しました",
|
||||
"error.cameraAccess": "カメラにアクセスできません",
|
||||
"error.micAccess": "マイクにアクセスできません",
|
||||
|
||||
// Auth
|
||||
"auth.subtitle": "AI ビジョンアシスタント",
|
||||
"auth.login": "ログイン",
|
||||
"auth.register": "新規登録",
|
||||
"auth.username": "ユーザー名",
|
||||
"auth.username.placeholder": "3〜64文字",
|
||||
"auth.password": "パスワード",
|
||||
"auth.password.placeholder": "8〜72文字",
|
||||
"auth.submitting": "お待ちください...",
|
||||
"auth.noAccount": "アカウントをお持ちでないですか?",
|
||||
"auth.hasAccount": "すでにアカウントをお持ちですか?",
|
||||
"auth.error.usernameLength": "ユーザー名は3〜64文字で入力してください",
|
||||
"auth.error.passwordLength": "パスワードは8〜72文字で入力してください",
|
||||
"auth.logout": "ログアウト",
|
||||
};
|
||||
|
||||
@@ -130,4 +130,19 @@ export const zhCN: TranslationMap = {
|
||||
"error.vadInit": "VAD 初始化失败",
|
||||
"error.cameraAccess": "无法访问摄像头",
|
||||
"error.micAccess": "无法访问麦克风",
|
||||
|
||||
// Auth
|
||||
"auth.subtitle": "AI 视觉对话助手",
|
||||
"auth.login": "登录",
|
||||
"auth.register": "注册",
|
||||
"auth.username": "用户名",
|
||||
"auth.username.placeholder": "3-64 个字符",
|
||||
"auth.password": "密码",
|
||||
"auth.password.placeholder": "8-72 个字符",
|
||||
"auth.submitting": "请稍候...",
|
||||
"auth.noAccount": "还没有账号?",
|
||||
"auth.hasAccount": "已有账号?",
|
||||
"auth.error.usernameLength": "用户名需要 3-64 个字符",
|
||||
"auth.error.passwordLength": "密码需要 8-72 个字符",
|
||||
"auth.logout": "退出登录",
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types
|
||||
|
||||
const CONFIG_KEY = "camtalk:config";
|
||||
const THEME_KEY = "camtalk:theme";
|
||||
const ACCESS_TOKEN_KEY = "camtalk:access_token";
|
||||
const REFRESH_TOKEN_KEY = "camtalk:refresh_token";
|
||||
const USER_KEY = "camtalk:user";
|
||||
|
||||
const DEFAULT_CONFIG: SessionConfig = {
|
||||
ttsEnabled: true,
|
||||
@@ -100,3 +103,70 @@ export function deleteSessionMessages(sessionId: string): void {
|
||||
localStorage.removeItem(SESSION_MSG_PREFIX + sessionId);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- Auth Token 存储 ----
|
||||
|
||||
export interface StoredUser {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 保存 access token */
|
||||
export function saveAccessToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取 access token */
|
||||
export function loadAccessToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存 refresh token */
|
||||
export function saveRefreshToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取 refresh token */
|
||||
export function loadRefreshToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存用户信息 */
|
||||
export function saveUser(user: StoredUser): void {
|
||||
try {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取用户信息 */
|
||||
export function loadUser(): StoredUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as StoredUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有 auth 数据 */
|
||||
export function clearAuth(): void {
|
||||
try {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export class CamTalkWebSocket {
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
private token: string | undefined;
|
||||
|
||||
private messageHandlers = new Set<MessageHandler>();
|
||||
private statusHandlers = new Set<StatusHandler>();
|
||||
@@ -45,14 +46,16 @@ export class CamTalkWebSocket {
|
||||
return () => this.statusHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): void {
|
||||
/** 建立连接,可选传入 JWT token 用于认证 */
|
||||
connect(token?: string): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.token = token;
|
||||
this.shouldReconnect = true;
|
||||
this.setStatus("connecting");
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL;
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
@@ -130,7 +133,7 @@ export class CamTalkWebSocket {
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempt++;
|
||||
this.connect();
|
||||
this.connect(this.token);
|
||||
}, totalDelay);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user