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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? "")}`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
|
||||
+44
-10
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user