feat: 登录改用 JWT(access token + refresh token),不再用 cookie
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 登录返回 15 分钟的 access token(HS256,密钥 JWT_SECRET,未配置则每次启动随机) 和 14 天的 refresh token;请求带 Authorization: Bearer,WebSocket 用 ?token= - refresh token 只存 sha256,放在新表 auth_refresh_token(删除旧的 auth_session); POST /account/refresh 换新的 access token,退出、改密码、封号时作废 - 带了无效或过期的 token 一律 401,前端据此刷新:快过期时提前刷新、并发请求只刷新一次, 401 时刷新后重试一次,refresh token 也失效才退出登录 - 不再用 cookie,去掉 CORS credentials 和写请求的 Origin 校验 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dde976cacb
commit
26827e31d5
21 files changed
+281
-141
No files matched your search
+4
-17
@@ -1,9 +1,8 @@
|
||||
import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { config } from "./config"
|
||||
import { sessionMiddleware } from "./auth"
|
||||
import { authMiddleware } from "./auth"
|
||||
import { HttpError, ValidationError, type Env } from "./http"
|
||||
import { isAllowedOrigin } from "./origin"
|
||||
import account from "./routes/account"
|
||||
import { tutorial, challenge, creation } from "./routes/tasks"
|
||||
import { assets, upload } from "./routes/files"
|
||||
@@ -14,22 +13,10 @@ import { mediaPath } from "./routes/files"
|
||||
// strict: false —— 带不带结尾的斜杠都能匹配(前端两种写法都有)
|
||||
export const app = new Hono<Env>({ strict: false })
|
||||
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({ origin: config.allowedOrigins, credentials: true }),
|
||||
)
|
||||
// 登录凭证放在 Authorization 头里,浏览器不会自动带上,所以不需要 cookie 和 CSRF 防护
|
||||
app.use("/api/*", cors({ origin: config.allowedOrigins }))
|
||||
|
||||
// 会话 cookie 是 SameSite=Lax,再加一道 Origin 校验防止跨站写请求
|
||||
app.use("/api/*", async (c, next) => {
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method)) {
|
||||
if (!isAllowedOrigin(c.req.header("origin") ?? null, c.req.header("host") ?? null)) {
|
||||
return c.json({ detail: "跨站请求被拒绝" }, 403)
|
||||
}
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
app.use("/api/*", sessionMiddleware)
|
||||
app.use("/api/*", authMiddleware)
|
||||
|
||||
const api = new Hono<Env>({ strict: false })
|
||||
api.route("/account", account)
|
||||
|
||||
+51
-36
@@ -1,6 +1,6 @@
|
||||
import { pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { createHash, pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { promisify } from "node:util"
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie"
|
||||
import { sign, verify } from "hono/jwt"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { config } from "./config"
|
||||
import { get, run, now, toDbTime } from "./db"
|
||||
@@ -54,8 +54,8 @@ export async function setPassword(userId: number, raw: string) {
|
||||
`UPDATE account_user SET password = $password, raw_password = $raw WHERE id = $id`,
|
||||
{ password: await makePassword(raw), raw: raw.slice(0, 20), id: userId },
|
||||
)
|
||||
// 改密码后让该用户的其它会话失效(与 Django 行为一致)
|
||||
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: userId })
|
||||
// 改密码后让该用户的 refresh token 全部失效(与 Django 行为一致)
|
||||
run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id: userId })
|
||||
}
|
||||
|
||||
// ── 会话 ──
|
||||
@@ -76,53 +76,68 @@ export async function authenticate(username: string, password: string) {
|
||||
return user as User
|
||||
}
|
||||
|
||||
export function login(c: Ctx, user: User) {
|
||||
const key = randomBytes(24).toString("base64url")
|
||||
const expire = new Date(Date.now() + config.sessionAgeSeconds * 1000)
|
||||
run(`DELETE FROM auth_session WHERE expire_at < $now`, { now: now() })
|
||||
// access token 是短期 JWT(只带用户 id,每次请求仍查一次用户,封号立即生效);
|
||||
// refresh token 是随机串,表 auth_refresh_token 里只存它的 sha256,用来换新的 access token
|
||||
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex")
|
||||
|
||||
function signAccessToken(userId: number) {
|
||||
const exp = Math.floor(Date.now() / 1000) + config.accessTokenSeconds
|
||||
return sign({ sub: userId, exp }, config.jwtSecret, "HS256")
|
||||
}
|
||||
|
||||
export async function issueTokens(user: User) {
|
||||
const refreshToken = randomBytes(32).toString("base64url")
|
||||
const expire = new Date(Date.now() + config.refreshTokenSeconds * 1000)
|
||||
run(`DELETE FROM auth_refresh_token WHERE expire_at < $now`, { now: now() })
|
||||
run(
|
||||
`INSERT INTO auth_session (key, user_id, expire_at) VALUES ($key, $user_id, $expire_at)`,
|
||||
{ key, user_id: user.id, expire_at: toDbTime(expire) },
|
||||
`INSERT INTO auth_refresh_token (token_hash, user_id, expire_at) VALUES ($hash, $user_id, $expire_at)`,
|
||||
{ hash: hashToken(refreshToken), user_id: user.id, expire_at: toDbTime(expire) },
|
||||
)
|
||||
run(`UPDATE account_user SET last_login = $now WHERE id = $id`, {
|
||||
now: now(),
|
||||
id: user.id,
|
||||
})
|
||||
setCookie(c, config.sessionCookie, key, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: config.sessionAgeSeconds,
|
||||
})
|
||||
return { access_token: await signAccessToken(user.id), refresh_token: refreshToken }
|
||||
}
|
||||
|
||||
export function logout(c: Ctx) {
|
||||
const key = getCookie(c, config.sessionCookie)
|
||||
if (key) run(`DELETE FROM auth_session WHERE key = $key`, { key })
|
||||
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||
export async function refreshAccessToken(refreshToken: string) {
|
||||
const row = get<{ id: number }>(
|
||||
`SELECT u.id FROM auth_refresh_token t JOIN account_user u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $hash AND t.expire_at > $now AND u.is_active = 1`,
|
||||
{ hash: hashToken(refreshToken), now: now() },
|
||||
)
|
||||
if (!row) throw new HttpError(401, "登录已过期,请重新登录")
|
||||
return signAccessToken(row.id)
|
||||
}
|
||||
|
||||
export function userFromSessionKey(key: string | undefined): User | null {
|
||||
if (!key) return null
|
||||
export function revokeRefreshToken(refreshToken: string) {
|
||||
run(`DELETE FROM auth_refresh_token WHERE token_hash = $hash`, { hash: hashToken(refreshToken) })
|
||||
}
|
||||
|
||||
export async function userFromAccessToken(token: string | null | undefined): Promise<User | null> {
|
||||
if (!token) return null
|
||||
let payload
|
||||
try {
|
||||
payload = await verify(token, config.jwtSecret, "HS256")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return get<User>(
|
||||
`SELECT u.id, u.username, u.role, u.classname, u.is_active
|
||||
FROM auth_session s JOIN account_user u ON u.id = s.user_id
|
||||
WHERE s.key = $key AND s.expire_at > $now AND u.is_active = 1`,
|
||||
{ key, now: now() },
|
||||
`SELECT id, username, role, classname, is_active FROM account_user
|
||||
WHERE id = $id AND is_active = 1`,
|
||||
{ id: Number(payload.sub) },
|
||||
)
|
||||
}
|
||||
|
||||
export function parseCookie(header: string | null, name: string) {
|
||||
if (!header) return undefined
|
||||
for (const part of header.split(";")) {
|
||||
const [k, ...v] = part.trim().split("=")
|
||||
if (k === name) return decodeURIComponent(v.join("="))
|
||||
export const authMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const header = c.req.header("authorization")
|
||||
let user: User | null = null
|
||||
if (header?.startsWith("Bearer ")) {
|
||||
user = await userFromAccessToken(header.slice("Bearer ".length))
|
||||
// 带了 token 却无效(过期、被封号)时返回 401,前端据此去刷新 token,而不是当成未登录
|
||||
if (!user) throw new HttpError(401, "登录已过期")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const sessionMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||
c.set("user", userFromSessionKey(getCookie(c, config.sessionCookie)))
|
||||
c.set("user", user)
|
||||
await next()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
const env = process.env
|
||||
@@ -19,8 +20,10 @@ export const config = {
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
sessionCookie: "sessionid",
|
||||
sessionAgeSeconds: 60 * 60 * 24 * 14,
|
||||
// 未配置时每次启动随机生成:重启后 access token 全部失效,前端会用 refresh token 自动换新的
|
||||
jwtSecret: env.JWT_SECRET || randomBytes(32).toString("hex"),
|
||||
accessTokenSeconds: 15 * 60,
|
||||
refreshTokenSeconds: 60 * 60 * 24 * 14,
|
||||
|
||||
// 测试时不调用真实的模型接口
|
||||
llmApiKey: env.NODE_ENV === "test" ? "" : (env.LLM_API_KEY ?? ""),
|
||||
|
||||
+4
-2
@@ -48,8 +48,10 @@ CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY A
|
||||
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
|
||||
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "auth_session" ("key" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS "auth_session_user_id" ON "auth_session" ("user_id");
|
||||
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256
|
||||
DROP TABLE IF EXISTS "auth_session";
|
||||
CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS "auth_refresh_token_user_id" ON "auth_refresh_token" ("user_id");
|
||||
`
|
||||
|
||||
export function openDb(path: string): Database {
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ const server = Bun.serve<WsData>({
|
||||
maxRequestBodySize: 50 * 1024 * 1024,
|
||||
// AI 生成走 WebSocket,普通接口不会太久
|
||||
idleTimeout: 120,
|
||||
fetch(req, server) {
|
||||
const res = upgrade(req, server)
|
||||
async fetch(req, server) {
|
||||
const res = await upgrade(req, server)
|
||||
if (res !== null) return res
|
||||
return app.fetch(req)
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { config } from "./config"
|
||||
|
||||
// 同源请求,或来自白名单里的前端地址
|
||||
export function isAllowedOrigin(origin: string | null, host: string | null) {
|
||||
if (!origin) return true
|
||||
if (config.allowedOrigins.includes(origin)) return true
|
||||
try {
|
||||
return new URL(origin).host === host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import { all, get, run, tx, now, iso, likeEscape } from "../db"
|
||||
import {
|
||||
Role,
|
||||
authenticate,
|
||||
login,
|
||||
logout,
|
||||
issueTokens,
|
||||
makePassword,
|
||||
refreshAccessToken,
|
||||
revokeRefreshToken,
|
||||
requireSuper,
|
||||
requireUser,
|
||||
setPassword,
|
||||
@@ -70,13 +71,18 @@ router.post("/login", async (c) => {
|
||||
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
||||
const user = await authenticate(payload.username, payload.password)
|
||||
if (!user) throw new HttpError(401, "账号密码错误")
|
||||
login(c, user)
|
||||
return c.json({ username: user.username, role: user.role })
|
||||
return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) })
|
||||
})
|
||||
|
||||
router.post("/logout", (c) => {
|
||||
requireUser(c)
|
||||
logout(c)
|
||||
router.post("/refresh", async (c) => {
|
||||
const payload = await body(c, z.object({ refresh_token: z.string() }))
|
||||
return c.json({ access_token: await refreshAccessToken(payload.refresh_token) })
|
||||
})
|
||||
|
||||
// access token 是无状态的,退出只需作废 refresh token;不要求登录,过期了也能退出
|
||||
router.post("/logout", async (c) => {
|
||||
const payload = await body(c, z.object({ refresh_token: z.string().optional() }))
|
||||
if (payload.refresh_token) revokeRefreshToken(payload.refresh_token)
|
||||
return c.json({ message: "退出成功" })
|
||||
})
|
||||
|
||||
@@ -248,7 +254,7 @@ router.put("/active/:id", (c) => {
|
||||
)
|
||||
const active = !user.is_active
|
||||
run(`UPDATE account_user SET is_active = $active WHERE id = $id`, { active, id })
|
||||
if (!active) run(`DELETE FROM auth_session WHERE user_id = $id`, { id })
|
||||
if (!active) run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id })
|
||||
return c.json({ message: `${user.username} ${active ? "解封" : "封号"}成功` })
|
||||
})
|
||||
|
||||
|
||||
+7
-8
@@ -1,7 +1,6 @@
|
||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||
import { config } from "./config"
|
||||
import { all, run, iso, uuidOut } from "./db"
|
||||
import { parseCookie, userFromSessionKey, type User } from "./auth"
|
||||
import { userFromAccessToken, type User } from "./auth"
|
||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
||||
import {
|
||||
extractCode,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
streamGuidance,
|
||||
type ChatMessage,
|
||||
} from "./services/llm"
|
||||
import { isAllowedOrigin } from "./origin"
|
||||
|
||||
type Kind = "prompt" | "guidance"
|
||||
|
||||
@@ -34,14 +32,15 @@ type WS = ServerWebSocket<WsData>
|
||||
const PATH_RE = /^\/ws\/(prompt|guidance)\/(\d+)\/?$/
|
||||
|
||||
// 处理 WebSocket 升级请求;返回 undefined 表示已升级,返回 Response 表示拒绝或不是 ws 路径
|
||||
export function upgrade(req: Request, server: Server<WsData>): Response | undefined | null {
|
||||
// 浏览器建 WebSocket 时不能加请求头,access token 放在 ?token= 里
|
||||
export async function upgrade(
|
||||
req: Request,
|
||||
server: Server<WsData>,
|
||||
): Promise<Response | undefined | null> {
|
||||
const url = new URL(req.url)
|
||||
const match = PATH_RE.exec(url.pathname)
|
||||
if (!match) return null
|
||||
if (!isAllowedOrigin(req.headers.get("origin"), req.headers.get("host"))) {
|
||||
return new Response("Forbidden", { status: 403 })
|
||||
}
|
||||
const user = userFromSessionKey(parseCookie(req.headers.get("cookie"), config.sessionCookie))
|
||||
const user = await userFromAccessToken(url.searchParams.get("token"))
|
||||
const taskId = Number(match[2])
|
||||
if (!user || !canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
|
||||
|
||||
|
||||
Reference in new issue
Block a user