登录改用访问令牌 + 刷新令牌,不用 cookie
- 访问令牌:JWT(HS256),15 分钟,Authorization: Bearer;WebSocket 放在网址参数里 - 刷新令牌:30 天,每次刷新换新的,数据库只存哈希;退出登录即作废 - 重置密码、删除学生:令牌版本加一并删除刷新令牌,所有设备立即退出 - 签名密钥首次使用时生成,存在数据库 settings 表 - 浏览器:令牌存 localStorage;快过期先刷新,401 时刷新一次再重试;多个标签页同时刷新不冲突 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3eb563b70
commit
5bb2940969
11 files changed
+332
-110
No files matched your search
+2
-2
@@ -189,7 +189,7 @@ IOT/
|
||||
1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。
|
||||
- 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。
|
||||
2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。
|
||||
3. **账号管控**:不开放注册,老师上传名单批量创建学生账号;所有接口和运行程序的 WebSocket 都要先登录;按 IP 限制输错次数。会话令牌数据库里只存哈希。学生密码由老师发放、老师可查看,按原样保存(有意的取舍)。
|
||||
3. **账号管控**:不开放注册,老师上传名单批量创建学生账号;所有接口和运行程序的 WebSocket 都要先登录;按 IP 限制输错次数。登录用访问令牌(JWT,15 分钟,放在 Authorization 请求头里;WebSocket 放在网址参数里)+ 刷新令牌(30 天,每次刷新换新的,数据库只存哈希,退出登录即作废);重置密码、删除学生时用户的令牌版本加一,所有设备立即退出。学生密码由老师发放、老师可查看,按原样保存(有意的取舍)。
|
||||
|
||||
**资源估算**
|
||||
|
||||
@@ -270,7 +270,7 @@ flowchart TB
|
||||
|
||||
**第 2 阶段进展**:
|
||||
|
||||
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
|
||||
+20
-14
@@ -1,8 +1,7 @@
|
||||
// HTTP 接口。除登录外都要先登录;/api/teacher/* 只有老师能用。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { Hono, type Context } from 'hono'
|
||||
import { deleteCookie, setCookie } from 'hono/cookie'
|
||||
import { login, logout, sessionMaxAge, SESSION_COOKIE, sessionToken, sessionUser, tooManyFailures, type User } from './auth'
|
||||
import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type Tokens, type User } from './auth'
|
||||
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
|
||||
import { listSamples, loadSample } from './samples'
|
||||
import { serveStatic } from './static'
|
||||
@@ -20,8 +19,6 @@ function clientIp(c: AppContext) {
|
||||
return c.req.header('x-real-ip') || c.env?.requestIP?.(c.req.raw)?.address || ''
|
||||
}
|
||||
|
||||
const isHttps = (c: AppContext) => new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https'
|
||||
|
||||
/** 读取 JSON 请求体,格式不对时返回空对象 */
|
||||
const body = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
|
||||
|
||||
@@ -32,22 +29,31 @@ export function createApp(db: Database) {
|
||||
const ip = clientIp(c)
|
||||
if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429)
|
||||
const { username, password } = await body<{ username: string; password: string }>(c)
|
||||
const token = login(db, String(username ?? ''), String(password ?? ''), ip)
|
||||
if (!token) return c.json({ message: '账号或密码不对' }, 401)
|
||||
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: isHttps(c), path: '/', maxAge: sessionMaxAge })
|
||||
return c.json({ user: sessionUser(db, token) })
|
||||
const tokens = await login(db, String(username ?? ''), String(password ?? ''), ip)
|
||||
if (!tokens) return c.json({ message: '账号或密码不对' }, 401)
|
||||
return c.json(await withUser(tokens))
|
||||
})
|
||||
|
||||
app.post('/api/logout', (c) => {
|
||||
const token = sessionToken(c.req.header('cookie'))
|
||||
if (token) logout(db, token)
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||
/** 返回令牌时一并返回用户信息,浏览器不用再查一次 */
|
||||
const withUser = async (tokens: Tokens) => ({ ...tokens, user: await tokenUser(db, tokens.accessToken) })
|
||||
|
||||
// 用刷新令牌换新的访问令牌(刷新令牌也换新的)
|
||||
app.post('/api/refresh', async (c) => {
|
||||
const { refreshToken } = await body<{ refreshToken: string }>(c)
|
||||
const tokens = typeof refreshToken === 'string' ? await refresh(db, refreshToken) : null
|
||||
if (!tokens) return c.json({ message: '登录已过期,请重新登录' }, 401)
|
||||
return c.json(await withUser(tokens))
|
||||
})
|
||||
|
||||
app.post('/api/logout', async (c) => {
|
||||
const { refreshToken } = await body<{ refreshToken: string }>(c)
|
||||
if (typeof refreshToken === 'string') logout(db, refreshToken)
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
// 其余接口都要先登录
|
||||
// 其余接口都要先登录(请求头 Authorization: Bearer <令牌>)
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const user = sessionUser(db, sessionToken(c.req.header('cookie')))
|
||||
const user = await tokenUser(db, bearerToken(c.req.header('authorization')))
|
||||
if (!user) return c.json({ message: '请先登录' }, 401)
|
||||
c.set('user', user)
|
||||
await next()
|
||||
|
||||
+86
-43
@@ -1,10 +1,17 @@
|
||||
// 登录与会话:账号密码登录后发一个 HttpOnly cookie,数据库只存令牌的哈希。
|
||||
// 登录:访问令牌 + 刷新令牌。
|
||||
// - 访问令牌:JWT(HS256),15 分钟有效,放在请求头 Authorization: Bearer 里;
|
||||
// WebSocket 不能带请求头,连接时放在网址参数 token 里。
|
||||
// - 刷新令牌:随机字符串,30 天有效,用来换新的访问令牌;数据库只存哈希。每次刷新都换一个新的(旧的立即作废),
|
||||
// 一直在用的学生不用重新登录。退出登录删除这个刷新令牌。
|
||||
// - 重置密码、删除学生:用户的 token_version 加一(已签发的访问令牌立即失效),并删除他的所有刷新令牌。
|
||||
// 签名密钥第一次用到时随机生成,存在数据库里,服务器重启后令牌仍然有效。
|
||||
// 老师账号只有一个,账号密码来自配置;学生账号由老师导入名单创建。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { sign, verify } from 'hono/jwt'
|
||||
import { config } from './config'
|
||||
|
||||
export const SESSION_COOKIE = 'iot_session'
|
||||
const SESSION_DAYS = 180
|
||||
const ACCESS_SECONDS = 15 * 60
|
||||
const REFRESH_DAYS = 30
|
||||
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
|
||||
const MAX_FAILURES = 30
|
||||
const FAILURE_WINDOW_MS = 10 * 60_000
|
||||
@@ -18,7 +25,10 @@ export interface User {
|
||||
className: string | null
|
||||
}
|
||||
|
||||
const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex')
|
||||
export interface Tokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
let warnedDefaultPassword = false
|
||||
|
||||
@@ -36,6 +46,15 @@ export function ensureTeacher(db: Database) {
|
||||
).run(config.teacherUsername, password, Date.now())
|
||||
}
|
||||
|
||||
/** JWT 签名密钥:存在 settings 表里,第一次用到时生成 */
|
||||
function secret(db: Database): string {
|
||||
db.query(`INSERT OR IGNORE INTO settings (key, value) VALUES ('jwt_secret', ?)`).run(randomString())
|
||||
return db.query<{ value: string }, []>(`SELECT value FROM settings WHERE key = 'jwt_secret'`).get()!.value
|
||||
}
|
||||
|
||||
const randomString = () => Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
||||
const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex')
|
||||
|
||||
/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */
|
||||
const failures = new Map<string, { count: number; since: number }>()
|
||||
|
||||
@@ -49,9 +68,23 @@ export function tooManyFailures(ip: string) {
|
||||
return entry.count >= MAX_FAILURES
|
||||
}
|
||||
|
||||
/** 账号密码正确时创建会话,返回令牌(放进 cookie);不正确返回 null */
|
||||
export function login(db: Database, username: string, password: string, ip: string): string | null {
|
||||
const row = db.query<{ id: number; password: string }, [string]>('SELECT id, password FROM users WHERE username = ?').get(username.trim())
|
||||
async function issueTokens(db: Database, userId: number, tokenVersion: number): Promise<Tokens> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const accessToken = await sign({ sub: String(userId), ver: tokenVersion, iat: now, exp: now + ACCESS_SECONDS }, secret(db), 'HS256')
|
||||
const refreshToken = randomString()
|
||||
db.query('INSERT INTO refresh_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)').run(
|
||||
hash(refreshToken),
|
||||
userId,
|
||||
Date.now() + REFRESH_DAYS * 86_400_000,
|
||||
)
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
/** 账号密码正确时签发令牌;不正确返回 null */
|
||||
export async function login(db: Database, username: string, password: string, ip: string): Promise<Tokens | null> {
|
||||
const row = db
|
||||
.query<{ id: number; password: string; token_version: number }, [string]>('SELECT id, password, token_version FROM users WHERE username = ?')
|
||||
.get(username.trim())
|
||||
if (!row || row.password !== password) {
|
||||
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
|
||||
entry.count++
|
||||
@@ -59,47 +92,57 @@ export function login(db: Database, username: string, password: string, ip: stri
|
||||
return null
|
||||
}
|
||||
failures.delete(ip)
|
||||
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')
|
||||
db.query('INSERT INTO sessions (token_hash, user_id, created_at) VALUES (?, ?, ?)').run(hash(token), row.id, Date.now())
|
||||
return token
|
||||
return issueTokens(db, row.id, row.token_version)
|
||||
}
|
||||
|
||||
export function logout(db: Database, token: string) {
|
||||
db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash(token))
|
||||
}
|
||||
|
||||
/** 删除某个用户的所有会话(重置密码、删除学生时,让他已登录的浏览器失效) */
|
||||
export function endSessions(db: Database, userId: number) {
|
||||
db.query('DELETE FROM sessions WHERE user_id = ?').run(userId)
|
||||
}
|
||||
|
||||
/** 从 Cookie 请求头取出令牌 */
|
||||
export function sessionToken(cookieHeader: string | null | undefined) {
|
||||
return cookieHeader
|
||||
?.split(';')
|
||||
.map((part) => part.trim().split('='))
|
||||
.find(([name]) => name === SESSION_COOKIE)?.[1]
|
||||
}
|
||||
|
||||
/** 令牌对应的用户;没登录或会话已过期返回 null */
|
||||
export function sessionUser(db: Database, token: string | undefined): User | null {
|
||||
if (!token) return null
|
||||
/** 用刷新令牌换一对新令牌,旧的刷新令牌作废;无效或过期返回 null */
|
||||
export async function refresh(db: Database, refreshToken: string): Promise<Tokens | null> {
|
||||
const row = db
|
||||
.query<
|
||||
{ id: number; username: string; name: string; role: User['role']; class_id: number | null; class_name: string | null; created_at: number },
|
||||
[string]
|
||||
>(
|
||||
`SELECT u.id, u.username, u.name, u.role, u.class_id, c.name AS class_name, s.created_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN classes c ON c.id = u.class_id
|
||||
WHERE s.token_hash = ?`,
|
||||
.query<{ user_id: number; expires_at: number; token_version: number }, [string]>(
|
||||
`SELECT r.user_id, r.expires_at, u.token_version FROM refresh_tokens r JOIN users u ON u.id = r.user_id WHERE r.token_hash = ?`,
|
||||
)
|
||||
.get(hash(token))
|
||||
if (!row) return null
|
||||
if (Date.now() - row.created_at > SESSION_DAYS * 86_400_000) {
|
||||
logout(db, token)
|
||||
.get(hash(refreshToken))
|
||||
db.query('DELETE FROM refresh_tokens WHERE token_hash = ?').run(hash(refreshToken))
|
||||
if (!row || row.expires_at < Date.now()) return null
|
||||
// 顺便清掉这个用户已过期的刷新令牌
|
||||
db.query('DELETE FROM refresh_tokens WHERE user_id = ? AND expires_at < ?').run(row.user_id, Date.now())
|
||||
return issueTokens(db, row.user_id, row.token_version)
|
||||
}
|
||||
|
||||
/** 退出登录:作废这个刷新令牌(访问令牌 15 分钟后自然过期) */
|
||||
export function logout(db: Database, refreshToken: string) {
|
||||
db.query('DELETE FROM refresh_tokens WHERE token_hash = ?').run(hash(refreshToken))
|
||||
}
|
||||
|
||||
/** 让某个用户在所有设备上的登录立即失效(重置密码、删除学生时) */
|
||||
export function revokeTokens(db: Database, userId: number) {
|
||||
db.query('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(userId)
|
||||
db.query('DELETE FROM refresh_tokens WHERE user_id = ?').run(userId)
|
||||
}
|
||||
|
||||
/** 从 Authorization 请求头取出访问令牌 */
|
||||
export function bearerToken(header: string | null | undefined) {
|
||||
return /^Bearer\s+(\S+)$/i.exec(header ?? '')?.[1]
|
||||
}
|
||||
|
||||
/** 访问令牌对应的用户;令牌无效、过期或已被撤回时返回 null */
|
||||
export async function tokenUser(db: Database, accessToken: string | null | undefined): Promise<User | null> {
|
||||
if (!accessToken) return null
|
||||
let payload: { sub?: unknown; ver?: unknown }
|
||||
try {
|
||||
payload = (await verify(accessToken, secret(db), 'HS256')) as unknown as { sub?: unknown; ver?: unknown }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const row = db
|
||||
.query<
|
||||
{ id: number; username: string; name: string; role: User['role']; class_id: number | null; class_name: string | null; token_version: number },
|
||||
[number]
|
||||
>(
|
||||
`SELECT u.id, u.username, u.name, u.role, u.class_id, c.name AS class_name, u.token_version
|
||||
FROM users u LEFT JOIN classes c ON c.id = u.class_id WHERE u.id = ?`,
|
||||
)
|
||||
.get(Number(payload.sub))
|
||||
if (!row || row.token_version !== payload.ver) return null
|
||||
return { id: row.id, username: row.username, name: row.name, role: row.role, classId: row.class_id, className: row.class_name }
|
||||
}
|
||||
|
||||
export const sessionMaxAge = SESSION_DAYS * 86_400
|
||||
@@ -1,6 +1,6 @@
|
||||
// 班级和学生名单(老师用)。学生账号由老师上传名单导入,密码由老师发放,学生不能自己改。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { endSessions } from './auth'
|
||||
import { revokeTokens } from './auth'
|
||||
|
||||
export interface ClassInfo {
|
||||
id: number
|
||||
@@ -115,7 +115,7 @@ export function importStudents(db: Database, classId: number, rows: ImportRow[])
|
||||
}
|
||||
if (existing) {
|
||||
update.run(name, classId, password ?? null, existing.id)
|
||||
if (password) endSessions(db, existing.id)
|
||||
if (password) revokeTokens(db, existing.id)
|
||||
result.updated++
|
||||
} else {
|
||||
insert.run(username, name, password ?? generatePassword(), classId, Date.now())
|
||||
@@ -131,11 +131,11 @@ export function resetPassword(db: Database, studentId: number): string | null {
|
||||
const password = generatePassword()
|
||||
const changed = db.query(`UPDATE users SET password = ? WHERE id = ? AND role = 'student'`).run(password, studentId).changes
|
||||
if (!changed) return null
|
||||
endSessions(db, studentId)
|
||||
revokeTokens(db, studentId)
|
||||
return password
|
||||
}
|
||||
|
||||
/** 删除学生,连同他的作品和登录会话 */
|
||||
/** 删除学生,连同他的作品 */
|
||||
export function deleteStudent(db: Database, studentId: number) {
|
||||
db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId)
|
||||
}
|
||||
+17
-1
@@ -1,4 +1,4 @@
|
||||
// SQLite 数据库:账号、班级、登录会话、学生作品。
|
||||
// SQLite 数据库:账号、班级、学生作品、平台设置(如 JWT 签名密钥)。
|
||||
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
@@ -38,6 +38,22 @@ const MIGRATIONS = [
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
`,
|
||||
// 登录改用 JWT:短期访问令牌(不存库)+ 长期刷新令牌(只存哈希,可以撤回)。
|
||||
// token_version 递增时该用户已签发的访问令牌全部失效(重置密码、删除学生)
|
||||
`
|
||||
DROP TABLE sessions;
|
||||
ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX refresh_tokens_user ON refresh_tokens (user_id);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
]
|
||||
|
||||
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
import type { ClientMessage, ServerMessage } from '@iot/shared'
|
||||
import { createApp } from './app'
|
||||
import { ensureTeacher, sessionToken, sessionUser } from './auth'
|
||||
import { ensureTeacher, tokenUser } from './auth'
|
||||
import { compileService } from './compile-service'
|
||||
import { config } from './config'
|
||||
import { openDatabase } from './db'
|
||||
@@ -21,9 +21,11 @@ interface SocketData {
|
||||
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
fetch(req, server) {
|
||||
if (new URL(req.url).pathname === '/ws') {
|
||||
if (!sessionUser(db, sessionToken(req.headers.get('cookie')))) return new Response('请先登录', { status: 401 })
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/ws') {
|
||||
// 浏览器的 WebSocket 不能带请求头,令牌放在网址参数里
|
||||
if (!(await tokenUser(db, url.searchParams.get('token')))) return new Response('请先登录', { status: 401 })
|
||||
if (server.upgrade(req, { data: {} })) return
|
||||
return new Response('WebSocket 升级失败', { status: 400 })
|
||||
}
|
||||
|
||||
+49
-12
@@ -13,21 +13,25 @@ beforeEach(() => {
|
||||
app = createApp(db)
|
||||
})
|
||||
|
||||
/** 一个浏览器:记住登录 cookie */
|
||||
/** 一个浏览器:记住访问令牌和刷新令牌 */
|
||||
class Browser {
|
||||
cookie = ''
|
||||
accessToken = ''
|
||||
refreshToken = ''
|
||||
constructor(readonly ip = '10.0.0.1') {}
|
||||
|
||||
async request(method: string, path: string, json?: unknown) {
|
||||
const headers: Record<string, string> = { 'x-real-ip': this.ip }
|
||||
if (this.cookie) headers.cookie = this.cookie
|
||||
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`
|
||||
if (json !== undefined) headers['content-type'] = 'application/json'
|
||||
const response = await app.fetch(
|
||||
new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }),
|
||||
)
|
||||
const setCookie = response.headers.get('set-cookie')
|
||||
if (setCookie) this.cookie = setCookie.split(';')[0]
|
||||
return { status: response.status, body: (await response.json()) as any }
|
||||
const body = (await response.json()) as any
|
||||
if (body?.accessToken) {
|
||||
this.accessToken = body.accessToken
|
||||
this.refreshToken = body.refreshToken
|
||||
}
|
||||
return { status: response.status, body }
|
||||
}
|
||||
|
||||
get = (path: string) => this.request('GET', path)
|
||||
@@ -39,6 +43,10 @@ class Browser {
|
||||
login(username: string, password: string) {
|
||||
return this.post('/api/login', { username, password })
|
||||
}
|
||||
|
||||
refresh(refreshToken = this.refreshToken) {
|
||||
return this.post('/api/refresh', { refreshToken })
|
||||
}
|
||||
}
|
||||
|
||||
async function teacher() {
|
||||
@@ -147,6 +155,7 @@ describe('班级和名单', () => {
|
||||
const { body } = await t.post(`/api/teacher/students/${id}/reset-password`)
|
||||
expect(body.password).toMatch(/^\d{6}$/)
|
||||
expect((await student.get('/api/me')).status).toBe(401)
|
||||
expect((await student.refresh()).status).toBe(401)
|
||||
expect((await new Browser().login('2024001', 'abc123')).status).toBe(401)
|
||||
expect((await new Browser().login('2024001', body.password)).status).toBe(200)
|
||||
})
|
||||
@@ -194,10 +203,38 @@ describe('作品', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('退出登录后会话失效', async () => {
|
||||
const t = await teacher()
|
||||
const cookie = t.cookie
|
||||
await t.post('/api/logout')
|
||||
t.cookie = cookie
|
||||
expect((await t.get('/api/me')).status).toBe(401)
|
||||
describe('令牌', () => {
|
||||
test('登录返回访问令牌和刷新令牌;访问令牌是 15 分钟有效的 JWT', async () => {
|
||||
const t = await teacher()
|
||||
const payload = JSON.parse(Buffer.from(t.accessToken.split('.')[1], 'base64url').toString())
|
||||
expect(payload.exp - payload.iat).toBe(15 * 60)
|
||||
expect(t.refreshToken).toMatch(/^[\w-]{43}$/)
|
||||
})
|
||||
|
||||
test('刷新:换到新的一对令牌,旧的刷新令牌不能再用', async () => {
|
||||
const t = await teacher()
|
||||
const old = t.refreshToken
|
||||
const refreshed = await t.refresh()
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(refreshed.body.user.role).toBe('teacher')
|
||||
expect(t.refreshToken).not.toBe(old)
|
||||
expect((await t.get('/api/me')).status).toBe(200)
|
||||
expect((await t.refresh(old)).status).toBe(401)
|
||||
})
|
||||
|
||||
test('退出登录后刷新令牌作废', async () => {
|
||||
const t = await teacher()
|
||||
await t.post('/api/logout', { refreshToken: t.refreshToken })
|
||||
expect((await t.refresh()).status).toBe(401)
|
||||
})
|
||||
|
||||
test('伪造或改过的访问令牌无效', async () => {
|
||||
const t = await teacher()
|
||||
const [header, payload, signature] = t.accessToken.split('.')
|
||||
const forged = Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(payload, 'base64url').toString()), sub: '999' })).toString('base64url')
|
||||
t.accessToken = `${header}.${forged}.${signature}`
|
||||
expect((await t.get('/api/me')).status).toBe(401)
|
||||
t.accessToken = 'not-a-token'
|
||||
expect((await t.get('/api/me')).status).toBe(401)
|
||||
})
|
||||
})
|
||||
+24
-12
@@ -1,5 +1,7 @@
|
||||
// 调用服务器接口。出错时抛出 ApiError,message 是服务器给的中文说明;
|
||||
// 登录过期(401)时回到登录页。
|
||||
// 请求带上访问令牌(快过期时先刷新);登录过期(刷新也失败)时回到登录页。
|
||||
import { refreshTokens, validAccessToken } from './tokens'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -13,19 +15,29 @@ export class ApiError extends Error {
|
||||
export const apiHooks = { onUnauthorized: () => {} }
|
||||
|
||||
export async function api<T>(method: string, path: string, json?: unknown, init?: RequestInit): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method,
|
||||
headers: json === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: json === undefined ? undefined : JSON.stringify(json),
|
||||
...init,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError('连不上服务器,请检查网络', 0)
|
||||
}
|
||||
const response = await send(method, path, json, init)
|
||||
const data = (await response.json().catch(() => ({}))) as T & { message?: string }
|
||||
if (response.status === 401 && path !== '/api/login') apiHooks.onUnauthorized()
|
||||
if (!response.ok) throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
|
||||
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (json !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (path !== '/api/login') {
|
||||
const token = await validAccessToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), ...init })
|
||||
} catch {
|
||||
throw new ApiError('连不上服务器,请检查网络', 0)
|
||||
}
|
||||
if (response.status === 401 && path !== '/api/login' && !retried && (await refreshTokens())) {
|
||||
return send(method, path, json, init, true)
|
||||
}
|
||||
return response
|
||||
}
|
||||
+10
-4
@@ -1,6 +1,7 @@
|
||||
// 当前登录的用户。页面启动时先问一次服务器,之后登录、退出时更新。
|
||||
// 当前登录的用户。页面启动时用保存的令牌问一次服务器,之后登录、退出时更新。
|
||||
import { ref } from 'vue'
|
||||
import { api } from './api'
|
||||
import { clearTokens, refreshToken, setTokens, type Tokens } from './tokens'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
@@ -13,18 +14,23 @@ export interface User {
|
||||
|
||||
export const currentUser = ref<User | null>(null)
|
||||
|
||||
/** 页面启动时调用:已登录(cookie 有效)就取到用户 */
|
||||
/** 页面启动时调用:保存的令牌还有效(或能刷新)就取到用户 */
|
||||
export async function loadCurrentUser() {
|
||||
if (!refreshToken()) return
|
||||
currentUser.value = await api<{ user: User }>('GET', '/api/me')
|
||||
.then((r) => r.user)
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
currentUser.value = (await api<{ user: User }>('POST', '/api/login', { username, password })).user
|
||||
const result = await api<Tokens & { user: User }>('POST', '/api/login', { username, password })
|
||||
setTokens(result)
|
||||
currentUser.value = result.user
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await api('POST', '/api/logout').catch(() => {})
|
||||
const token = refreshToken()
|
||||
clearTokens()
|
||||
currentUser.value = null
|
||||
if (token) await api('POST', '/api/logout', { refreshToken: token }).catch(() => {})
|
||||
}
|
||||
+19
-14
@@ -1,4 +1,5 @@
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { validAccessToken } from '../tokens'
|
||||
import type {
|
||||
BoardState,
|
||||
ClientMessage,
|
||||
@@ -43,20 +44,24 @@ export function createRunSession() {
|
||||
let actionSentAt = 0
|
||||
|
||||
function connect() {
|
||||
connecting ??= new Promise<void>((resolve) => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws`)
|
||||
socket = ws
|
||||
ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
|
||||
ws.onopen = () => resolve()
|
||||
ws.onclose = () => {
|
||||
state.status = 'stopped'
|
||||
state.board = null
|
||||
socket = undefined
|
||||
connecting = undefined
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
connecting ??= validAccessToken().then(
|
||||
(token) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
// 浏览器的 WebSocket 不能带请求头,访问令牌放在网址参数里(只在建立连接时检查)
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws?token=${encodeURIComponent(token ?? '')}`)
|
||||
socket = ws
|
||||
ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
|
||||
ws.onopen = () => resolve()
|
||||
ws.onclose = () => {
|
||||
state.status = 'stopped'
|
||||
state.board = null
|
||||
socket = undefined
|
||||
connecting = undefined
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
)
|
||||
return connecting
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// 登录令牌:访问令牌(15 分钟)和刷新令牌(30 天)都放在 localStorage,多个标签页共用。
|
||||
// 访问令牌快过期时先用刷新令牌换一对新的;同时有多个请求要刷新时只刷新一次。
|
||||
// 刷新令牌每用一次就作废:另一个标签页刚刷新过时,这边的旧令牌会失败,这时改用它存下的新令牌。
|
||||
|
||||
const ACCESS_KEY = 'iot-sim:access-token'
|
||||
const REFRESH_KEY = 'iot-sim:refresh-token'
|
||||
|
||||
export interface Tokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
function read(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setTokens(tokens: Tokens) {
|
||||
try {
|
||||
localStorage.setItem(ACCESS_KEY, tokens.accessToken)
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refreshToken)
|
||||
} catch {
|
||||
// 浏览器禁用了本地存储:只能本次页面内有效
|
||||
memory = tokens
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
memory = null
|
||||
try {
|
||||
localStorage.removeItem(ACCESS_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地存储不可用时退回内存 */
|
||||
let memory: Tokens | null = null
|
||||
|
||||
const accessToken = () => read(ACCESS_KEY) ?? memory?.accessToken ?? null
|
||||
export const refreshToken = () => read(REFRESH_KEY) ?? memory?.refreshToken ?? null
|
||||
|
||||
/** 访问令牌是否会在一分钟内过期(读 JWT 里的 exp,不校验签名) */
|
||||
function expiresSoon(token: string) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) as { exp?: number }
|
||||
return !payload.exp || payload.exp * 1000 < Date.now() + 60_000
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let refreshing: Promise<boolean> | null = null
|
||||
|
||||
/** 用刷新令牌换新令牌;返回是否成功。登录已过期(服务器拒绝)时清掉令牌 */
|
||||
export function refreshTokens(): Promise<boolean> {
|
||||
refreshing ??= (async () => {
|
||||
const tried = refreshToken()
|
||||
if (!tried) return false
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch('/api/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: tried }),
|
||||
})
|
||||
} catch {
|
||||
return false // 网络问题:保留令牌,下次再试
|
||||
}
|
||||
if (response.ok) {
|
||||
setTokens((await response.json()) as Tokens)
|
||||
return true
|
||||
}
|
||||
// 另一个标签页已经用这个刷新令牌换过了新的
|
||||
const latest = refreshToken()
|
||||
if (latest && latest !== tried) return true
|
||||
clearTokens()
|
||||
return false
|
||||
})().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
/** 可用的访问令牌:快过期时先刷新;没登录或登录已过期返回 null */
|
||||
export async function validAccessToken(): Promise<string | null> {
|
||||
const token = accessToken()
|
||||
if (token && !expiresSoon(token)) return token
|
||||
if (!(await refreshTokens())) return null
|
||||
return accessToken()
|
||||
}
|
||||
Reference in new issue
Block a user