Files
OJ2/apps/api/src/routes/auth.ts
T
xuyue d25b5dbe6d
Deploy / deploy (push) Canceled after 0s
update
2026-09-27 19:36:28 +08:00

104 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { loginRequestSchema } from "@oj2/contract"
import { eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { createSession, destroySession } from "../auth/session"
import { publishSessionRevoked } from "../events"
import { hashPassword, verifyPassword } from "../auth/password"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { getUserProfileById } from "../services/profile"
import {
clientIp,
countAttempt,
lockoutRemaining,
resetAttempts,
type AttemptRule,
} from "../services/throttling"
export const authRoutes = new Hono<AppEnv>()
/**
* 登录防爆破,两道都只数失败:
*
* - **按用户名**:盯着一个账号猜。学生的初始密码弱、`/classes/:name/usernames` 又能
* 公开列出整班用户名,这是最现实的攻击。15 分钟 10 次,登录成功清零。
* 代价是别人能故意输错把某个学生锁 15 分钟 —— 比被猜中密码好得多。
* - **按 IP**:换着用户名猜(每个号只试几次常见密码,绕开上一道)。机房一个班共用一个
* 出口 IP,所以放得很宽,而且成功**不清零**,否则攻击者拿自己的号登一次就能重置。
*/
const LOGIN_PER_USERNAME: AttemptRule = { limit: 10, windowSeconds: 15 * 60 }
const LOGIN_PER_IP: AttemptRule = { limit: 100, windowSeconds: 15 * 60 }
authRoutes.post("/auth/login", async (c) => {
const parsed = await parseBody(c, loginRequestSchema, "Username and password are required")
if (!parsed.success) return parsed.response
const usernameKey = `login:user:${parsed.data.username.toLowerCase()}`
const ipKey = `login:ip:${clientIp(c)}`
const wait =
(await lockoutRemaining(usernameKey, LOGIN_PER_USERNAME)) ??
(await lockoutRemaining(ipKey, LOGIN_PER_IP))
if (wait !== null) {
return failure(
c,
429,
"too-many-login-attempts",
`Too many failed attempts, please wait ${wait} seconds`,
)
}
const loginFailed = async () => {
await Promise.all([
countAttempt(usernameKey, LOGIN_PER_USERNAME),
countAttempt(ipKey, LOGIN_PER_IP),
])
return failure(c, 401, "invalid-credentials", "Invalid username or password")
}
const [user] = await db
.select()
.from(schema.user)
.where(sql`lower(${schema.user.username}) = lower(${parsed.data.username})`)
.limit(1)
if (!user) return loginFailed()
if (user.isDisabled) {
return failure(c, 403, "account-disabled", "Your account has been disabled")
}
const password = await verifyPassword(parsed.data.password, user.password)
if (!password.valid) return loginFailed()
await resetAttempts(usernameKey)
const now = new Date().toISOString()
const update: { lastLogin: string; password?: string } = { lastLogin: now }
// 存量 pbkdf2 顺手升级成 argon2。生产库 1710 个账号都是 Django 写的 pbkdf2,
// 靠这里随登录逐个迁移;没登录过的照旧由 verifyPassword 的 pbkdf2 分支兜着。
if (password.needsUpgrade) {
update.password = await hashPassword(parsed.data.password)
}
await db.update(schema.user).set(update).where(eq(schema.user.id, user.id))
await createSession(c, user.id, user.lastLogin)
return success(c, { ok: true })
})
authRoutes.delete("/auth/session", async (c) => {
const token = await destroySession(c)
// 同一个浏览器的其他标签页还挂着 WebSocket,页面上仍显示着登录态。推一条让它们
// 立刻清掉,不用等最多 60 秒的会话巡检。
// 按 token 而不是按用户:这个人在别的设备上的登录是另一张会话,不该被牵连。
if (token) await publishSessionRevoked({ token }, "session-ended")
return success(c, null)
})
authRoutes.get("/me", optionalAuth, async (c) => {
const authUser = c.get("user")
if (!authUser) return success(c, null)
const data = await getUserProfileById(authUser.id, true)
if (!data) return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, data)
})