05 内存数据与登录鉴权:先让四种账号能进入系统
05 内存数据与登录鉴权:先让四种账号能进入系统
本章你会做出什么
你会在后端创建四个演示账号和默认分类,完成登录与获取当前用户信息接口。登录后返回 JWT Token;受保护接口只有携带有效 Token 才能访问。
先理解三个概念
1. 为什么先使用内存数据
内存数据就是程序启动时创建的数组。它关闭后会重置,但没有安装数据库的阻碍,非常适合先跑通登录、权限和工单流程。第 13 章才将相同存储能力替换为 MySQL。
2. 密码哈希与 JWT
后端不能保存明文密码。bcryptjs 会把 123456 转成无法直接还原的哈希,登录时比较哈希。JWT 是后端签发的短期身份凭证,本练习 Token 有效期为 8 小时。
3. 认证与授权
- 认证:确认你是谁,例如 Token 是否有效。
- 授权:确认你能做什么,例如是否有
ticket:assign。
登录通过不代表可以管理用户,后续接口会同时使用两步判断。
本章最终目录变化
server/src/
auth.ts
store/
memory-store.ts
store.ts
app.ts # 增加登录和 profile 接口
一步一步操作
第 1 步:安装认证与参数校验依赖
npm install -w server bcryptjs jsonwebtoken zod
npm install -D -w server @types/jsonwebtoken
第 2 步:定义这一阶段需要的存储接口
存储接口让 API 不关心数据来自数组还是 MySQL。本章只需要用户、角色和分类;工单方法会在第 9 章增加。
第 3 步:生成内存演示数据
启动应用时调用 initialize()。它会统一将 123456 哈希后写给四个账号。
第 4 步:编写认证中间件
authMiddleware 从 Authorization: Bearer <token> 读取身份;requirePermission 检查当前用户的权限码。
第 5 步:在 app.ts 添加登录路由
本章开始,createApp 接收 store;启动前先初始化 store。
关键文件完整代码
server/src/store/store.ts(本章版本)
import type { Category, Role, UserRecord } from '../types.js'
export interface TicketStore {
initialize(): Promise<void>
findUserById(id: number): Promise<UserRecord | undefined>
findUserByUsername(username: string): Promise<UserRecord | undefined>
listRoles(): Promise<Role[]>
listCategories(includeDisabled: boolean): Promise<Category[]>
}
server/src/store/memory-store.ts(本章版本)
import { hash } from 'bcryptjs'
import { DEFAULT_ROLES } from '../constants.js'
import type { Category, Role, UserRecord } from '../types.js'
import type { TicketStore } from './store.js'
export class MemoryStore implements TicketStore {
private users: UserRecord[] = []
private roles: Role[] = structuredClone(DEFAULT_ROLES)
private categories: Category[] = []
async initialize() {
if (this.users.length) return
const passwordHash = await hash('123456', 8)
const now = new Date().toISOString()
const records = [
['user', '张三', 'user@demo.com', 1],
['agent', '王工', 'agent@demo.com', 2],
['supervisor', '李主管', 'supervisor@demo.com', 3],
['admin', '系统管理员', 'admin@demo.com', 4]
] as const
this.users = records.map(([username, name, email, roleId], index) => ({
id: index + 1,
username,
name,
email,
roleId,
passwordHash,
status: 1,
createdAt: now,
updatedAt: now
}))
this.categories = ['账号权限', '网络问题', '设备故障', '软件安装', '其他问题'].map((name, index) => ({
id: index + 1,
name,
enabled: true,
createdAt: now
}))
}
async findUserById(id: number) {
return this.users.find((user) => user.id === id)
}
async findUserByUsername(username: string) {
return this.users.find((user) => user.username === username)
}
async listRoles() {
return structuredClone(this.roles)
}
async listCategories(includeDisabled: boolean) {
return this.categories.filter((category) => includeDisabled || category.enabled)
}
}
server/src/auth.ts
import type { NextFunction, Request, Response } from 'express'
import jwt from 'jsonwebtoken'
import { AppError } from './errors.js'
import type { TicketStore } from './store/store.js'
import type { AuthUser, UserRecord } from './types.js'
declare global {
namespace Express {
interface Request {
currentUser?: AuthUser
}
}
}
export const buildAuthUser = async (store: TicketStore, user: UserRecord): Promise<AuthUser> => {
const role = (await store.listRoles()).find((item) => item.id === user.roleId)
if (!role) throw new AppError(403, '账号未配置有效角色')
return {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
status: user.status,
roleId: role.id,
roleCode: role.code,
roleName: role.name,
permissionCodes: role.permissionCodes
}
}
export const signToken = (userId: number, secret: string) => jwt.sign({ sub: userId }, secret, { expiresIn: '8h' })
export const authMiddleware =
(store: TicketStore, secret: string) => async (request: Request, _response: Response, next: NextFunction) => {
try {
const token = request.header('authorization')?.replace(/^Bearer\s+/i, '')
if (!token) throw new AppError(401, '请先登录')
const payload = jwt.verify(token, secret) as { sub: string | number }
const user = await store.findUserById(Number(payload.sub))
if (!user || !user.status) throw new AppError(401, '账号不可用或登录已失效')
request.currentUser = await buildAuthUser(store, user)
next()
} catch (error) {
next(error instanceof AppError ? error : new AppError(401, '登录已失效,请重新登录'))
}
}
export const requirePermission =
(...permissionCodes: string[]) =>
(request: Request, _response: Response, next: NextFunction) => {
const owned = request.currentUser?.permissionCodes ?? []
if (!permissionCodes.some((code) => owned.includes(code))) {
next(new AppError(403, '没有执行此操作的权限'))
return
}
next()
}
server/src/app.ts(替换为本章完整版本)
import { compare } from 'bcryptjs'
import cors from 'cors'
import express, { type NextFunction, type Request, type Response } from 'express'
import { z } from 'zod'
import { authMiddleware, buildAuthUser, signToken } from './auth.js'
import type { AppConfig } from './config.js'
import { AppError } from './errors.js'
import type { TicketStore } from './store/store.js'
import { requestId } from './utils.js'
const success = <T>(response: Response, data: T, message = 'ok') =>
response.json({ code: 0, message, data, requestId: requestId() })
export const createApp = (store: TicketStore, config: AppConfig) => {
const app = express()
const authenticated = authMiddleware(store, config.jwtSecret)
app.use(cors())
app.use(express.json())
app.get('/api/v1/health', (_request, response) => {
success(response, { status: 'up', database: config.dbDriver })
})
app.post('/api/v1/auth/login', async (request, response) => {
const input = z.object({ username: z.string().min(1), password: z.string().min(1) }).parse(request.body)
const user = await store.findUserByUsername(input.username)
if (!user || !user.status || !(await compare(input.password, user.passwordHash))) {
throw new AppError(401, '账号或密码错误')
}
success(response, {
accessToken: signToken(user.id, config.jwtSecret),
user: await buildAuthUser(store, user)
})
})
app.get('/api/v1/auth/profile', authenticated, (request, response) => {
success(response, request.currentUser)
})
app.get('/api/v1/categories', authenticated, async (_request, response) => {
success(response, await store.listCategories(false))
})
app.use((error: unknown, _request: Request, response: Response, _next: NextFunction) => {
if (error instanceof z.ZodError) {
response.status(400).json({ code: 400, message: '请求参数错误', data: null, requestId: requestId() })
return
}
const appError = error instanceof AppError ? error : new AppError(500, '服务暂时不可用')
response.status(appError.status).json({
code: appError.code,
message: appError.message,
data: null,
requestId: requestId()
})
})
return app
}
server/src/index.ts(替换为本章完整版本)
import { createApp } from './app.js'
import { config } from './config.js'
import { MemoryStore } from './store/memory-store.js'
const store = new MemoryStore()
await store.initialize()
createApp(store, config).listen(config.port, () => {
console.log(`ServiceDesk API running at http://localhost:${config.port}/api/v1`)
})
启动并验证
启动服务:
npm run dev
在另一个 PowerShell 登录并保存响应:
$login = Invoke-RestMethod -Method Post -Uri http://localhost:3001/api/v1/auth/login -ContentType "application/json" -Body '{"username":"user","password":"123456"}'
$login.data.user
$token = $login.data.accessToken
Invoke-RestMethod -Headers @{ Authorization = "Bearer $token" } -Uri http://localhost:3001/api/v1/auth/profile
你应该看到 user 的角色为 USER,权限中包含 ticket:view_own。
再测试错误密码:
Invoke-RestMethod -Method Post -Uri http://localhost:3001/api/v1/auth/login -ContentType "application/json" -Body '{"username":"user","password":"wrong"}'
它应返回 401 错误。
常见报错与原因
| 报错 | 原因 | 修正 |
|---|---|---|
Cannot find module bcryptjs/jsonwebtoken/zod |
依赖未安装 | 重跑本章安装命令 |
请求 profile 返回 请先登录 |
未将 Token 放进请求头 | 对照 PowerShell 的 Authorization 行 |
| 修改默认权限后没有变化 | 服务未重启或当前 Token 的用户信息尚未重新请求 | 重启服务并重新请求 profile |
本章完成清单
- 四个账号均以密码
123456可登录。 - 错误密码不能登录。
- 没有 Token 不能访问 profile。
- 我能解释密码哈希、Token、认证和授权的区别。
面试时这一章能怎么讲
开发演示阶段我用实现相同存储接口的内存 Store 初始化四种账号,避免数据库阻塞主流程;登录使用 bcrypt 校验哈希,JWT 由认证中间件解析,并将权限码注入当前请求供接口授权使用。