身份验证是SaaS产品中最具深远影响的基础设施决策。选错了,就意味着账户被盗、合规失败,以及凌晨两点的紧急补丁。本指南将抛开理论,直接展示你应该实际构建的内容。
三种方案对比
| Session | JWT | OAuth / 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应用,考虑使用Clerk、Auth0或Lucia,而不是自己实现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设置为
httpOnly、secure、sameSite=lax - JWT存储在内存中,而非localStorage
- 刷新令牌在服务端存储并设置过期时间
- 登录端点限流(每15分钟5次尝试)
- 多次失败后锁定账户
- 账户激活前进行邮箱验证
- 生产环境强制HTTPS(负载均衡器后设置
app.set("trust proxy", 1)) - 基于Session的表单启用CSRF防护(
csurf或双重提交Cookie) - 依赖审计:
pnpm audit --audit-level=high