完整实战:用 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 无效或已过期" });
  }
}

这个中间件做了三层检查:

  1. 请求头格式是否正确
  2. token 签名、过期、issuer、audience 是否都通过
  3. 通过后才把用户信息挂到 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. 整个请求链路回顾

把一次完整的"注册 -> 登录 -> 访问接口"串起来:

注册

  1. 客户端提交邮箱和密码
  2. 路由层做参数校验
  3. service 层用 bcrypt.hash() 生成密码哈希
  4. 哈希值存入用户表
  5. 返回注册成功

登录

  1. 客户端提交邮箱和密码
  2. service 层查用户,用 bcrypt.compare() 校验密码
  3. 校验通过后,用 joseSignJWT 签发 access token
  4. 返回 token 和用户基本信息

访问受保护接口

  1. 客户端在请求头带上 Authorization: Bearer <token>
  2. 鉴权中间件用 jwtVerify() 验签、验过期、验 issuer 和 audience
  3. 通过后把用户信息挂到 req.user
  4. 路由处理函数正常执行
  5. 如果有角色守卫,再检查 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辅助下完成。

更多推荐