完整实战:用 bcryptjs + jose 搭建 Node.js 登录系统
完整实战:用 bcryptjs + jose 搭建 Node.js 登录系统
前三篇分别讲了全局视角、密码哈希、JWT 签发与验签。
这一篇把它们拼起来,写一个完整可跑的项目。不再分散讲概念,直接给一个能注册、登录、带 token 访问受保护接口的最小系统。
目标是跑完这篇之后,你手上有一套结构清晰的代码,后面再加数据库、refresh token、角色权限都有地方接。
1. 项目结构
src/
index.ts # 入口,启动服务
config.ts # 配置:密钥、常量
store.ts # 用户存储(内存模拟)
auth-service.ts # 注册、登录、token 签发
auth-middleware.ts # JWT 鉴权中间件
routes/
auth-routes.ts # 注册、登录路由
user-routes.ts # 受保护的用户接口
不用 ORM,不用数据库,用内存数组模拟存储。
这样你只需要关注认证逻辑本身。
2. 依赖安装
npm init -y
npm install express bcryptjs jose
npm install -D typescript @types/express @types/bcryptjs tsx
tsconfig.json 最小配置:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
package.json 里加上启动脚本和模块类型:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts"
}
}
3. config.ts:集中管理密钥和常量
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error("环境变量 JWT_SECRET 未设置");
}
export const config = {
port: Number(process.env.PORT) || 3000,
jwtSecret: new TextEncoder().encode(jwtSecret),
jwtIssuer: "https://api.example.com",
jwtAudience: "my-app",
jwtExpiresIn: "15m",
bcryptRounds: 10,
} as const;
密钥从环境变量读,不写死在代码里。
启动时如果缺少 JWT_SECRET,直接报错退出,比运行到一半才发现好得多。
4. store.ts:内存用户存储
export type UserRecord = {
id: string;
email: string;
passwordHash: string;
role: string;
createdAt: Date;
};
const users: UserRecord[] = [];
let nextId = 1;
export function findUserByEmail(email: string) {
return users.find((u) => u.email === email) ?? null;
}
export function findUserById(id: string) {
return users.find((u) => u.id === id) ?? null;
}
export function createUser(
email: string,
passwordHash: string,
role: string = "user"
): UserRecord {
const user: UserRecord = {
id: String(nextId++),
email,
passwordHash,
role,
createdAt: new Date(),
};
users.push(user);
return user;
}
后面换成数据库时,只需要替换这个文件里的实现,其他层不用动。
5. auth-service.ts:核心业务逻辑
这里集中了注册、登录、token 签发三件事。
import bcrypt from "bcryptjs";
import { SignJWT } from "jose";
import { config } from "./config.js";
import { findUserByEmail, createUser } from "./store.js";
export async function register(email: string, password: string) {
const existing = findUserByEmail(email);
if (existing) {
throw new Error("EMAIL_ALREADY_EXISTS");
}
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
const user = createUser(email, passwordHash);
return { id: user.id, email: user.email, role: user.role };
}
export async function login(email: string, password: string) {
const user = findUserByEmail(email);
if (!user) {
throw new Error("INVALID_CREDENTIALS");
}
const matched = await bcrypt.compare(password, user.passwordHash);
if (!matched) {
throw new Error("INVALID_CREDENTIALS");
}
const accessToken = await signAccessToken(user.id, user.role);
return {
accessToken,
user: { id: user.id, email: user.email, role: user.role },
};
}
async function signAccessToken(userId: string, role: string) {
return await new SignJWT({ role })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(userId)
.setIssuer(config.jwtIssuer)
.setAudience(config.jwtAudience)
.setIssuedAt()
.setExpirationTime(config.jwtExpiresIn)
.setJti(crypto.randomUUID())
.sign(config.jwtSecret);
}
几个关键点:
register()里用bcrypt.hash()存哈希,绝不存明文login()里用bcrypt.compare()校验,不是重新 hash 再比字符串- 登录失败统一抛
INVALID_CREDENTIALS,不区分"用户不存在"和"密码错误" signAccessToken()是私有函数,只在 login 成功后调用
6. auth-middleware.ts:JWT 鉴权中间件
import type { Request, Response, NextFunction } from "express";
import { jwtVerify } from "jose";
import { config } from "./config.js";
export type AuthenticatedUser = {
id: string;
role: string;
};
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
try {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ message: "未提供认证信息" });
}
const token = header.slice("Bearer ".length).trim();
const { payload } = await jwtVerify(token, config.jwtSecret, {
issuer: config.jwtIssuer,
audience: config.jwtAudience,
});
req.user = {
id: String(payload.sub),
role: typeof payload.role === "string" ? payload.role : "user",
};
next();
} catch {
return res.status(401).json({ message: "token 无效或已过期" });
}
}
这个中间件做了三层检查:
- 请求头格式是否正确
- token 签名、过期、issuer、audience 是否都通过
- 通过后才把用户信息挂到
req.user
7. 加一个角色守卫
鉴权中间件解决的是"你是谁"。
角色守卫解决的是"你能不能进"。
import type { Request, Response, NextFunction } from "express";
export function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ message: "未登录" });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ message: "权限不足" });
}
next();
};
}
用法:
app.get("/admin/stats", authMiddleware, requireRole("admin"), handler);
先过认证,再过授权,顺序很重要。
8. routes/auth-routes.ts:注册和登录路由
import { Router } from "express";
import { register, login } from "../auth-service.js";
export const authRouter = Router();
authRouter.post("/register", async (req, res) => {
try {
const { email, password } = req.body as {
email?: string;
password?: string;
};
if (!email || !password) {
return res.status(400).json({ message: "邮箱和密码不能为空" });
}
if (password.length < 8) {
return res.status(400).json({ message: "密码长度不能少于 8 位" });
}
const user = await register(email, password);
return res.status(201).json({ message: "注册成功", user });
} catch (error) {
if (error instanceof Error && error.message === "EMAIL_ALREADY_EXISTS") {
return res.status(409).json({ message: "该邮箱已注册" });
}
console.error("register error:", error);
return res.status(500).json({ message: "服务器内部错误" });
}
});
authRouter.post("/login", async (req, res) => {
try {
const { email, password } = req.body as {
email?: string;
password?: string;
};
if (!email || !password) {
return res.status(400).json({ message: "邮箱和密码不能为空" });
}
const result = await login(email, password);
return res.status(200).json(result);
} catch (error) {
if (error instanceof Error && error.message === "INVALID_CREDENTIALS") {
return res.status(401).json({ message: "邮箱或密码错误" });
}
console.error("login error:", error);
return res.status(500).json({ message: "服务器内部错误" });
}
});
路由层只负责:参数校验、调用 service、处理错误、返回响应。
不做任何密码操作或 token 操作。
9. routes/user-routes.ts:受保护接口
import { Router } from "express";
import { authMiddleware } from "../auth-middleware.js";
import { requireRole } from "../auth-middleware.js"; // 如果放在同一文件
import { findUserById } from "../store.js";
export const userRouter = Router();
// 所有 user 路由都需要登录
userRouter.use(authMiddleware);
// 获取当前用户信息
userRouter.get("/me", (req, res) => {
const user = findUserById(req.user!.id);
if (!user) {
return res.status(404).json({ message: "用户不存在" });
}
return res.json({
id: user.id,
email: user.email,
role: user.role,
createdAt: user.createdAt,
});
});
// 仅管理员可访问
userRouter.get("/admin/dashboard", requireRole("admin"), (_req, res) => {
return res.json({ message: "欢迎进入管理后台" });
});
/me 是登录后的典型接口:拿到 token 里的用户 id,再去查完整信息。/admin/dashboard 展示了角色守卫的用法。
10. index.ts:把一切拼起来
import express from "express";
import { config } from "./config.js";
import { authRouter } from "./routes/auth-routes.js";
import { userRouter } from "./routes/user-routes.js";
const app = express();
app.use(express.json());
app.use("/auth", authRouter);
app.use("/user", userRouter);
app.listen(config.port, () => {
console.log(`server running at http://localhost:${config.port}`);
});
最终的接口列表:
| 方法 | 路径 | 说明 | 是否需要 token |
|---|---|---|---|
| POST | /auth/register | 注册 | 否 |
| POST | /auth/login | 登录 | 否 |
| GET | /user/me | 获取当前用户 | 是 |
| GET | /user/admin/dashboard | 管理后台 | 是 + admin 角色 |
11. 启动和测试
启动:
JWT_SECRET=a-very-long-random-string-at-least-32-chars npm run dev
注册:
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"MyPassword123!"}'
登录:
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"MyPassword123!"}'
登录成功后会返回 accessToken。用它访问受保护接口:
curl http://localhost:3000/user/me \
-H "Authorization: Bearer <这里替换成拿到的 token>"
不带 token 或 token 过期,会得到 401。
普通用户访问 /user/admin/dashboard,会得到 403。
12. 整个请求链路回顾
把一次完整的"注册 -> 登录 -> 访问接口"串起来:
注册
- 客户端提交邮箱和密码
- 路由层做参数校验
- service 层用
bcrypt.hash()生成密码哈希 - 哈希值存入用户表
- 返回注册成功
登录
- 客户端提交邮箱和密码
- service 层查用户,用
bcrypt.compare()校验密码 - 校验通过后,用
jose的SignJWT签发 access token - 返回 token 和用户基本信息
访问受保护接口
- 客户端在请求头带上
Authorization: Bearer <token> - 鉴权中间件用
jwtVerify()验签、验过期、验 issuer 和 audience - 通过后把用户信息挂到
req.user - 路由处理函数正常执行
- 如果有角色守卫,再检查
req.user.role
每一步的职责都很清楚,没有交叉。
13. 这套结构为什么值得保持
这个项目虽然小,但分层已经比较健康:
- config 管配置和密钥,集中且显式
- store 管数据存取,后面可以直接换成数据库
- service 管业务逻辑,密码和 token 操作都在这里
- middleware 管请求级别的认证和授权
- routes 只做参数校验和响应格式化
这种结构的好处是,后面不管加什么功能——接数据库、加 refresh token、加日志、加限流——都能找到明确的位置放进去,不会把认证逻辑散得到处都是。
14. 这一篇之后还缺什么
到这里,最小闭环已经跑通了。但离生产级还有几件事没做:
- Refresh Token:access token 过期后怎么续期,不能让用户反复登录
- 注销和 token 撤销:JWT 是无状态的,主动失效需要额外机制
- 密码重置:忘记密码的完整流程
- 限流和防暴力破解:密码哈希挡不住接口被持续撞
- HTTPS 和 token 传输安全:token 在网络层的保护
- 密钥轮换:密钥不能永远不变
下一篇可以专门讲 refresh token 和 token 生命周期管理,把"短 access token + 长 refresh token"这套常见模式讲清楚。
后记
2026年5月7日于上海,在claude opus 4.6辅助下完成。
更多推荐

所有评论(0)