Skip to content

鉴权完整步骤(AI 实现) ​

标注:AI 实现

本文记录的后端鉴权、前端登录页与路由守卫代码均由 AI 实现,我暂时还没有学习这部分内容。文档只保证与当前仓库代码一致,用来先跑通、后补课。

1. 本次实现的范围 ​

层文件作用状态
配置server/src/env.ts读取 .env,导出 configAI 实现
数据库server/src/db/schema.tsuser、session 两张表AI 实现
迁移server/drizzle.config.ts、server/drizzle/启动时自动建表AI 实现
密码server/src/servers/auth.tsscrypt 哈希、校验、播种管理员AI 实现
会话server/src/servers/session.ts建会话、查会话、删会话AI 实现
中间件server/src/middleware/auth.tsCookie 读写、requireAuth、登录限流AI 实现
路由server/src/router/auth.tsstatus / login / logoutAI 实现
路由挂载server/src/router/index.ts/auth 公开,/system 需鉴权AI 实现
前端请求client/src/composables/common.tsaxios 实例 requestAI 实现
前端登录client/src/composables/login.tsfetchStatus / loginAI 实现
前端守卫client/src/router/index.tsbeforeEach 检查登录态AI 实现
前端页面client/src/views/Login.vue登录表单AI 实现
前端布局client/src/App.vue登录页隐藏侧栏AI 实现

整体方案:单管理员 + 服务端会话 + HttpOnly Cookie。密码只在数据库里存哈希,登录成功后服务端下发一个随机 token 放在 Cookie,前端不保存 token。

2. 一次登录的完整流程 ​

text
浏览器                          前端                          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 ​

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 里需要:

ini
PORT=3000
ADMIN_USERNAME=admin
ADMIN_PASSWORD=你的密码
SESSION_DAYS=7

其它模块一律 import { config } from '../env.js',不再各写各的 process.env。

3.2 建两张表 ​

ts
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/ 下执行一次:

bash
npx drizzle-kit generate

server/src/db/index.ts 在启动时执行迁移:

ts
export const runMigrations = () => {
  migrate(db, { migrationsFolder: path.join(import.meta.dirname, '..', '..', 'drizzle') })
}

注意:server/drizzle/ 目录必须存在,缺少 meta/_journal.json 时启动会直接报 ENOENT。

3.4 密码只存 scrypt 哈希 ​

ts
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 启动时播种管理员 ​

ts
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 和数据库 ​

ts
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 字符串,返回普通对象。

server/src/middleware/auth.ts 是所有 HTTP 细节的集中地。

写 Cookie:

ts
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,直接解析请求头:

ts
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
}

鉴权中间件:

ts
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。改成模块扩展:

ts
declare module 'express-serve-static-core' {
  interface Request {
    user?: { id: number; username: string }
  }
}

3.8 登录限流 ​

进程内 Map,同一 IP 连续失败 5 次锁 15 分钟:

ts
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:

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:

ts
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 挂载路由并保护系统接口 ​

ts
const router = Router()
router.use('/auth', authRouter)
router.use('/system', requireAuth, systemRouter)
export default router

server/src/index.ts 里挂到 /api:

ts
app.use(express.json())
app.use('/api', router)

因此实际地址是 /api/auth/* 和 /api/system/*,/api/system/* 全部经过 requireAuth。

启动顺序也很关键:

ts
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:

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:

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:

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= 记录的原地址,只接受以 / 开头的站内路径,否则回 /
  • 错误码转中文:
ts
const messages: Record<string, string> = {
  INVALID_CREDENTIALS: '用户名或密码错误',
  TOO_MANY_ATTEMPTS: '失败次数过多,请 15 分钟后再试',
  BAD_REQUEST: '请填写用户名和密码',
  NETWORK_ERROR: '无法连接服务器',
}

4.5 登录页不显示侧栏 ​

client/src/App.vue:

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) ​

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

预期:

  1. authenticated: false
  2. 登录返回 { user: { id: 1, username: 'admin' } }
  3. CPU 数据正常返回
  4. 登出返回 204,再查 status 变回 authenticated: false

5.2 浏览器流程 ​

  1. 启动后端和前端,打开 http://localhost:5173/
  2. 应自动跳到 /login?redirect=/,左侧侧栏不显示
  3. 输入 .env 里的管理员账号密码,登录成功后回到 /
  4. 面板六个接口正常显示数据
  5. 手动清掉浏览器 Cookie 或调用登出,再刷新应回到登录页

6. 错误码对照 ​

状态码error触发条件前端表现
400BAD_REQUEST用户名或密码不是字符串「请填写用户名和密码」
401INVALID_CREDENTIALS账号不存在或密码错误「用户名或密码错误」
401UNAUTHORIZED未带 Cookie 或会话无效/过期守卫跳登录页
429TOO_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'
TS6059drizzle.config.ts 不在 rootDir 内tsconfig.json 顶层加 include: ["src"]
控制台 ReferenceError: port is not definedconfig.port 替换不完整全部改成 config.port
前端所有接口 401未登录时直接访问面板路由守卫先跳登录页

8. 还没做的部分 ​

  • 侧栏没有退出登录按钮(POST /api/auth/logout 已可用,前端未接)
  • 登录行为没有写日志(日志工具还没接入)
  • 没有改密码接口,改密码只能直接改库或删掉 user 行重新播种
  • Cookie 的 secure 仍是 false,部署 HTTPS 后要改成 true
  • 限流是内存实现,多实例部署时会失效

以上内容均为 AI 实现,建议按本文顺序读一遍代码,再自己重写一遍,重点看 servers/ 为什么完全不碰 req / res,HTTP 细节为什么集中在 middleware/auth.ts。

基于 MIT 许可发布