登录改用访问令牌 + 刷新令牌,不用 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:
xuyueandClaude Opus 5.5 committed 2026-10-22 18:20:41 +08:00
1 parent a3eb563b70
commit 5bb2940969
11 files changed
+332 -110

No files matched your search

+20 -14
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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 })
}