鉴权完整步骤(AI 实现)
标注:AI 实现
本文记录的后端鉴权、前端登录页与路由守卫代码均由 AI 实现,我暂时还没有学习这部分内容。文档只保证与当前仓库代码一致,用来先跑通、后补课。
1. 本次实现的范围
| 层 | 文件 | 作用 | 状态 |
|---|---|---|---|
| 配置 | server/src/env.ts | 读取 .env,导出 config | AI 实现 |
| 数据库 | server/src/db/schema.ts | user、session 两张表 | AI 实现 |
| 迁移 | server/drizzle.config.ts、server/drizzle/ | 启动时自动建表 | AI 实现 |
| 密码 | server/src/servers/auth.ts | scrypt 哈希、校验、播种管理员 | AI 实现 |
| 会话 | server/src/servers/session.ts | 建会话、查会话、删会话 | AI 实现 |
| 中间件 | server/src/middleware/auth.ts | Cookie 读写、requireAuth、登录限流 | AI 实现 |
| 路由 | server/src/router/auth.ts | status / login / logout | AI 实现 |
| 路由挂载 | server/src/router/index.ts | /auth 公开,/system 需鉴权 | AI 实现 |
| 前端请求 | client/src/composables/common.ts | axios 实例 request | AI 实现 |
| 前端登录 | client/src/composables/login.ts | fetchStatus / login | AI 实现 |
| 前端守卫 | client/src/router/index.ts | beforeEach 检查登录态 | AI 实现 |
| 前端页面 | client/src/views/Login.vue | 登录表单 | AI 实现 |
| 前端布局 | client/src/App.vue | 登录页隐藏侧栏 | AI 实现 |
整体方案:单管理员 + 服务端会话 + HttpOnly Cookie。密码只在数据库里存哈希,登录成功后服务端下发一个随机 token 放在 Cookie,前端不保存 token。
2. 一次登录的完整流程
浏览器 前端 Express
│ 打开 / │ │
├──────────────────────────────>│ beforeEach │
│ ├── GET /api/auth/status ──────>│ 读 Cookie 里的 sid
│ │<── authenticated: false ──────┤ 查 session 表
│<── 跳到 /login?redirect=/ ────┤ │
│ 填用户名密码,点登录 │ │
├──────────────────────────────>├── POST /api/auth/login ───────>│ 限流 → 校验密码
│ │ │ 建会话 → Set-Cookie
│ │<── 200 { user } + Cookie ─────┤
├── 跳回 / ─────────────────────>├── GET /api/system/* ─────────>│ requireAuth 查会话
│<── 面板数据 ───────────────────┤<─────────────────────────────┤关键点:
- 原始 token 只出现在响应头
Set-Cookie和浏览器的 Cookie 里,数据库存的是它的 sha256 - Cookie 是
httpOnly,前端 JS 读不到,因此 axios 只需要withCredentials: true,不需要手动带 token - 服务端会话存在 SQLite,Node 重启后登录态不会丢
3. 后端实现步骤
3.1 先把配置收口到 env.ts
import { existsSync } from 'node:fs'
import path from 'node:path'
// src/env.ts → 上一级是 server/,所以这里指向 server/.env
const envPath = path.join(import.meta.dirname, '..', '.env')
if (existsSync(envPath)) process.loadEnvFile(envPath)
export const config = {
port: Number(process.env.PORT) || 3000,
adminUsername: process.env.ADMIN_USERNAME ?? '',
adminPassword: process.env.ADMIN_PASSWORD ?? '',
sessionDays: Number(process.env.SESSION_DAYS) || 7,
}server/.env 里需要:
PORT=3000
ADMIN_USERNAME=admin
ADMIN_PASSWORD=你的密码
SESSION_DAYS=7其它模块一律 import { config } from '../env.js',不再各写各的 process.env。
3.2 建两张表
export const user = sqliteTable('user', {
id: integer('id').primaryKey({ autoIncrement: true }),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const session = sqliteTable('session', {
id: text('id').primaryKey(), // token 的 sha256,原始 token 只在 Cookie 里
userId: integer('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
})- 单管理员:
user永远只有一行 session可以有多行(同一账号多次登录)- 时间统一用
{ mode: 'timestamp_ms' },读出来就是Date
3.3 生成迁移并让启动自动执行
改了 schema.ts 之后,在 server/ 下执行一次:
npx drizzle-kit generateserver/src/db/index.ts 在启动时执行迁移:
export const runMigrations = () => {
migrate(db, { migrationsFolder: path.join(import.meta.dirname, '..', '..', 'drizzle') })
}注意:server/drizzle/ 目录必须存在,缺少 meta/_journal.json 时启动会直接报 ENOENT。
3.4 密码只存 scrypt 哈希
const scrypt = (password: string, salt: Buffer, keylen: number, options: ScryptOptions) =>
new Promise<Buffer>((resolve, reject) => {
scryptCb(password, salt, keylen, options, (err, key) => {
if (err) reject(err)
else resolve(key)
})
})
const KEY_LEN = 64
const PARAMS = { N: 16384, r: 8, p: 1 }入库格式:scrypt$16384$8$1$<salt base64>$<hash base64>。
- 参数跟哈希一起存,以后调参不影响老密码
- 校验用
timingSafeEqual,不要写=== - 这里没有用
promisify(scrypt),因为它的类型只推导出 3 个参数,传options会报TS2554
3.5 启动时播种管理员
export const ensureAdmin = async () => {
const [existing] = await db.select().from(user).limit(1)
if (existing) return
const username = config.adminUsername
const password = config.adminPassword
if (!username || !password) {
console.warn('[WARN]:未配置 ADMIN_USERNAME / ADMIN_PASSWORD,跳过播种')
return
}
await db.insert(user).values({
username,
passwordHash: await hashPassword(password),
createdAt: new Date(),
})
}只会在库里没有用户时执行,重启不会覆盖已有密码。播种成功后可以删掉 .env 里的 ADMIN_PASSWORD。
3.6 会话只处理 token 和数据库
const hashToken = (raw: string) => createHash('sha256').update(raw).digest('hex')
export const createSession = async (userId: number) => {
const raw = randomBytes(32).toString('base64url')
const now = new Date()
await db.insert(session).values({
id: hashToken(raw),
userId,
createdAt: now,
expiresAt: new Date(now.getTime() + config.sessionDays * 24 * 60 * 60 * 1000),
})
return raw // 只返回这一次,之后再也不出现
}
export const findSession = async (raw: string) => {
const [row] = await db
.select({ id: user.id, username: user.username, expiresAt: session.expiresAt })
.from(session)
.innerJoin(user, eq(session.userId, user.id))
.where(eq(session.id, hashToken(raw)))
.limit(1)
if (!row) return null
if (row.expiresAt.getTime() <= Date.now()) {
await db.delete(session).where(eq(session.id, hashToken(raw))) // 过期顺手清掉
return null
}
return { id: row.id, username: row.username }
}
export const destroySession = async (raw: string) => {
await db.delete(session).where(eq(session.id, hashToken(raw)))
}这一层不碰 req / res,只接收 token 字符串,返回普通对象。
3.7 Cookie 与鉴权中间件
server/src/middleware/auth.ts 是所有 HTTP 细节的集中地。
写 Cookie:
res.cookie('sid', token, {
httpOnly: true, // JS 读不到,防 XSS 偷 token
sameSite: 'lax', // 防 CSRF,同时不影响普通跳转
path: '/',
maxAge: config.sessionDays * 24 * 60 * 60 * 1000,
secure: false, // 上 HTTPS 后改成 true
})读 Cookie 没有引入 cookie-parser,直接解析请求头:
export const getSessionCookie = (cookieHeader: string | undefined) => {
if (!cookieHeader) return null
for (const part of cookieHeader.split(';')) {
const [key, ...rest] = part.trim().split('=')
if (key === COOKIE_NAME) return decodeURIComponent(rest.join('='))
}
return null
}鉴权中间件:
export const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
const token = getSessionCookie(req.headers.cookie)
if (!token) {
res.status(401).json({ error: 'UNAUTHORIZED' })
return
}
const currentUser = await findSession(token)
if (!currentUser) {
res.status(401).json({ error: 'UNAUTHORIZED' })
return
}
req.user = currentUser
next()
}给 req.user 补类型时不要用 declare global { namespace Express },会触发 ESLint 的 no-namespace。改成模块扩展:
declare module 'express-serve-static-core' {
interface Request {
user?: { id: number; username: string }
}
}3.8 登录限流
进程内 Map,同一 IP 连续失败 5 次锁 15 分钟:
const failMap = new Map<string, { count: number; lockedUntil: number }>()
const MAX_FAILS = 5
const LOCK_TIME = 15 * 60 * 1000- key 用
req.ip ?? 'unknown' - 只存在内存,重启清零,单机够用
- 锁定期间登录直接返回 429
- 登录成功后
clearLoginFail(ip)清零
3.9 三个登录接口
server/src/router/auth.ts:
// GET /api/auth/status —— 前端守卫用它判断登录态
router.get('/status', async (req, res) => {
const admin = await findAdmin()
const token = getSessionCookie(req.headers.cookie)
const currentUser = token ? await findSession(token) : null
const initialized = admin !== null
const authenticated = currentUser !== null
res.json({ initialized, authenticated, user: currentUser })
})
// POST /api/auth/login
router.post('/login', async (req, res) => {
const ip = req.ip ?? 'unknown'
const remain = getLockRemain(ip)
if (remain > 0) {
res.status(429).json({ error: 'TOO_MANY_ATTEMPTS' })
return
}
const username = req.body.username
const password = req.body.password
if (typeof username !== 'string' || typeof password !== 'string') {
res.status(400).json({ error: 'BAD_REQUEST' })
return
}
const currentUser = await checkCredentials(username, password)
if (!currentUser) {
markLoginFail(ip)
res.status(401).json({ error: 'INVALID_CREDENTIALS' })
return
}
clearLoginFail(ip)
const token = await createSession(currentUser.id)
setSessionCookie(res, token)
res.json({ user: currentUser })
})
// POST /api/auth/logout
router.post('/logout', async (req, res) => {
const token = getSessionCookie(req.headers.cookie)
if (token) await destroySession(token)
clearSessionCookie(res)
res.status(204).end()
})checkCredentials 在 servers/auth.ts,只做「查库 + 校验哈希 + 返回用户」,失败返回 null:
export const checkCredentials = async (username: string, password: string) => {
const [admin] = await db.select().from(user).where(eq(user.username, username)).limit(1)
if (!admin) return null
const passed = await verifyPassword(password, admin.passwordHash)
if (!passed) return null
const currentUser = { id: admin.id, username: admin.username }
return currentUser
}登录失败统一返回 INVALID_CREDENTIALS,不区分「用户不存在」和「密码错误」,避免被枚举账号。
3.10 挂载路由并保护系统接口
const router = Router()
router.use('/auth', authRouter)
router.use('/system', requireAuth, systemRouter)
export default routerserver/src/index.ts 里挂到 /api:
app.use(express.json())
app.use('/api', router)因此实际地址是 /api/auth/* 和 /api/system/*,/api/system/* 全部经过 requireAuth。
启动顺序也很关键:
import './env.js' // 副作用导入必须放在最前,保证 .env 先加载
import { config } from './env.js'
runMigrations() // 先建表
await ensureAdmin() // 再播种管理员
app.listen(config.port, () => { ... }) // 最后监听4. 前端实现步骤
4.1 axios 实例
client/src/composables/common.ts:
export const request = axios.create({
baseURL: '/api',
withCredentials: true, // 让浏览器自动带 Cookie
})
request.interceptors.response.use(
(res) => res,
(err) => {
if (!axios.isAxiosError(err)) return Promise.reject(err)
const body = err.response?.data
if (body && typeof body === 'object' && 'error' in body) {
return Promise.reject(new Error(String(body.error)))
}
if (err.response) {
return Promise.reject(new Error(`HTTP ${err.response.status}`))
}
return Promise.reject(new Error('NETWORK_ERROR'))
},
)统一收口的好处:
- 调用方只写
/auth/status,不用每次拼/api - 后端
{ error: 'INVALID_CREDENTIALS' }直接变成Error.message,页面按错误码提示 - 断网时统一报
NETWORK_ERROR
开发环境下 Vite 会把 /api 代理到 localhost:3000,所以前后端同源,Cookie 正常工作。
4.2 登录请求封装
client/src/composables/login.ts:
export const fetchStatus = async () => {
const res = await request.get<StatusData>('/auth/status')
return res.data
}
export const login = async (username: string, password: string) => {
const res = await request.post<{ user: User }>('/auth/login', { username, password })
return res.data.user
}StatusData 是 { initialized, authenticated, user },user 未登录时为 null。
4.3 路由守卫
client/src/router/index.ts:
router.beforeEach(async (to) => {
const status = await fetchStatus().catch(() => null)
if (!status?.authenticated) {
if (to.name === 'login') return true
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login') return { name: 'panel' }
return true
})行为:
| 当前状态 | 访问 | 结果 |
|---|---|---|
| 未登录 | /、/files 等 | 跳 /login?redirect=原地址 |
| 未登录 | /login | 放行 |
| 已登录 | /login | 回 / |
| 后端没启动 | 任意 | 按未登录处理,停在登录页 |
因为守卫每次跳转都查一次 /api/auth/status,会话过期后下一次点导航就会重新回登录页。
4.4 登录页
client/src/views/Login.vue:
- 用户名 + 密码表单,回车或点「登录」都会走
submit - 提交期间禁用输入框和按钮,避免重复点
- 成功后跳到
?redirect=记录的原地址,只接受以/开头的站内路径,否则回/ - 错误码转中文:
const messages: Record<string, string> = {
INVALID_CREDENTIALS: '用户名或密码错误',
TOO_MANY_ATTEMPTS: '失败次数过多,请 15 分钟后再试',
BAD_REQUEST: '请填写用户名和密码',
NETWORK_ERROR: '无法连接服务器',
}4.5 登录页不显示侧栏
client/src/App.vue:
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppMain from '@/components/AppMain.vue'
import AppSidebar from '@/components/AppSidebar.vue'
const route = useRoute()
const isLogin = computed(() => route.name === 'login')
</script>
<template>
<div class="app-layout">
<AppSidebar v-if="!isLogin" />
<AppMain />
</div>
</template>5. 完整验证步骤
5.1 后端接口(PowerShell)
# 1. 未登录状态
Invoke-RestMethod http://localhost:3000/api/auth/status
# 2. 登录并保存 Cookie
$s = New-Object Microsoft.PowerShell.Commands.WebRequestSession
Invoke-RestMethod -Uri http://localhost:3000/api/auth/login -Method Post `
-ContentType 'application/json' -Body '{"username":"admin","password":"你的密码"}' -WebSession $s
# 3. 带 Cookie 访问受保护接口
Invoke-RestMethod -Uri http://localhost:3000/api/system/cpu -WebSession $s
# 4. 登出
Invoke-RestMethod -Uri http://localhost:3000/api/auth/logout -Method Post -WebSession $s预期:
authenticated: false- 登录返回
{ user: { id: 1, username: 'admin' } } - CPU 数据正常返回
- 登出返回 204,再查 status 变回
authenticated: false
5.2 浏览器流程
- 启动后端和前端,打开
http://localhost:5173/ - 应自动跳到
/login?redirect=/,左侧侧栏不显示 - 输入
.env里的管理员账号密码,登录成功后回到/ - 面板六个接口正常显示数据
- 手动清掉浏览器 Cookie 或调用登出,再刷新应回到登录页
6. 错误码对照
| 状态码 | error | 触发条件 | 前端表现 |
|---|---|---|---|
| 400 | BAD_REQUEST | 用户名或密码不是字符串 | 「请填写用户名和密码」 |
| 401 | INVALID_CREDENTIALS | 账号不存在或密码错误 | 「用户名或密码错误」 |
| 401 | UNAUTHORIZED | 未带 Cookie 或会话无效/过期 | 守卫跳登录页 |
| 429 | TOO_MANY_ATTEMPTS | 同一 IP 15 分钟内失败 5 次 | 「失败次数过多,请 15 分钟后再试」 |
7. 实现时踩过的坑
| 问题 | 原因 | 处理 |
|---|---|---|
启动时报 ENOENT ... meta/_journal.json | 没生成迁移 | 先 npx drizzle-kit generate |
TS2554 出现在 scrypt 调用处 | promisify(scrypt) 只推导 3 个参数 | 手写 Promise 包装 |
ESLint no-namespace | 用了 declare global { namespace Express } | 改 declare module 'express-serve-static-core' |
TS6059 | drizzle.config.ts 不在 rootDir 内 | tsconfig.json 顶层加 include: ["src"] |
控制台 ReferenceError: port is not defined | config.port 替换不完整 | 全部改成 config.port |
| 前端所有接口 401 | 未登录时直接访问面板 | 路由守卫先跳登录页 |
8. 还没做的部分
- 侧栏没有退出登录按钮(
POST /api/auth/logout已可用,前端未接) - 登录行为没有写日志(日志工具还没接入)
- 没有改密码接口,改密码只能直接改库或删掉
user行重新播种 - Cookie 的
secure仍是false,部署 HTTPS 后要改成true - 限流是内存实现,多实例部署时会失效
以上内容均为 AI 实现,建议按本文顺序读一遍代码,再自己重写一遍,重点看
servers/为什么完全不碰req/res,HTTP 细节为什么集中在middleware/auth.ts。