🔐 feat(auth): integrate backend session login + 2FA; add HTTP client and route guards

- add lib/http.ts: unified fetch wrapper with:
  - 30s timeout via AbortController
  - JSON/text parsing and normalized HttpError
  - Authorization header from cookie
  - New-Api-User header from persisted user id
  - get/post/put/patch/del helpers
- add types/api.ts: ApiResponse, UserBasic, UserSelf, LoginResponse, Verify2FAResponse, SelfResponse
- add lib/auth.ts: persist/restore user in localStorage (get/set/clear helpers)
- refactor stores/auth-store.ts:
  - implement login (POST /api/user/login?turnstile=...), 2FA verify (POST /api/user/login/2fa)
  - implement fetchSelf (GET /api/user/self), logout (GET /api/user/logout)
  - persist user on successful login/verify/self
- refactor auth/sign-in form:
  - switch field to "username" (supports username or email), loosen validation
  - call auth.login, handle 2FA branch → redirect to /otp
- refactor OTP form:
  - call auth.verify2FA; on success, toast + redirect to home
- add route guards:
  - /_authenticated: beforeLoad redirect to /sign-in if no user; silently refresh session via auth.fetchSelf
  - /(auth)/sign-in: beforeLoad redirect to / if already logged in
- update sign-out dialog: use auth.logout and preserve redirect
- cleanup: remove mock auth, no direct axios/fetch usage outside http.ts, no Semi UI imports in new code

Notes
- Backend expects username or email in "username" field.
- Turnstile support: pass token via ?turnstile=... during login when enabled.

Security
- Ensure New-Api-User header matches logged-in user id per backend middleware.
- Session-based auth with optional Bearer token via cookie.

No breaking changes.
This commit is contained in:
t0ng7u
2025-09-26 01:03:17 +08:00
parent 836ae7affe
commit 347c31f93c
9 changed files with 348 additions and 52 deletions

121
web/src/lib/http.ts Normal file
View File

@@ -0,0 +1,121 @@
// 统一 http 封装超时、JSON、错误语义、鉴权头
import { getStoredUserId } from '@/lib/auth'
import { getCookie } from '@/lib/cookies'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
type JsonBody = Record<string, unknown> | Array<unknown> | undefined
export interface HttpError {
status: number
message: string
details?: unknown
}
async function safeMessage(res: Response): Promise<string> {
try {
const data = await res.clone().json()
if (typeof data?.message === 'string') return data.message
return JSON.stringify(data)
} catch {
try {
return await res.clone().text()
} catch {
return res.statusText || 'Request failed'
}
}
}
const DEFAULT_TIMEOUT_MS = 30000
const ACCESS_TOKEN = 'thisisjustarandomstring'
export async function http<T>(
input: RequestInfo | URL,
init: RequestInit & { timeoutMs?: number; asText?: boolean } = {}
): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(),
init.timeoutMs ?? DEFAULT_TIMEOUT_MS
)
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(init.headers || {}),
}
// 从 cookie 读取 token并自动附带
const token = getCookie(ACCESS_TOKEN)
if (token) {
;(headers as Record<string, string>)['Authorization'] =
`Bearer ${JSON.parse(token)}`
}
// 携带 New-Api-User 以通过后端鉴权
const userId = getStoredUserId()
if (typeof userId === 'number' && userId > 0) {
;(headers as Record<string, string>)['New-Api-User'] = String(userId)
}
try {
const response = await fetch(input, {
...init,
headers,
signal: controller.signal,
})
if (!response.ok) {
const message = await safeMessage(response)
const error: HttpError = { status: response.status, message }
throw error
}
if (init.asText) {
return (await response.text()) as unknown as T
}
// 缺省按 JSON 解析
return (await response.json()) as T
} finally {
clearTimeout(timeout)
}
}
export async function get<T>(url: string, init?: RequestInit) {
return http<T>(url, { ...init, method: 'GET' })
}
export async function post<T>(
url: string,
body?: JsonBody,
init?: RequestInit
) {
return http<T>(url, {
...init,
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
})
}
export async function put<T>(url: string, body?: JsonBody, init?: RequestInit) {
return http<T>(url, {
...init,
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
})
}
export async function patch<T>(
url: string,
body?: JsonBody,
init?: RequestInit
) {
return http<T>(url, {
...init,
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
})
}
export async function del<T>(url: string, init?: RequestInit) {
return http<T>(url, { ...init, method: 'DELETE' })
}