文章

2025年Node.js身份验证:JWT、Session与OAuth实战指南

一份面向Node.js SaaS开发者的身份验证方案选择与实现指南,对比无状态JWT、服务端Session和OAuth 2.0流程,附带真实代码示例。

身份验证是SaaS产品中最具深远影响的基础设施决策。选错了,就意味着账户被盗、合规失败,以及凌晨两点的紧急补丁。本指南将抛开理论,直接展示你应该实际构建的内容。

三种方案对比

SessionJWTOAuth / OIDC
状态服务端无状态委托
存储Redis / 数据库客户端(Cookie或localStorage)提供商处理
撤销即时困难(需要黑名单)取决于提供商
最佳场景Web应用API消费者、微服务社交登录、单点登录
复杂度中高

服务端Session

Session将随机Session ID存储在Cookie中。服务端将该ID映射到存储(Redis、PostgreSQL)中的用户数据。无状态JWT并非天生更好——Session更简单、撤销更快,且暴露给客户端的Session ID在没有服务端存储的情况下毫无意义。

使用express-session + Redis实现

pnpm add express-session connect-redis ioredis
pnpm add -D @types/express-session
import session from "express-session";
import RedisStore from "connect-redis";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET!,  // 至少32个随机字节
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,     // JS无法访问——防止XSS窃取
    secure: true,       // 仅HTTPS
    sameSite: "lax",    // CSRF防护
    maxAge: 7 * 24 * 60 * 60 * 1000  // 7天
  }
}));

// 登录端点
app.post("/auth/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: "凭据无效" });
  }

  req.session.userId = user.id;  // TypeScript:扩展session类型
  req.session.save();

  res.json({ ok: true });
});

// 身份验证中间件
const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: "未认证" });
  }
  req.user = await db.users.findById(req.session.userId);
  next();
};

// 登出
app.post("/auth/logout", (req, res) => {
  req.session.destroy(() => {
    res.clearCookie("connect.sid");
    res.json({ ok: true });
  });
});
// 扩展express-session类型
declare module "express-session" {
  interface SessionData {
    userId: number;
  }
}

撤销是即时的——从Redis中删除Session,用户立即在所有地方登出。

JWT身份验证

JWT是客户端持有的Base64编码、加密签名的令牌。服务端无需访问数据库即可验证签名。在API身份验证场景中使用JWT,其中客户端不是浏览器(移动应用、CLI工具、第三方集成)。

安全JWT设置

pnpm add jsonwebtoken
pnpm add -D @types/jsonwebtoken
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_EXPIRY = "15m";   // 短有效期——无法撤销
const REFRESH_TOKEN_EXPIRY = "30d";  // 较长有效期——存储在数据库中以便撤销

interface TokenPayload {
  sub: number;  // 用户ID
  email: string;
}

export function signAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: ACCESS_TOKEN_EXPIRY });
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload;
}

// 登录端点
app.post("/auth/login", async (req, res) => {
  const user = await authenticateUser(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "凭据无效" });

  const accessToken = signAccessToken({ sub: user.id, email: user.email });
  const refreshToken = crypto.randomBytes(48).toString("hex");

  // 在数据库中存储刷新令牌的哈希值
  await db.refreshTokens.create({
    userId: user.id,
    tokenHash: await bcrypt.hash(refreshToken, 10),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
  });

  // 访问令牌在内存/请求头中;刷新令牌在httpOnly Cookie中
  res
    .cookie("refresh_token", refreshToken, { httpOnly: true, secure: true, sameSite: "strict" })
    .json({ accessToken });
});

// 身份验证中间件
const requireAuth = (req: Request, res: Response, next: NextFunction) => {
  const auth = req.headers.authorization;
  if (!auth?.startsWith("Bearer ")) return res.status(401).json({ error: "无令牌" });

  try {
    req.user = verifyAccessToken(auth.slice(7));
    next();
  } catch {
    res.status(401).json({ error: "令牌无效或已过期" });
  }
};

关键:切勿将JWT存储在localStorage中。 XSS攻击可以窃取它们。将访问令牌存储在内存中(JS变量),将刷新令牌存储在httpOnly Cookie中。

OAuth 2.0 + OpenID Connect

使用OAuth进行社交登录(Google、GitHub、Apple)和企业单点登录(Okta、Azure AD)。用户在提供商处进行身份验证;你获得一个经过验证的身份。

使用Passport.js实现Google OAuth

pnpm add passport passport-google-oauth20 express-session
pnpm add -D @types/passport @types/passport-google-oauth20
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  callbackURL: `${process.env.APP_URL}/auth/google/callback`
}, async (accessToken, refreshToken, profile, done) => {
  // 根据Google个人资料查找或创建用户
  const email = profile.emails?.[0]?.value;
  if (!email) return done(new Error("Google个人资料中没有邮箱"));

  let user = await db.users.findByEmail(email);
  if (!user) {
    user = await db.users.create({
      email,
      name: profile.displayName,
      googleId: profile.id,
      emailVerified: true  // Google已验证邮箱
    });
  }

  done(null, user);
}));

passport.serializeUser((user: any, done) => done(null, user.id));
passport.deserializeUser(async (id: number, done) => {
  const user = await db.users.findById(id);
  done(null, user);
});

app.use(passport.initialize());
app.use(passport.session());

// 发起OAuth流程
app.get("/auth/google",
  passport.authenticate("google", { scope: ["email", "profile"] })
);

// 处理来自Google的回调
app.get("/auth/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => res.redirect("/dashboard")
);

托管身份验证提供商

对于大多数SaaS应用,考虑使用ClerkAuth0Lucia,而不是自己实现OAuth:

// Clerk——即插即用的身份验证,带有预构建UI
import { clerkMiddleware, getAuth } from "@clerk/express";

app.use(clerkMiddleware());

app.get("/protected", (req, res) => {
  const { userId } = getAuth(req);
  if (!userId) return res.status(401).json({ error: "未认证" });
  res.json({ userId });
});

托管提供商处理OAuth流程、邮箱验证、多因素认证、会话管理和管理面板。成本(Clerk:大规模使用时每月25美元以上)通常值得节省的工程时间。

密码安全

如果你实现基于密码的身份验证,切勿使用SHA-256或MD5进行密码哈希:

import bcrypt from "bcryptjs";  // 纯JS,随处可用

const BCRYPT_ROUNDS = 12;  // 在现代硬件上约300ms——故意设计得慢

// 注册
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);

// 登录
const isValid = await bcrypt.compare(inputPassword, user.passwordHash);

对于新项目,优先选择Argon2而非bcrypt:

pnpm add argon2
import argon2 from "argon2";

const hash = await argon2.hash(password);
const isValid = await argon2.verify(hash, password);

决策指南

你在构建带有浏览器前端的Web应用吗?
  → 是:使用Session(express-session + Redis)

你需要社交登录(Google、GitHub)吗?
  → 是:通过Passport.js在Session之上添加OAuth
          或使用托管提供商(Clerk、Auth0)

你需要对外部API客户端(移动端、CLI)进行身份验证吗?
  → 是:添加基于JWT的API令牌(短有效期 + 刷新令牌)

你在构建无法共享Session存储的微服务吗?
  → 是:服务间使用JWT,面向用户的API使用Session

你需要企业单点登录(SAML、Okta)吗?
  → 使用WorkOS、Clerk或Auth0——自己实现SAML非常痛苦

安全检查清单

  • 密码使用bcrypt(轮数≥12)或Argon2进行哈希
  • Session Cookie设置为httpOnlysecuresameSite=lax
  • JWT存储在内存中,而非localStorage
  • 刷新令牌在服务端存储并设置过期时间
  • 登录端点限流(每15分钟5次尝试)
  • 多次失败后锁定账户
  • 账户激活前进行邮箱验证
  • 生产环境强制HTTPS(负载均衡器后设置app.set("trust proxy", 1)
  • 基于Session的表单启用CSRF防护(csurf或双重提交Cookie)
  • 依赖审计:pnpm audit --audit-level=high

常见问题

在Node.js SaaS应用中,应该使用JWT还是Session?
Session更简单、默认更安全(令牌不会暴露在JS中),且易于撤销。除非你有特定理由使用JWT——比如为第三方API消费者或无法共享Session存储的微服务进行身份验证——否则请使用Session。大多数SaaS应用的前端Web界面并不需要JWT。
如何在JWT过期前撤销它?
没有服务端黑名单就无法撤销JWT,这会失去无状态的优势。可选方案:使用短有效期(15分钟)配合刷新令牌、在Redis中维护黑名单(每次请求检查),或者改用Session。如果需要即时撤销,Session是更好的选择。
OAuth等同于身份验证吗?
不。OAuth 2.0是一种授权协议——它授予第三方对资源的访问权限。基于OAuth 2.0构建的OpenID Connect(OIDC)增加了身份层,使其可用于身份验证(如使用Google、GitHub登录)。当人们说“OAuth登录”时,通常指的是OAuth 2.0 + OIDC。