feat: 登录改用 JWT(access token + refresh token),不再用 cookie
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:
xuyueandClaude Opus 5.5 committed 2026-10-26 19:10:55 +08:00
1 parent dde976cacb
commit 26827e31d5
21 files changed
+281 -141

No files matched your search

+5 -4
View File
@@ -12,19 +12,20 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- **`server/`** — Bun + **Hono** HTTP API, Bun's native WebSocket, **`bun:sqlite`** (single SQLite file, WAL). No Redis, no Postgres.
- `src/index.ts` — `Bun.serve`: WebSocket upgrades go to `ws.ts`, everything else to the Hono app
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, Origin check on writes, session middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
- `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions
- `src/auth.ts` — Django-compatible password hashing, sessions in table `auth_session` (cookie `sessionid`), `requireUser/requireAdmin/requireSuper` (401 / 403)
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt`
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes, done by hand), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
- `src/ws.ts` — `/ws/prompt/<task_id>/` (code generation chat) and `/ws/guidance/<task_id>/` (prompt coaching); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted
- `scripts/` — one-off CLIs: `migrate-from-postgres.ts`, `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
- `vite.config.ts` — browser targets are Chrome ≥ 87 (lab machines run Chrome 105); `@vitejs/plugin-legacy` only injects usage-based core-js polyfills, no legacy chunks
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
- `src/api.ts` — Axios-based API client; 401 clears login and goes home, 403 alerts and goes home
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
- `src/router.ts` — Vue Router with auth guards
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
+2 -11
View File
@@ -2,10 +2,9 @@
import { dateZhCN, zhCN } from "naive-ui"
import type { GlobalThemeOverrides } from "naive-ui"
import Login from "./components/Login.vue"
import { onMounted, watch } from "vue"
import { onMounted } from "vue"
import { Account } from "./api"
import { authed, user } from "./store/user"
import { STORAGE_KEY } from "./utils/const"
import { user } from "./store/user"
import hljs from "highlight.js/lib/core"
const themeOverrides: GlobalThemeOverrides = {
@@ -28,14 +27,6 @@ onMounted(async () => {
user.loaded = true
}
})
watch(authed, (v) => {
if (v) {
localStorage.setItem(STORAGE_KEY.LOGIN, "true")
} else {
localStorage.removeItem(STORAGE_KEY.LOGIN)
}
})
</script>
<template>
+32 -8
View File
@@ -25,24 +25,46 @@ import type {
CreationDetail,
CreationIn,
} from "./utils/type"
import { BASE_URL, STORAGE_KEY } from "./utils/const"
import { BASE_URL } from "./utils/const"
import {
clearTokens,
getAccessToken,
getRefreshToken,
hasLogin,
refreshAccessToken,
setTokens,
} from "./token"
import { user } from "./store/user"
const http = axios.create({
baseURL: BASE_URL,
xsrfCookieName: "xsrfCookieName",
xsrfHeaderName: "X-CSRFTOKEN",
withCredentials: true,
})
http.interceptors.request.use(async (config) => {
const token = await getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
(res) => {
return res
},
(err) => {
async (err) => {
if (err.response) {
// access token 失效(如服务端重启换了密钥):刷新一次后重试
if (
err.response.status === 401 &&
hasLogin() &&
!err.config._retried
) {
err.config._retried = true
if (await refreshAccessToken()) return http(err.config)
}
switch (err.response.status) {
case 401: // 未授权
localStorage.removeItem(STORAGE_KEY.LOGIN)
clearTokens()
user.username = ""
router.push("/")
break
case 403: // 禁止访问
@@ -62,12 +84,14 @@ http.interceptors.response.use(
export const Account = {
async login(username: string, password: string) {
const res = await http.post("/account/login", { username, password })
setTokens(res.data)
return res.data
},
async logout() {
const res = await http.post("/account/logout")
return res.data
const refresh_token = getRefreshToken()
clearTokens()
await http.post("/account/logout", { refresh_token })
},
async getMyProfile() {
+2 -3
View File
@@ -2,7 +2,7 @@ import { createWebHistory, createRouter } from "vue-router"
import { loginModal } from "./store/modal"
import Workspace from "./pages/Workspace.vue"
import { STORAGE_KEY } from "./utils/const"
import { hasLogin } from "./token"
import { authed } from "./store/user"
const routes = [
@@ -103,8 +103,7 @@ export const router = createRouter({
})
router.beforeEach((to) => {
const isLoggedIn =
authed.value || localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
const isLoggedIn = authed.value || hasLogin()
if (to.meta.auth && !isLoggedIn) {
loginModal.value = true
return false
+16 -7
View File
@@ -1,5 +1,5 @@
import { ref } from "vue"
import { WS_BASE_URL } from "../utils/const"
import { wsUrl } from "../token"
export interface GuidanceMessage {
role: "user" | "assistant"
@@ -17,6 +17,18 @@ export const initialPrompt = ref("")
let ws: WebSocket | null = null
let _taskId = 0
// 取 token 要等一下,期间又重连或关闭时作废这次连接
let connectSeq = 0
async function connect(taskId: number) {
if (ws) ws.close()
ws = null
const seq = ++connectSeq
const url = await wsUrl(`/ws/guidance/${taskId}/`)
if (seq !== connectSeq) return
ws = new WebSocket(url)
setupHandlers(ws)
}
function setupHandlers(socket: WebSocket) {
socket.onopen = () => {
@@ -69,9 +81,7 @@ export function openGuidance(taskId: number, prompt: string) {
streamingContent.value = ""
isOpen.value = true
if (ws) ws.close()
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${taskId}/`)
setupHandlers(ws)
connect(taskId)
}
export function sendGuidance(content: string) {
@@ -88,14 +98,13 @@ export function stopGuidance() {
streaming.value = false
streamingContent.value = ""
if (_taskId) {
if (ws) ws.close()
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${_taskId}/`)
initialPrompt.value = ""
setupHandlers(ws)
connect(_taskId)
}
}
export function closeGuidance() {
connectSeq++
if (ws) {
ws.close()
ws = null
+10 -3
View File
@@ -1,5 +1,5 @@
import { ref } from "vue"
import { WS_BASE_URL } from "../utils/const"
import { wsUrl } from "../token"
import { html, css, js } from "./editors"
export interface PromptMessage {
@@ -29,12 +29,18 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
}
let ws: WebSocket | null = null
// 取 token 要等一下,期间又连了别的题或断开时作废这次连接
let connectSeq = 0
export function connectPrompt(taskId: number) {
export async function connectPrompt(taskId: number) {
currentTaskId.value = taskId
if (ws) ws.close()
ws = null
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
const seq = ++connectSeq
const url = await wsUrl(`/ws/prompt/${taskId}/`)
if (seq !== connectSeq) return
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
@@ -92,6 +98,7 @@ export function connectPrompt(taskId: number) {
}
export function disconnectPrompt() {
connectSeq++
if (ws) {
ws.close()
ws = null
+70
View File
@@ -0,0 +1,70 @@
import axios from "axios"
import { BASE_URL, STORAGE_KEY, WS_BASE_URL } from "./utils/const"
// access token 有效期很短,快过期时提前换新的
const REFRESH_AHEAD_MS = 30_000
export function setTokens(tokens: {
access_token: string
refresh_token?: string
}) {
localStorage.setItem(STORAGE_KEY.ACCESS_TOKEN, tokens.access_token)
if (tokens.refresh_token) {
localStorage.setItem(STORAGE_KEY.REFRESH_TOKEN, tokens.refresh_token)
}
}
export function clearTokens() {
localStorage.removeItem(STORAGE_KEY.ACCESS_TOKEN)
localStorage.removeItem(STORAGE_KEY.REFRESH_TOKEN)
}
export const getRefreshToken = () =>
localStorage.getItem(STORAGE_KEY.REFRESH_TOKEN)
export const hasLogin = () => !!getRefreshToken()
function expiresSoon(token: string) {
try {
const payload = token.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/")
const { exp } = JSON.parse(atob(payload))
return exp * 1000 - Date.now() < REFRESH_AHEAD_MS
} catch {
return true
}
}
let refreshing: Promise<string | null> | null = null
// 同时有多个请求要刷新时只发一次
export function refreshAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken()
if (!refreshToken) return Promise.resolve(null)
refreshing ??= axios
.post(`${BASE_URL}/account/refresh`, { refresh_token: refreshToken })
.then((res) => {
setTokens(res.data)
return res.data.access_token as string
})
.catch((err) => {
// refresh token 失效(过期、改密码、封号)才清掉;网络错误下次再试
if (err.response?.status === 401) clearTokens()
return null
})
.finally(() => {
refreshing = null
})
return refreshing
}
export async function getAccessToken() {
const token = localStorage.getItem(STORAGE_KEY.ACCESS_TOKEN)
if (token && !expiresSoon(token)) return token
return refreshAccessToken()
}
// 浏览器建 WebSocket 时不能加请求头,token 放在查询参数里
export async function wsUrl(path: string) {
const token = await getAccessToken()
return `${WS_BASE_URL}${path}?token=${encodeURIComponent(token ?? "")}`
}
+2 -1
View File
@@ -27,7 +27,8 @@ export const alertVariants = [
]
export const STORAGE_KEY = {
LOGIN: "web-isloggedin",
ACCESS_TOKEN: "web-access-token",
REFRESH_TOKEN: "web-refresh-token",
HTML: "web-html",
CSS: "web-css",
JS: "web-js",
+2
View File
@@ -12,6 +12,8 @@ ENV=dev
# MEDIA_ROOT=../media
# 允许访问 API 的前端地址,逗号分隔
# ALLOWED_ORIGINS=http://localhost:3000
# access token 的签名密钥;不配置则每次启动随机生成(重启后前端会自动刷新 token)
# JWT_SECRET=
LLM_API_KEY=
LLM_MODEL=deepseek-v4-flash
+1 -1
View File
@@ -20,7 +20,7 @@ if (user) {
is_superuser = 1, is_staff = 1, is_active = 1 WHERE id = $id`,
{ hash, raw: password.slice(0, 20), id: user.id },
)
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: user.id })
run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id: user.id })
console.log(`已将 ${username} 设为超级管理员并重设密码`)
} else {
run(
+1 -1
View File
@@ -101,7 +101,7 @@ await pg.close()
db.transaction(() => {
db.run("PRAGMA defer_foreign_keys = ON")
for (const table of [...TABLES].reverse()) db.run(`DELETE FROM ${quote(table)}`)
db.run(`DELETE FROM auth_session`)
db.run(`DELETE FROM auth_refresh_token`)
for (const table of TABLES) {
const rows = rowsByTable[table]!
if (!rows.length) {
+4 -17
View File
@@ -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
View File
@@ -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()
}
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
},
-12
View File
@@ -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
}
}
+14 -8
View File
@@ -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
View File
@@ -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 })
+44 -10
View File
@@ -17,23 +17,57 @@ describe("权限", () => {
expect((await adm("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
})
test("封号后会话失效", async () => {
test("封号后 access token 和 refresh token 都失效", async () => {
const id = makeUser("stu")
makeUser("boss", "super")
const stu = await client("stu")
expect((await stu("GET", "/api/account/profile")).json.username).toBe("stu")
await (await client("boss"))("PUT", `/api/account/active/${id}`)
expect((await stu("GET", "/api/account/profile")).json.username).toBe("")
expect((await stu("GET", "/api/account/profile")).status).toBe(401)
const anon = await client()
const res = await anon("POST", "/api/account/refresh", { refresh_token: stu.tokens.refresh_token })
expect(res.status).toBe(401)
})
})
describe("JWT 登录", () => {
test("登录返回两个 token,refresh token 能换新的 access token", async () => {
makeUser("stu")
const stu = await client("stu")
expect(stu.tokens.access_token.split(".")).toHaveLength(3)
expect(get(`SELECT token_hash FROM auth_refresh_token`).token_hash).not.toBe(stu.tokens.refresh_token)
const anon = await client()
const res = await anon("POST", "/api/account/refresh", { refresh_token: stu.tokens.refresh_token })
expect(res.status).toBe(200)
stu.tokens.access_token = res.json.access_token
expect((await stu("GET", "/api/account/profile")).json.username).toBe("stu")
})
test("跨站写请求被拒绝", async () => {
const { app } = await import("../src/app")
const res = await app.request("/api/account/login", {
method: "POST",
headers: { origin: "https://evil.example", "content-type": "application/json" },
body: "{}",
})
expect(res.status).toBe(403)
test("无效或过期的 access token 返回 401,不带 token 当作未登录", async () => {
makeUser("stu")
const stu = await client("stu")
const { sign } = await import("hono/jwt")
const { config } = await import("../src/config")
const userId = get(`SELECT id FROM account_user WHERE username = 'stu'`).id
stu.tokens.access_token = await sign({ sub: userId, exp: Math.floor(Date.now() / 1000) - 1 }, config.jwtSecret)
expect((await stu("GET", "/api/account/profile")).status).toBe(401)
stu.tokens.access_token = "garbage"
expect((await stu("GET", "/api/account/profile")).status).toBe(401)
expect((await (await client())("GET", "/api/account/profile")).json.username).toBe("")
})
test("退出和改密码都会作废 refresh token", async () => {
const id = makeUser("stu")
makeUser("boss", "super")
const anon = await client()
const a = await client("stu")
expect((await a("POST", "/api/account/logout", { refresh_token: a.tokens.refresh_token })).status).toBe(200)
expect((await anon("POST", "/api/account/refresh", { refresh_token: a.tokens.refresh_token })).status).toBe(401)
const b = await client("stu")
await (await client("boss"))("PUT", `/api/account/${id}`, { password: "newpass1" })
expect((await anon("POST", "/api/account/refresh", { refresh_token: b.tokens.refresh_token })).status).toBe(401)
})
})
+7 -5
View File
@@ -48,9 +48,9 @@ export function makeSubmission(userId: number, taskId: number, score = 0, create
export const uuid = (hex: string) => uuidOut(hex)
/** 登录后返回一个带 cookie 的请求函数 */
/** 登录后返回一个带 access token 的请求函数,tokens 是登录拿到的 token */
export async function client(username?: string) {
let cookie = ""
const tokens = { access_token: "", refresh_token: "" }
if (username) {
const res = await app.request("/api/account/login", {
method: "POST",
@@ -58,10 +58,11 @@ export async function client(username?: string) {
body: JSON.stringify({ username, password: PASSWORD }),
})
if (res.status !== 200) throw new Error(`login failed: ${res.status}`)
cookie = res.headers.get("set-cookie")!.split(";")[0]!
Object.assign(tokens, await res.json())
}
return async (method: string, path: string, body?: unknown) => {
const headers: Record<string, string> = { cookie }
const request = async (method: string, path: string, body?: unknown) => {
const headers: Record<string, string> = {}
if (tokens.access_token) headers.authorization = `Bearer ${tokens.access_token}`
if (body !== undefined) headers["content-type"] = "application/json"
const res = await app.request(path, {
method,
@@ -75,4 +76,5 @@ export async function client(username?: string) {
} catch {}
return { status: res.status, json, text, headers: res.headers }
}
return Object.assign(request, { tokens })
}