用户认证与授权

密码哈希、JWT、角色权限——保护你的 API

🎯 学习目标

学完本章,你将能够:

  • 使用 bcrypt 对密码进行安全哈希存储
  • 实现基于 JWT 的注册、登录和令牌刷新流程
  • 编写认证中间件保护受限路由
  • 理解 RBAC 角色权限控制模型,能实现基本的权限检查
  • 了解 OAuth 第三方登录的基本流程

你有没有想过

为什么你登录淘宝之后,刷新页面还是登录状态?别人为什么不能以你的身份买东西?

这背后涉及两个核心概念:认证(Authentication)——"你是谁";授权(Authorization)——"你能做什么"。

这一章你将学会:用 bcrypt 安全地存储密码、用 JWT 实现无状态认证、用 RBAC 控制角色权限。

认证 vs 授权

两个经常被混淆的概念,但它们完全不同

认证 Authentication授权 Authorization
回答的问题你是谁?你能做什么?
常见方式密码登录、OAuth角色、权限检查
比喻检查身份证查看门禁权限
HTTP 状态码401 Unauthorized403 Forbidden

Part 1: 密码哈希

用户密码的安全存储

明文存储密码等于把钥匙放在门口的垫子下面,任何能访问数据库的人都能看到所有用户的密码。密码哈希是用户认证的第一道防线,它让即使数据库泄露,攻击者也无法还原出原始密码。bcrypt 是目前 Node.js 生态中最推荐的密码哈希算法,内置盐值和自适应工作因子,能有效抵抗暴力破解和彩虹表攻击。

为什么用 bcrypt

不是所有哈希算法都适合存密码

特性bcryptMD5/SHA-256
内置盐值是否(需手动加盐)
自适应工作因子是否
计算慢(抗暴力破解)是否(GPU加速极易)

bcrypt 的"慢"是优点。它让攻击者破解一个密码的成本变得极高。工作因子越高,计算越慢,安全性越强。

bcrypt 用法

两个函数:hash 用于注册,compare 用于登录

$ npm install bcryptjs
import bcrypt from 'bcryptjs';

const SALT_ROUNDS = 12; // 推荐值 10-12

// 注册时:哈希密码
async function hashPassword(plainPassword: string): Promise<string> {
    const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
    return hash;
    // 输出类似: $2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
}

// 登录时:验证密码
async function verifyPassword(
    plainPassword: string,
    hashedPassword: string
): Promise<boolean> {
    return bcrypt.compare(plainPassword, hashedPassword);
}

注册/登录服务

将密码哈希集成到用户注册和登录流程中

// src/services/auth.service.ts
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
const SALT_ROUNDS = 12;

export class AuthService {
    async register(email: string, password: string, name: string) {
        const existing = await prisma.user.findUnique({ where: { email } });
        if (existing) throw new Error('该邮箱已被注册');

        const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
        return prisma.user.create({
            data: { email, name, password: hashedPassword },
            select: { id: true, email: true, name: true, createdAt: true },
        });
    }

    async login(email: string, password: string) {
        const user = await prisma.user.findUnique({ where: { email } });
        if (!user) throw new Error('邮箱或密码错误'); // 不提示"用户不存在"

        const isValid = await bcrypt.compare(password, user.password);
        if (!isValid) throw new Error('邮箱或密码错误');

        const { password: _, ...safeUser } = user;
        return safeUser;
    }
}
登录失败时始终返回相同的错误信息。 不要分别提示"用户不存在"或"密码错误",这会让攻击者可以枚举有效邮箱。

认证架构

注册、登录、Token 管理的完整数据流

图 A:JWT 认证数据流主图 图 B:JWT 验证中间件流程

Part 2: JWT 认证

无状态令牌认证方案

用户登录后,服务器需要一个方式来"记住"这个用户。JWT(JSON Web Token)是目前最流行的无状态认证方案,它把用户信息编码成一个带签名的令牌字符串,服务器不需要存储会话数据就能验证用户身份。我们将学习双令牌策略——Access Token 负责短期访问、Refresh Token 负责长期续期,兼顾安全性和用户体验。

JWT 结构

三段 Base64 编码的字符串,用点号分隔

Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjF9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
部分内容说明
Header算法和类型{"alg":"HS256","typ":"JWT"}
Payload用户数据userId、email、role 等
Signature签名防止数据被篡改
JWT 的 Payload 只是 Base64 编码,不是加密! 永远不要在 JWT 中存储密码、身份证号等敏感信息。

双令牌策略

Access Token 短命,Refresh Token 长命

属性Access TokenRefresh Token
有效期短(15-30分钟)长(7-30天)
用途访问受保护的 API获取新的 Access Token
存储位置内存 / httpOnly CookiehttpOnly Cookie / 数据库
泄露风险短有效期降低风险可被撤销

认证流程

从登录到访问受保护资源的完整流程

图:JWT 登录认证与 Token 刷新的完整时序流程

JWT 工具函数

生成、验证令牌的封装

$ npm install jsonwebtoken
$ npm install -D @types/jsonwebtoken
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'access-secret-dev';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret-dev';

interface TokenPayload {
    userId: string;
    email: string;
    role: string;
}

export function generateAccessToken(payload: TokenPayload): string {
    return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' });
}

export function generateRefreshToken(payload: TokenPayload): string {
    return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });
}

export function verifyAccessToken(token: string): TokenPayload {
    return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}

export function generateTokenPair(payload: TokenPayload) {
    return {
        accessToken: generateAccessToken(payload),
        refreshToken: generateRefreshToken(payload),
    };
}

登录接口

验证通过后签发令牌对

router.post('/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await authService.login(email, password);
    const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });

    // Refresh Token 放 httpOnly Cookie
    res.cookie('refreshToken', tokens.refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000,
        path: '/api/auth/refresh',
    });

    res.json({ user, accessToken: tokens.accessToken });
});

Token 刷新接口

Access Token 过期后,用 Refresh Token 无感续期

Access Token 有效期只有 15 分钟,过期后用户不应该被强制重新登录。POST /auth/refresh 接口从 httpOnly Cookie 中取出 Refresh Token,验证通过后签发新的令牌对。为了安全,每次刷新都会轮换(Rotate)Refresh Token——旧 Token 立即失效,新 Token 写入 Cookie。

router.post('/refresh', async (req, res) => {
    const token = req.cookies.refreshToken;
    if (!token) {
        res.status(401).json({ error: '缺少 Refresh Token' });
        return;
    }

    try {
        // 1. 验证 Refresh Token
        const decoded = jwt.verify(token, REFRESH_SECRET) as TokenPayload;

        // 2. 检查是否已被撤销(可选:查 Redis 黑名单)
        const blacklisted = await redis.get(`bl:${token}`);
        if (blacklisted) {
            res.status(401).json({ error: '令牌已被撤销' });
            return;
        }

        // 3. 将旧 Refresh Token 加入黑名单
        const ttl = decoded.exp! - Math.floor(Date.now() / 1000);
        await redis.set(`bl:${token}`, '1', 'EX', ttl);

        // 4. 签发新令牌对(轮换)
        const payload = { userId: decoded.userId, email: decoded.email, role: decoded.role };
        const newTokens = generateTokenPair(payload);

        // 5. 写入新 Cookie
        res.cookie('refreshToken', newTokens.refreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000,
            path: '/api/auth/refresh',
        });

        res.json({ accessToken: newTokens.accessToken });
    } catch {
        res.status(401).json({ error: 'Refresh Token 无效或已过期' });
    }
});
Refresh Token 轮换是安全最佳实践。 如果攻击者窃取了旧的 Refresh Token,它在下次刷新时就会失效,且服务器可以通过"重用检测"发现异常。

登出与令牌撤销

用户点了"退出登录",Token 怎么失效?

JWT 是无状态的,一旦签发就无法"取消"。那用户登出怎么办?常见方案有两种:客户端清除(删除 Cookie 和内存中的 Token)和服务端黑名单(用 Redis 记录已撤销的 Token)。两者结合才能实现真正的登出。

router.post('/logout', requireAuth, async (req, res) => {
    const refreshToken = req.cookies.refreshToken;

    // 1. 将 Refresh Token 加入 Redis 黑名单
    if (refreshToken) {
        try {
            const decoded = jwt.decode(refreshToken) as any;
            const ttl = decoded.exp - Math.floor(Date.now() / 1000);
            if (ttl > 0) await redis.set(`bl:${refreshToken}`, '1', 'EX', ttl);
        } catch { /* Token 可能已无效,忽略 */ }
    }

    // 2. 清除 Cookie
    res.clearCookie('refreshToken', { path: '/api/auth/refresh' });

    // 3. 前端也需清除内存中的 Access Token
    res.json({ message: '已成功登出' });
});

为什么 JWT 不能被真正"撤销"? 因为 JWT 的验证只依赖密钥,不需要查数据库。如果要求每次请求都查库验证,那就失去了 JWT 无状态的优势——这时不如直接用 Session 方案。黑名单是一种折中:只在登出/撤销时写 Redis,不影响正常请求的性能。

Part 3: 认证中间件

在路由中统一校验用户身份

JWT 签发了,但怎么在路由中检查?答案是中间件。Express 中间件可以在请求到达控制器之前自动提取并验证 Token,将解码后的用户信息挂载到 req.user 上,后续的路由处理器就能直接使用。这样每个需要认证的接口只需加一行中间件调用,无需重复编写验证逻辑。

requireAuth 中间件

从请求头提取 Token,验证后把用户信息挂到 req 上

// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, TokenPayload } from '../utils/jwt';

declare global {
    namespace Express {
        interface Request {
            user?: TokenPayload;
        }
    }
}

export function requireAuth(req: Request, res: Response, next: NextFunction) {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
        res.status(401).json({ error: '未提供认证令牌' });
        return;
    }

    const token = authHeader.split(' ')[1];
    try {
        req.user = verifyAccessToken(token);
        next();
    } catch (error: any) {
        if (error.name === 'TokenExpiredError') {
            res.status(401).json({ error: '令牌已过期,请刷新' });
            return;
        }
        res.status(403).json({ error: '无效的认证令牌' });
    }
}

在路由中使用

一行代码保护任何路由

// 公开路由
router.get('/public-profile/:id', userController.getPublicProfile);

// 需要认证
router.get('/me', requireAuth, userController.getMe);
router.get('/me/posts', requireAuth, userController.getMyPosts);
router.patch('/me', requireAuth, userController.updateMe);

// 控制器中使用 req.user
export function getMe(req: Request, res: Response) {
    const user = req.user!; // 经过 requireAuth 后一定存在
    res.json({ id: user.userId, email: user.email, role: user.role });
}

密码重置流程

"忘记密码"功能的完整实现

密码重置分两步:先通过 POST /auth/forgot-password 生成一个短期有效的重置令牌并发送到用户邮箱;再通过 POST /auth/reset-password 验证令牌后更新密码。重置令牌应该是一次性的,用过即失效。

图:密码重置流程的完整时序交互

密码重置代码

用 crypto 生成令牌,用 Prisma 存储验证

import crypto from 'crypto';
import { sendEmail } from '../utils/email';

// 第一步:请求重置密码
router.post('/forgot-password', async (req, res) => {
    const { email } = req.body;
    const user = await prisma.user.findUnique({ where: { email } });
    // 即使用户不存在也返回成功,防止邮箱枚举
    if (!user) return res.json({ message: '如果该邮箱已注册,你将收到重置邮件' });

    const resetToken = crypto.randomBytes(32).toString('hex');
    const hashedToken = crypto.createHash('sha256').update(resetToken).digest('hex');

    await prisma.user.update({
        where: { id: user.id },
        data: {
            resetToken: hashedToken,
            resetTokenExpiry: new Date(Date.now() + 15 * 60 * 1000),
        },
    });

    const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
    await sendEmail(user.email, '密码重置', `点击链接重置密码:${resetUrl}`);
    res.json({ message: '如果该邮箱已注册,你将收到重置邮件' });
});

// 第二步:重置密码
router.post('/reset-password', async (req, res) => {
    const { token, newPassword } = req.body;
    const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

    const user = await prisma.user.findFirst({
        where: {
            resetToken: hashedToken,
            resetTokenExpiry: { gt: new Date() },
        },
    });
    if (!user) { res.status(400).json({ error: '令牌无效或已过期' }); return; }

    await prisma.user.update({
        where: { id: user.id },
        data: {
            password: await bcrypt.hash(newPassword, 12),
            resetToken: null,
            resetTokenExpiry: null,
        },
    });
    res.json({ message: '密码重置成功' });
});
重置令牌只存哈希,邮件中发送明文。 这样即使数据库泄露,攻击者也无法用哈希值构造重置链接。

☕ 学习断点

认证基础已完成,休息后进入授权部分

你已经学会了密码哈希、JWT 认证和认证中间件——这是用户系统的核心安全机制。

建议:休息 5 分钟,回顾一下 JWT 的签发和验证流程。

💡 后半部分将学习 RBAC 角色权限控制和 OAuth 第三方登录——这些是高级认证授权的常见模式。

Part 4: RBAC

基于角色的访问控制

不是所有人都能做所有事。RBAC(Role-Based Access Control)通过给用户分配角色、给角色分配权限,实现了灵活的访问控制。比如普通用户只能查看和编辑自己的内容,编辑可以管理所有文章,管理员拥有全部权限。这种模式在企业应用中几乎是标配。

权限矩阵

哪个角色能做什么,一目了然

操作ADMINEDITORUSER
查看所有用户是否否
删除任何文章是是否
创建文章是是是
编辑自己的文章是是是
管理用户角色是否否

角色检查

工厂函数生成角色检查中间件

type Role = 'ADMIN' | 'EDITOR' | 'USER';

const ROLE_HIERARCHY: Record<Role, number> = {
    USER: 1, EDITOR: 2, ADMIN: 3,
};

export function requireRole(...allowedRoles: Role[]) {
    return (req: Request, res: Response, next: NextFunction) => {
        requireAuth(req, res, () => {
            const userRole = req.user!.role as Role;
            if (!allowedRoles.includes(userRole)) {
                res.status(403).json({ error: '权限不足' });
                return;
            }
            next();
        });
    };
}

// 使用
router.get('/admin/users', requireRole('ADMIN'), adminController.listUsers);
router.delete('/posts/:id', requireRole('ADMIN', 'EDITOR'), postController.deletePost);

Part 5: OAuth

第三方登录集成

"用 GitHub 登录"——OAuth 2.0 让用户不用在你的网站上注册新账号就能登录,极大降低了注册门槛。它的工作原理是把认证过程委托给可信的第三方(GitHub、Google、微信等),你的后端只需要拿到授权码换取用户信息,然后签发自己的 JWT。Passport.js 是 Node.js 生态中最成熟的 OAuth 实现库。

OAuth 流程

用户点击"GitHub 登录"后发生了什么

图:OAuth 2.0 第三方 GitHub 登录的完整时序流程

Passport.js 实现

Node.js 生态中最成熟的 OAuth 库

$ npm install passport passport-github2 express-session
$ npm install -D @types/passport @types/passport-github2
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';

passport.use(new GitHubStrategy({
    clientID: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    callbackURL: process.env.GITHUB_CALLBACK_URL!,
}, async (accessToken, refreshToken, profile, done) => {
    // 查找或创建用户
    let user = await prisma.user.findFirst({
        where: { oauthProvider: 'github', oauthId: profile.id },
    });
    if (!user) {
        user = await prisma.user.create({
            data: {
                email: profile.emails?.[0]?.value,
                name: profile.displayName,
                password: '', // OAuth 用户没有密码
            },
        });
    }
    done(null, user);
}));
OAuth Client Secret 绝对不能暴露在前端代码中! 务必使用环境变量管理。

Session 方案

JWT 不是唯一选择——Session 方案同样经典

Session 方案将用户状态存储在服务器端(通常是 Redis),客户端只持有一个 Session ID Cookie。相比 JWT,Session 可以被服务器立即撤销,但需要额外的存储依赖。下面是两种方案的对比:

对比项JWTSession
存储位置客户端(无状态)服务器端(Redis/DB)
可撤销性需要黑名单直接删除 session
水平扩展天然支持需要共享存储
适合移动端是需要额外处理
适用场景移动端、微服务、SSO传统 Web 应用、管理后台

何时选 Session?如果你的应用只需要浏览器访问、需要即时撤销能力、且不想处理 Token 黑名单的复杂性,Session + Redis 是更简单、更直观的选择。

Session 代码实现

express-session + connect-redis 配置

$ npm install express-session connect-redis ioredis
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, prefix: 'sess:' }),
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 24 * 60 * 60 * 1000, // 1 天
    },
}));

// 登录时存储用户信息
router.post('/login', async (req, res) => {
    const user = await authService.login(req.body.email, req.body.password);
    req.session.userId = user.id;
    req.session.role = user.role;
    res.json({ user });
});

// 登出时销毁 session
router.post('/logout', (req, res) => {
    req.session.destroy(() => {
        res.clearCookie('connect.sid');
        res.json({ message: '已登出' });
    });
});

// 受保护路由直接读 session
router.get('/me', (req, res) => {
    if (!req.session.userId) {
        res.status(401).json({ error: '请先登录' });
        return;
    }
    // ...
});

CSRF 防护

为什么把 JWT 放在 Cookie 里仍然有风险

CSRF(跨站请求伪造)攻击利用浏览器自动携带 Cookie 的特性:用户在银行网站已登录,然后访问了一个恶意页面,恶意页面的 JavaScript 可以向银行发起转账请求——浏览器会自动带上银行的 Cookie。

防护方案原理优缺点
SameSite CookieCookie 只在同站请求中发送最简单,但旧浏览器不支持
CSRF Token服务器签发 Token,表单提交时校验最经典,需前后端配合
Double-submit CookieCookie 和 Header 中都放 Token,比对是否一致无需服务器存储
Bearer Token(非 Cookie)Token 放 Authorization 头,浏览器不会自动发送天然免疫 CSRF

CSRF 防护代码

SameSite + Double-submit Cookie

// 方案一:SameSite Cookie(最简单,现代浏览器都支持)
res.cookie('token', token, {
    httpOnly: true,
    sameSite: 'strict',  // 或 'lax'(允许安全的顶级导航)
    secure: true,
});

// 方案二:Double-submit Cookie(无需服务端存储,自己实现约 20 行)
import crypto from 'crypto';

// 签发:把一个随机 token 同时写入 Cookie 和响应体
router.get('/csrf-token', (req, res) => {
    const csrfToken = crypto.randomBytes(32).toString('hex');
    res.cookie('csrfToken', csrfToken, {
        httpOnly: false,        // 前端 JS 要读取后放进请求头
        sameSite: 'strict',
        secure: true,
    });
    res.json({ csrfToken });
});

// 校验中间件:比对 Cookie 里的 token 与自定义请求头里的 token 是否一致
function csrfProtection(req, res, next) {
    const cookieToken = req.cookies.csrfToken;
    const headerToken = req.headers['x-csrf-token'];
    // 长度相等且恒等比较,避免时序攻击
    if (!cookieToken || !headerToken ||
        cookieToken.length !== headerToken.length ||
        !crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) {
        return res.status(403).json({ error: 'CSRF 校验失败' });
    }
    next();
}

// 需要 CSRF 保护的请求
router.post('/transfer', csrfProtection, (req, res) => {
    res.json({ message: '转账成功' });
});
如果用 Bearer Token 放在 Authorization 头中而非 Cookie,天然免疫 CSRF。 但如果你选择把 JWT 放在 httpOnly Cookie 中以防止 XSS 窃取,就必须同时做 CSRF 防护。安全没有银弹,只有权衡。
避开 csurf 这个包。曾经最流行的 Express CSRF 中间件 csurf 自 2022 年起已停止维护,且其自身存在已知 CSRF 绕过漏洞,官方已明确建议弃用。上面给出的 Double-submit Cookie 自实现只有约 20 行、无第三方依赖,足够大多数场景;如果需要更完善的方案(如自动轮换 token、可配置 cookie 选项),可使用维护中的 csrf-csrf 替代。

输入验证 Schema

用 Zod 为认证接口定义严格的验证规则

认证系统是攻击的首要目标。注册邮箱需要格式验证和归一化、密码需要强度检查、登录参数需要长度限制。Zod 是 TypeScript 生态中最适合做请求验证的库——它既能验证运行时数据,又能自动推导类型。

$ npm install zod
import { z } from 'zod';

// 注册验证:邮箱归一化 + 密码强度
const registerSchema = z.object({
    email: z.string().email('邮箱格式不正确').toLowerCase().trim(),
    password: z.string()
        .min(8, '密码至少 8 位')
        .max(128, '密码不超过 128 位')
        .regex(/[A-Z]/, '需要至少一个大写字母')
        .regex(/[a-z]/, '需要至少一个小写字母')
        .regex(/[0-9]/, '需要至少一个数字')
        .regex(/[^A-Za-z0-9]/, '需要至少一个特殊字符'),
    name: z.string().min(2).max(50).trim(),
});

// 登录验证:邮箱归一化 + 密码非空
const loginSchema = z.object({
    email: z.string().email('邮箱格式不正确').toLowerCase().trim(),
    password: z.string().min(1, '请输入密码'),
});

// 密码重置验证
const resetPasswordSchema = z.object({
    token: z.string().length(64, '令牌格式无效'), // 32 bytes hex
    newPassword: z.string().min(8).max(128),
});

验证中间件集成

一行代码为路由加上输入验证

// 统一验证中间件
function validate(schema: z.ZodSchema) {
    return (req: Request, res: Response, next: NextFunction) => {
        const result = schema.safeParse(req.body);
        if (!result.success) {
            res.status(400).json({
                error: '输入验证失败',
                details: result.error.flatten().fieldErrors,
            });
            return;
        }
        req.body = result.data; // 使用验证+归一化后的数据
        next();
    };
}

// 路由中使用
router.post('/register', validate(registerSchema), authController.register);
router.post('/login', validate(loginSchema), authController.login);
router.post('/reset-password', validate(resetPasswordSchema), authController.resetPassword);

// 错误响应示例
// POST /register { email: "abc", password: "123" }
// 400 {
//   "error": "输入验证失败",
//   "details": {
//     "email": ["邮箱格式不正确"],
//     "password": ["密码至少 8 位", "需要至少一个大写字母", ...]
//   }
// }
验证通过后用 result.data 替换 req.body。 这样后续代码拿到的是经过归一化(如 toLowerCase()、trim())后的干净数据。

常见问题

认证问题往往是最难排查的。 建议先用 console.log 打印 Token 内容,确认签发和验证使用的是同一个密钥。

问题一:Token 验证总是返回"无效"

原因:签发和验证使用的密钥不一致,或环境变量在不同环境下值不同。

修复:确保 JWT_ACCESS_SECRET 在签发和验证时是同一个值。检查 .env 文件。

问题二:bcrypt.compare 总返回 false

原因:注册时哈希的密码可能被截断了(数据库字段太短)。

修复:bcrypt 哈希结果固定 60 字符,确保数据库 password 字段至少 VARCHAR(255)。

问题三:OAuth 回调报错"redirect_uri_mismatch"

原因:GitHub 后台配置的回调 URL 和代码中的不一致。

修复:在 GitHub Settings > Developer settings 中检查回调 URL,确保和 GITHUB_CALLBACK_URL 完全一致。

问题四:Refresh Token 刷新后旧 Token 仍可用

原因:Redis 黑名单未生效,或 TTL 设置为 0 导致 key 立即过期。

修复:确认 ttl = decoded.exp - now 为正数再写入 Redis,并用 redis.get 验证黑名单确实生效。

试一试

练习一

任务:写一个注册接口,接收 email 和 password。用 bcrypt 哈希密码后存入数据库。

// 提示:先查重,再 hash,再 create
// 你的代码:

参考答案

router.post('/register', asyncHandler(async (req, res) => {
    const { email, password } = req.body;
    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) throw new AppError(409, '该邮箱已注册');

    const hash = await bcrypt.hash(password, 12);
    const user = await prisma.user.create({
        data: { email, password: hash, name: email.split('@')[0] },
        select: { id: true, email: true },
    });
    res.status(201).json(user);
}));

练习二

任务:给 DELETE /api/posts/:id 加上认证和权限检查——只有 ADMIN 或文章作者才能删除。

// 提示:requireAuth + 检查 req.user.role 或 post.authorId
// 你的代码:

参考答案

router.delete('/posts/:id', requireAuth, asyncHandler(async (req, res) => {
    const post = await prisma.post.findUnique({ where: { id: req.params.id } });
    if (!post) throw new AppError(404, '文章不存在');
    if (req.user!.role !== 'ADMIN' && post.authorId !== req.user!.userId) {
        throw new AppError(403, '只能删除自己的文章');
    }
    await prisma.post.delete({ where: { id: req.params.id } });
    res.json({ message: '删除成功' });
}));

练习三:防枚举登录

任务:下面这个登录接口有信息泄露漏洞——攻击者能通过不同的错误信息判断哪些邮箱已注册。修复它。

// 漏洞代码:错误信息暴露了用户是否存在
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new AppError(404, '用户不存在');     // ← 暴露了
const match = await bcrypt.compare(password, user.password);
if (!match) throw new AppError(401, '密码错误');        // ← 也暴露了

要求:无论邮箱是否存在、密码是否正确,都返回相同的错误信息和状态码。但为了不泄露时序信息(通过响应时间判断),compare 仍要执行。

参考答案

router.post('/login', asyncHandler(async (req, res) => {
    const { email, password } = req.body;
    const user = await prisma.user.findUnique({ where: { email } });

    // 统一错误:不区分"用户不存在"和"密码错误"
    const GENERIC = '邮箱或密码错误';

    // 即便用户不存在,也做一次 compare,保证响应时间一致
    const hash = user?.password ?? '$2b$12$无效占位哈希值..............';
    const match = await bcrypt.compare(password, hash);

    if (!user || !match) throw new AppError(401, GENERIC);

    // ...签发 token
}));

为什么这么做:枚举攻击(enumeration)是黑客收集有效账号的常用手段。"该邮箱未注册"这句话本身就是在帮攻击者。

练习四:Refresh Token 轮换

任务:实现 refresh 接口的核心逻辑——验证旧 refresh token、签发新的一对 token,并让旧 refresh token 立即失效(防止重放)。

// 假设 Redis 可用,key 格式:refresh:{userId}
// 提示:旧 token 用过即弃,存入"黑名单"或直接覆盖
async function refresh(oldRefreshToken) {
    // 1. 验证旧 token 的签名和有效期
    // 2. 检查它是否仍有效(未被吊销)
    // 3. 签发新的 access + refresh token
    // 4. 让旧 refresh token 失效
    // 你的代码:
}

关键点:如果不做"用过即弃",一个泄露的 refresh token 可以无限续期——这是 JWT 方案最危险的地方。

参考答案

async function refresh(oldRefreshToken) {
    // 1. 验证签名 + 有效期
    const payload = jwt.verify(oldRefreshToken, REFRESH_SECRET);

    // 2. 检查是否仍是当前有效的 token(防重放)
    const stored = await redis.get(`refresh:${payload.userId}`);
    if (stored !== oldRefreshToken) {
        // 旧 token 已被轮换 → 可能被盗用,吊销整个会话
        await redis.del(`refresh:${payload.userId}`);
        throw new AppError(401, '请重新登录');
    }

    // 3. 签发新的一对 token
    const newAccess = signAccessToken(payload.userId);
    const newRefresh = signRefreshToken(payload.userId);

    // 4. 旧 token 立即失效:用新 token 覆盖存储
    await redis.set(`refresh:${payload.userId}`, newRefresh, 'EX', 60 * 60 * 24 * 7);

    return { accessToken: newAccess, refreshToken: newRefresh };
}

进阶思考:如果用户在两台设备上登录,上面这种"单 token 覆盖"会让一台设备的登录挤掉另一台。生产系统通常为每个设备维护独立的 refresh token,或采用"令牌家族"(token family)检测重放。

📝 要点总结

本章核心知识点

密码安全

bcrypt 哈希 + 盐值,即使数据库泄露也无法还原原始密码

JWT 认证

Access Token + Refresh Token 双令牌策略,平衡安全性和用户体验

认证中间件

requireAuth 中间件统一校验 Token,保护受限路由

RBAC 授权

角色 + 权限矩阵,控制不同用户对资源的访问级别

下一章

认证搞定了。下一章学习文件上传与静态资源——让用户能上传头像、图片和文档。

1 / X