style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

+15 -17
View File
@@ -1,8 +1,8 @@
import { ADMIN_ROLES, TEACHER_ROLES } from "@oj2/contract"
import type { Context, MiddlewareHandler } from "hono"
import { ADMIN_ROLES, TEACHER_ROLES } from '@oj2/contract'
import type { Context, MiddlewareHandler } from 'hono'
import { failure } from "../http"
import { resolveSession, type AuthUser } from "./session"
import { failure } from '../http'
import { resolveSession, type AuthUser } from './session'
export interface AppEnv {
Variables: {
@@ -18,8 +18,8 @@ export interface AppEnv {
*/
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
const session = await resolveSession(c)
if (session.reason === "expired") return denied(c, "expired")
c.set("user", session.user)
if (session.reason === 'expired') return denied(c, 'expired')
c.set('user', session.user)
await next()
}
@@ -33,17 +33,17 @@ export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
* 用 403 而不是 401:凭证是有效的,是这个账号不让用了,和 login 接口对禁用账号
* 的回法(403 `account-disabled`)也一致。
*/
function denied(c: Context, reason: "anonymous" | "expired" | "disabled") {
if (reason === "disabled") return failure(c, 403, "account-disabled", "账号已被禁用,请联系老师")
function denied(c: Context, reason: 'anonymous' | 'expired' | 'disabled') {
if (reason === 'disabled') return failure(c, 403, 'account-disabled', '账号已被禁用,请联系老师')
// 前端见到这个码会拿 refresh token 换一张再重试,换不到才当没登录
if (reason === "expired") return failure(c, 401, "token-expired", "登录已过期,请重新登录")
return failure(c, 401, "login-required", "请先登录")
if (reason === 'expired') return failure(c, 401, 'token-expired', '登录已过期,请重新登录')
return failure(c, 401, 'login-required', '请先登录')
}
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
const session = await resolveSession(c)
if (!session.user) return denied(c, session.reason)
c.set("user", session.user)
c.set('user', session.user)
await next()
}
@@ -58,8 +58,8 @@ function requireRole(allowed: (user: AuthUser) => boolean): MiddlewareHandler<Ap
return async (c, next) => {
const session = await resolveSession(c)
if (!session.user) return denied(c, session.reason)
if (!allowed(session.user)) return failure(c, 403, "permission-denied", "权限不足")
c.set("user", session.user)
if (!allowed(session.user)) return failure(c, 403, 'permission-denied', '权限不足')
c.set('user', session.user)
await next()
}
}
@@ -71,13 +71,11 @@ export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.admi
export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType))
/** 旧 `@super_admin_required` */
export const requireSuperAdmin = requireRole((user) => user.adminType === "Super Admin")
export const requireSuperAdmin = requireRole((user) => user.adminType === 'Super Admin')
/**
* 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。
* 注意它只管「能不能进这个接口」,「能改哪些题」(Own vs All)由各 handler 自己按
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
*/
export const requireProblemPermission = requireRole(
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
)
export const requireProblemPermission = requireRole((user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== 'None')
+9 -9
View File
@@ -1,21 +1,21 @@
import { pbkdf2, timingSafeEqual } from "node:crypto"
import { promisify } from "node:util"
import { pbkdf2, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
const pbkdf2Async = promisify(pbkdf2)
async function verifyDjangoPbkdf2(password: string, encoded: string) {
const [algorithm, iterationsText, salt, digestText] = encoded.split("$")
if (algorithm !== "pbkdf2_sha256" || !iterationsText || !salt || !digestText) {
const [algorithm, iterationsText, salt, digestText] = encoded.split('$')
if (algorithm !== 'pbkdf2_sha256' || !iterationsText || !salt || !digestText) {
return false
}
const iterations = Number(iterationsText)
const expected = Buffer.from(digestText, "base64")
const expected = Buffer.from(digestText, 'base64')
if (!Number.isSafeInteger(iterations) || iterations <= 0 || expected.length === 0) {
return false
}
const actual = await pbkdf2Async(password, salt, iterations, expected.length, "sha256")
const actual = await pbkdf2Async(password, salt, iterations, expected.length, 'sha256')
return timingSafeEqual(actual, expected)
}
@@ -25,14 +25,14 @@ async function verifyDjangoPbkdf2(password: string, encoded: string) {
* 迭代次数是从哈希串里读的,所以 120000 到 1200000 的老哈希都验得了。
*/
export async function verifyPassword(password: string, encoded: string) {
if (encoded.startsWith("pbkdf2_sha256$")) {
if (encoded.startsWith('pbkdf2_sha256$')) {
return {
valid: await verifyDjangoPbkdf2(password, encoded),
needsUpgrade: true,
}
}
if (encoded.startsWith("$argon2")) {
if (encoded.startsWith('$argon2')) {
return {
valid: await Bun.password.verify(password, encoded),
needsUpgrade: false,
@@ -53,7 +53,7 @@ export async function verifyPassword(password: string, encoded: string) {
* 账号一个都不用迁移**,Bun.password.verify 读串里的参数验,改这里只影响此后新写的哈希。
*/
const ARGON2_OPTIONS = {
algorithm: "argon2id",
algorithm: 'argon2id',
memoryCost: 19456,
timeCost: 2,
} as const
+6 -13
View File
@@ -1,7 +1,7 @@
import type { ChainableCommander } from "ioredis"
import type { ChainableCommander } from 'ioredis'
import { redis } from "../redis"
import { calendarDay } from "../time"
import { redis } from '../redis'
import { calendarDay } from '../time'
/**
* 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。
@@ -11,7 +11,7 @@ import { calendarDay } from "../time"
* 写入一律搭在已有的 pipeline 上(登录、每个带鉴权的请求、WebSocket 巡检),
* 不多一趟往返。
*/
const PRESENCE_KEY = "online-users"
const PRESENCE_KEY = 'online-users'
/**
* 多久没动就算离线。挂着页面不操作的人靠 sweepSessions 每 60 秒续一次
@@ -33,10 +33,7 @@ const SEEN_TODAY_TTL_SECONDS = 2 * 24 * 60 * 60
export function markOnline(pipeline: ChainableCommander, userId: number) {
const now = Date.now()
const seenKey = seenTodayKey(calendarDay(now))
pipeline
.zadd(PRESENCE_KEY, now, String(userId))
.zadd(seenKey, now, String(userId))
.expire(seenKey, SEEN_TODAY_TTL_SECONDS)
pipeline.zadd(PRESENCE_KEY, now, String(userId)).zadd(seenKey, now, String(userId)).expire(seenKey, SEEN_TODAY_TTL_SECONDS)
}
/**
@@ -48,11 +45,7 @@ export function markOnline(pipeline: ChainableCommander, userId: number) {
*/
export async function onlineUserIds() {
const cutoff = Date.now() - ONLINE_WINDOW_MS
const results = await redis
.pipeline()
.zremrangebyscore(PRESENCE_KEY, "-inf", `(${cutoff}`)
.zrange(PRESENCE_KEY, "0", "-1")
.exec()
const results = await redis.pipeline().zremrangebyscore(PRESENCE_KEY, '-inf', `(${cutoff}`).zrange(PRESENCE_KEY, '0', '-1').exec()
const members = (results?.[1]?.[1] ?? []) as string[]
return new Set(members.map(Number).filter(Number.isInteger))
}
+45 -82
View File
@@ -1,23 +1,16 @@
import { randomBytes, timingSafeEqual } from "node:crypto"
import { randomBytes, timingSafeEqual } from 'node:crypto'
import {
toAdminType,
toProblemPermission,
type AdminType,
type AuthTokens,
type ProblemPermission,
WS_AUTH_PROTOCOL,
} from "@oj2/contract"
import { toAdminType, toProblemPermission, type AdminType, type AuthTokens, type ProblemPermission, WS_AUTH_PROTOCOL } from '@oj2/contract'
import { eq, sql } from "drizzle-orm"
import type { Context } from "hono"
import { sign, verify } from "hono/jwt"
import { eq, sql } from 'drizzle-orm'
import type { Context } from 'hono'
import { sign, verify } from 'hono/jwt'
import { config } from "../config"
import { db, schema } from "../db"
import { publishSessionRevoked, type SessionRevokedReason } from "../events"
import { clearOnline, markOnline } from "./presence"
import { redis } from "../redis"
import { config } from '../config'
import { db, schema } from '../db'
import { publishSessionRevoked, type SessionRevokedReason } from '../events'
import { clearOnline, markOnline } from './presence'
import { redis } from '../redis'
/**
* 登录态是两张 token:
@@ -39,12 +32,12 @@ import { redis } from "../redis"
* 续会话),没有多出往返。
*/
const SESSION_PREFIX = "auth-session:"
const SESSION_PREFIX = 'auth-session:'
/**
* 输过的比赛密码,一张会话一个 hash(field = 比赛 id)。不塞进会话本体:
* 会话本体每换一次 token 就整份重写一次,两边一起读改写会互相冲掉。
*/
const CONTEST_PASSWORD_PREFIX = "auth-contest:"
const CONTEST_PASSWORD_PREFIX = 'auth-contest:'
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60
@@ -65,17 +58,17 @@ const REFRESH_GRACE_MS = 60_000
* env 文件里各补一行,漏了也不会起不来。要让全站所有人重新登录,删掉这个 key
* 再重启 api 即可。
*/
const JWT_SECRET_KEY = "auth:jwt-secret"
const JWT_SECRET_KEY = 'auth:jwt-secret'
let jwtSecret: string | null = null
export async function loadJwtSecret() {
await redis.set(JWT_SECRET_KEY, randomBytes(48).toString("base64url"), "NX")
await redis.set(JWT_SECRET_KEY, randomBytes(48).toString('base64url'), 'NX')
jwtSecret = await redis.get(JWT_SECRET_KEY)
if (!jwtSecret) throw new Error("JWT 密钥没能写进 Redis")
if (!jwtSecret) throw new Error('JWT 密钥没能写进 Redis')
}
function secret() {
if (!jwtSecret) throw new Error("JWT 密钥还没加载,入口要先 await loadJwtSecret()")
if (!jwtSecret) throw new Error('JWT 密钥还没加载,入口要先 await loadJwtSecret()')
return jwtSecret
}
@@ -133,7 +126,7 @@ function sameSecret(a: string, b: string | null) {
/** `<sid>.<随机串>` 拆开。格式不对返回 null */
function splitRefreshToken(token: string) {
const dot = token.indexOf(".")
const dot = token.indexOf('.')
if (dot <= 0 || dot === token.length - 1) return null
return { sid: token.slice(0, dot), secret: token.slice(dot + 1) }
}
@@ -170,28 +163,21 @@ end
return 0`
async function compareAndSetSession(sid: string, expected: string, value: StoredSession) {
const result = await redis.eval(
COMPARE_AND_SET,
1,
sessionKey(sid),
expected,
JSON.stringify(value),
config.sessionTtlSeconds,
)
const result = await redis.eval(COMPARE_AND_SET, 1, sessionKey(sid), expected, JSON.stringify(value), config.sessionTtlSeconds)
return result === 1
}
async function signAccessToken(claims: AccessClaims) {
const now = Math.floor(Date.now() / 1000)
return sign({ ...claims, iat: now, exp: now + ACCESS_TOKEN_TTL_SECONDS }, secret(), "HS256")
return sign({ ...claims, iat: now, exp: now + ACCESS_TOKEN_TTL_SECONDS }, secret(), 'HS256')
}
/** 验签名和有效期,载荷形状不对也当无效。不碰 Redis 和库 */
async function verifyAccessToken(token: string): Promise<AccessClaims | null> {
try {
const payload = await verify(token, secret(), "HS256")
const payload = await verify(token, secret(), 'HS256')
const { uid, sid, ver } = payload
if (!Number.isInteger(uid) || typeof sid !== "string" || !Number.isInteger(ver)) return null
if (!Number.isInteger(uid) || typeof sid !== 'string' || !Number.isInteger(ver)) return null
return { uid: uid as number, sid, ver: ver as number }
} catch {
return null
@@ -216,13 +202,9 @@ async function selectUser(userId: number) {
return user ?? null
}
export async function issueTokens(
userId: number,
tokenVersion: number,
previousLogin: string | null = null,
): Promise<AuthTokens> {
const sid = randomBytes(16).toString("base64url")
const refreshSecret = randomBytes(32).toString("base64url")
export async function issueTokens(userId: number, tokenVersion: number, previousLogin: string | null = null): Promise<AuthTokens> {
const sid = randomBytes(16).toString('base64url')
const refreshSecret = randomBytes(32).toString('base64url')
const value: StoredSession = {
userId,
tokenVersion,
@@ -234,9 +216,7 @@ export async function issueTokens(
}
// 两条写进一个 pipeline:一个班四十号人同时登录时,两趟往返和一趟的差别
// 全压在登录这一下上
const pipeline = redis
.pipeline()
.set(sessionKey(sid), JSON.stringify(value), "EX", config.sessionTtlSeconds)
const pipeline = redis.pipeline().set(sessionKey(sid), JSON.stringify(value), 'EX', config.sessionTtlSeconds)
markOnline(pipeline, userId)
await pipeline.exec()
return {
@@ -246,9 +226,7 @@ export async function issueTokens(
}
}
export type RefreshResult =
| { tokens: AuthTokens; reason?: undefined }
| { tokens: null; reason: "invalid" | "disabled" }
export type RefreshResult = { tokens: AuthTokens; reason?: undefined } | { tokens: null; reason: 'invalid' | 'disabled' }
/**
* 拿 refresh token 换一张新的 access token,同时轮换 refresh token。
@@ -259,30 +237,27 @@ export type RefreshResult =
*/
export async function refreshTokens(refreshToken: string): Promise<RefreshResult> {
const parts = splitRefreshToken(refreshToken)
if (!parts) return { tokens: null, reason: "invalid" }
if (!parts) return { tokens: null, reason: 'invalid' }
// 比对再写入失败(别的标签页抢先轮换了)就重读一遍,这时手里这张已经是「上一张」,
// 第二轮走宽限期。三轮还落不了地只可能是被刷,直接拒
for (let attempt = 0; attempt < 3; attempt++) {
const stored = await readSessionRaw(parts.sid)
if (!stored) return { tokens: null, reason: "invalid" }
if (!stored) return { tokens: null, reason: 'invalid' }
const session = stored.value
const current = sameSecret(parts.secret, session.refreshSecret)
const inGrace =
!current &&
sameSecret(parts.secret, session.previousRefreshSecret) &&
Date.now() - session.rotatedAt < REFRESH_GRACE_MS
if (!current && !inGrace) return { tokens: null, reason: "invalid" }
const inGrace = !current && sameSecret(parts.secret, session.previousRefreshSecret) && Date.now() - session.rotatedAt < REFRESH_GRACE_MS
if (!current && !inGrace) return { tokens: null, reason: 'invalid' }
const user = await selectUser(session.userId)
if (!user || user.tokenVersion !== session.tokenVersion) {
await redis.del(sessionKey(parts.sid))
return { tokens: null, reason: "invalid" }
return { tokens: null, reason: 'invalid' }
}
if (user.isDisabled) {
await redis.del(sessionKey(parts.sid))
return { tokens: null, reason: "disabled" }
return { tokens: null, reason: 'disabled' }
}
const accessToken = await signAccessToken({
@@ -301,7 +276,7 @@ export async function refreshTokens(refreshToken: string): Promise<RefreshResult
}
}
const refreshSecret = randomBytes(32).toString("base64url")
const refreshSecret = randomBytes(32).toString('base64url')
const rotated = await compareAndSetSession(parts.sid, stored.raw, {
...session,
refreshSecret,
@@ -318,7 +293,7 @@ export async function refreshTokens(refreshToken: string): Promise<RefreshResult
}
}
}
return { tokens: null, reason: "invalid" }
return { tokens: null, reason: 'invalid' }
}
/**
@@ -332,10 +307,7 @@ export async function destroySession(refreshToken: string) {
if (!parts) return null
const session = await readSession(parts.sid)
if (!session) return null
if (
!sameSecret(parts.secret, session.refreshSecret) &&
!sameSecret(parts.secret, session.previousRefreshSecret)
) {
if (!sameSecret(parts.secret, session.refreshSecret) && !sameSecret(parts.secret, session.previousRefreshSecret)) {
return null
}
await redis.del(sessionKey(parts.sid), `${CONTEST_PASSWORD_PREFIX}${parts.sid}`)
@@ -375,23 +347,20 @@ export async function revokeUserSessions(userId: number, reason: SessionRevokedR
*/
export type SessionResult =
| { user: AuthUser; sid: string; tokenVersion: number; reason?: undefined }
| { user: null; reason: "anonymous" | "expired" | "disabled" }
| { user: null; reason: 'anonymous' | 'expired' | 'disabled' }
async function authenticateToken(token: string | null): Promise<SessionResult> {
if (!token) return { user: null, reason: "anonymous" }
if (!token) return { user: null, reason: 'anonymous' }
const claims = await verifyAccessToken(token)
if (!claims) return { user: null, reason: "expired" }
if (!claims) return { user: null, reason: 'expired' }
// 会话还在不在、用户还在不在,两件事并行问。EXPIRE 顺手给会话续期:
// 不发 refresh 的请求也该算活动
const [alive, user] = await Promise.all([
redis.expire(sessionKey(claims.sid), config.sessionTtlSeconds),
selectUser(claims.uid),
])
const [alive, user] = await Promise.all([redis.expire(sessionKey(claims.sid), config.sessionTtlSeconds), selectUser(claims.uid)])
if (alive !== 1 || !user || user.tokenVersion !== claims.ver) {
return { user: null, reason: "expired" }
return { user: null, reason: 'expired' }
}
if (user.isDisabled) return { user: null, reason: "disabled" }
if (user.isDisabled) return { user: null, reason: 'disabled' }
// 在线状态就是搭在每个带鉴权的请求上记的,见 presence.ts
const online = redis.pipeline()
@@ -418,14 +387,12 @@ function bearerToken(header: string | null | undefined) {
/** 中间件用这个:要区分「没登录」「token 不能用」「已被禁用」 */
export function resolveSession(c: Context) {
return authenticateToken(bearerToken(c.req.header("authorization")))
return authenticateToken(bearerToken(c.req.header('authorization')))
}
/** WebSocket 握手:access token 借子协议带过来,见契约里 WS_AUTH_PROTOCOL 的注释 */
export async function authenticateWebSocket(request: Request) {
const protocols = (request.headers.get("sec-websocket-protocol") ?? "")
.split(",")
.map((value) => value.trim())
const protocols = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map((value) => value.trim())
if (protocols[0] !== WS_AUTH_PROTOCOL) return null
const result = await authenticateToken(protocols[1] || null)
return result.user ? result : null
@@ -451,7 +418,7 @@ export async function touchSession(sid: string, userId: number) {
/** 当前请求那张 access token 的载荷,验不过返回 null */
async function requestClaims(c: Context) {
const token = bearerToken(c.req.header("authorization"))
const token = bearerToken(c.req.header('authorization'))
return token ? verifyAccessToken(token) : null
}
@@ -463,11 +430,7 @@ export async function setContestPassword(c: Context, contestId: number, password
const claims = await requestClaims(c)
if (!claims) return false
const key = `${CONTEST_PASSWORD_PREFIX}${claims.sid}`
await redis
.pipeline()
.hset(key, String(contestId), password)
.expire(key, config.sessionTtlSeconds)
.exec()
await redis.pipeline().hset(key, String(contestId), password).expire(key, config.sessionTtlSeconds).exec()
return true
}