React 与 NestJS 中的 Passport.js 集成:实现支持 OAuth2 与单点登录(SSO)的全栈安全链路

各位老铁,大家好!

今天我们要聊一个既性感又让人头疼的话题:安全

想象一下,你辛苦搭好了一个全栈应用,就像你装修了一栋豪宅。前端 React 是那漂亮的大落地窗和真皮沙发,看着就舒服;后端 NestJS 是那坚固的承重墙和安保系统。但是,如果你连门都不锁,或者锁的钥匙只给你自己,那这豪宅和垃圾堆有什么区别?

在 Web 开发的世界里,我们通常不亲手做锁(太累了,也不安全),我们用第三方。今天的主角就是那个著名的开源库——Passport.js。它不是让你带身份证的“边检人员”,而是你应用大门外那个穿着西装、一脸严肃的保镖

我们的目标是构建一条完美的 OAuth2 SSO(单点登录)链路。也就是说,用户只要在一个地方登录了(比如 Google),以后访问我们的所有系统都不用再输密码了。

好了,废话不多说,戴上你的开发者眼镜,我们开始干活。


第一部分:后端的“护城河” —— NestJS 配置

首先,我们要在 NestJS 这边筑起一道墙。这里我们要用到 @nestjs/passportpassport-google-oauth20

想象 NestJS 是一个特种部队的指挥中心。当你需要外部力量(比如 Google)来帮忙验证人员身份时,你不能直接喊“Google,给我查查这人是谁”,你得制定一个详细的“作战计划”(Strategy)。

1. 安装依赖

就像你要开飞机得先买机票一样,我们得先装库。

npm install @nestjs/passport passport passport-google-oauth20 @types/passport-google-oauth20

2. 编写策略文件

这是核心中的核心。google.strategy.ts 就是你和 Google 官方接口的“翻译官”。

注意,这里有个大坑,很多新手会栽在这里:Client ID 和 Client Secret 必须和你 Redirect URI 完美匹配。 就像你不能拿着去沃尔玛的会员卡去开房一样,Google 不会让你把回调地址写在任何地方。

// src/auth/strategies/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(private configService: ConfigService) {
    // 初始化策略:告诉 Passport 我们要搞 Google OAuth
    super({
      clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
      clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET'),
      callbackURL: 'http://localhost:3000/auth/google/callback', // 你的回调地址
      scope: ['email', 'profile'], // 我们要 Google 给我们什么?邮箱和头像就行
    });
  }

  // 当 Google 验证完用户,把凭证扔过来时,这个方法会被调用
  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: VerifyCallback,
  ): Promise<any> {
    const { id, emails, name } = profile;

    // 这里的逻辑就是:从 Google 拿到数据后,我们要怎么处理?
    // 通常做法:去自己数据库查,如果没查到就创建一个。
    // 为了演示简单,我们直接构造一个用户对象返回。
    const user = {
      providerId: id, // Google 的用户 ID
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      accessToken, // 有时候我们想把 Google 的 Access Token 拿来用
    };

    // done 是 Passport 的回调函数。第一个参数是错误,第二个是用户对象
    done(null, user);
  }
}

这段代码告诉 NestJS:“嘿,哥们,如果有人用 Google 账号进来,就交给我来审讯,把资料整理好给我。”

3. 注册模块

有了保镖(Strategy),你得去模块里把他招安了。

// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule } from '@nestjs/config'; // 为了读取环境变量
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './strategies/google.strategy';

@Module({
  imports: [
    PassportModule, // 必须引入 Passport 模块
    ConfigModule,   // 必须引入 Config 模块,不然读不到 .env
  ],
  controllers: [AuthController],
  providers: [AuthService, GoogleStrategy],
})
export class AuthModule {}

第二部分:控制中心的指挥 —— AuthController

现在策略有了,模块有了,接下来就是 Controller 里的“抓人”环节。

我们要处理两个核心路由:

  1. /auth/google:告诉 Google “我想让这个用户登录”,然后 Google 把用户踢回这里。
  2. /auth/google/callback:Google 拿到用户的授权,把用户扔回来,我们这里要把他“收编”进我们的系统。
// src/auth/auth.controller.ts
import { Controller, Get, Res, Req, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  // 路由 1: 触发 Google 登录
  @Get('google')
  @UseGuards(AuthGuard('google')) // 招牌动作:告诉 NestJS 用 Google 策略
  googleAuth() {
    // 这一行代码执行后,NestJS 会直接重定向到 Google 授权页面
    // 所以这个 Controller 其实只负责“发号施令”
  }

  // 路由 2: 处理 Google 回调
  @Get('google/callback')
  @UseGuards(AuthGuard('google'))
  googleAuthRedirect(@Req() req) {
    // Passport 的魔法在这里生效:
    // 它会自动调用上面那个 Strategy 的 validate 方法
    // validate 方法返回的 user 对象,会乖乖地放在 req.user 里面

    const user = req.user; // 哇,我们拿到了用户数据!

    // 常见的做法是生成一个 JWT 令牌发给前端,或者设置一个 Cookie
    // 这里我们为了演示方便,简单返回 JSON,实际生产中要发 Token
    return {
      message: 'Google 回调成功!',
      user: {
        email: user.email,
        name: `${user.firstName} ${user.lastName}`,
        id: user.providerId,
      },
    };
  }
}

这里有个必须注意的细节:Session 策略 vs JWT 策略
Passport 默认是 Session 策略(Cookie 里的 session ID)。但在 SPA(单页应用)里,我们通常更喜欢无状态的 JWT。我们上面的代码其实用的是 JWT 策略(通过 @UseGuards 触发),因为它不需要额外的 Session 存储,更适合前后端分离。


第三部分:前端的“诱饵” —— React 登录逻辑

现在后端已经准备好了枪和子弹,我们需要在前端给用户一个“按钮”。

React 不懂 Passport,它只懂 JSX 和 State。所以我们要在前端写一个简单的“代理”逻辑。

1. 登录页面

// src/pages/Login.tsx
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; // 假设你用 Router v6

const Login: React.FC = () => {
  const navigate = useNavigate();

  const handleGoogleLogin = () => {
    // 前端直接请求后端的 Google 登录接口
    // 注意:这里其实是直接跳转到了 /auth/google
    window.location.href = 'http://localhost:3000/auth/google';
  };

  return (
    <div style={{ textAlign: 'center', marginTop: '50px' }}>
      <h1>欢迎来到我的全栈堡垒</h1>
      <p>由于安全协议,请通过外部认证系统登录</p>
      <button
        onClick={handleGoogleLogin}
        style={{
          padding: '10px 20px',
          backgroundColor: '#4285F4', // Google 蓝
          color: 'white',
          border: 'none',
          borderRadius: '5px',
          fontSize: '16px',
          cursor: 'pointer',
        }}
      >
        使用 Google 账号登录
      </button>
    </div>
  );
};

export default Login;

点击按钮 -> 浏览器跳转 Google -> 输入密码 -> Google 授权 -> 关键点来了 -> 浏览器跳转回 http://localhost:3000/auth/google/callback

2. 处理回调与存储 Token

这是最有趣的部分。当用户被 Google “踢”回我们的应用时,React 需要立刻把那把“钥匙”(Token)捡起来,揣进兜里。

// src/components/TokenHandler.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const TokenHandler: React.FC = () => {
  const navigate = useNavigate();

  useEffect(() => {
    // 1. 检查 URL 里的 code 参数
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');

    if (code) {
      // 2. 如果有 code,说明是回调
      // 我们要把 code 发给后端,让后端去换取 Token
      fetch('http://localhost:3000/auth/token-exchange', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code }),
      })
        .then((res) => res.json())
        .then((data) => {
          // 3. 后端把 JWT 给我们,我们存起来!
          // 比如存在 localStorage 或者 Redux 里
          localStorage.setItem('authToken', data.token);

          // 4. 满血复活,跳转首页
          navigate('/');
        })
        .catch((err) => console.error('登录失败', err));
    }
  }, [navigate]);

  return <div>正在处理登录凭证...</div>;
};

export default TokenHandler;

注:上面的 /auth/token-exchange 是后端的一个额外接口,用来接收前端的 code,换取 JWT。如果你直接让 /auth/google/callback 返回 JWT,那通常也行,但很多框架(如 Next.js 或特定的 Auth 库)倾向于分离这两个逻辑以保持 Callback 页面简单。


第四部分:守卫森严 —— 保护你的路由

现在用户登录了,Token 也有了。我们得告诉 NestJS:“从现在开始,只有拿着 Token 的家伙才能进这个门。”

我们需要写一个自定义的 Guard(守卫)

// src/common/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // 这里可以添加一些业务逻辑,比如检查用户是否被禁用
    return super.canActivate(context);
  }
}

然后在 Controller 里应用它。

import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';

@Controller('dashboard')
@UseGuards(JwtAuthGuard) // 哎哟,这把锁上好了
export class DashboardController {
  @Get('data')
  getSecretData() {
    return {
      data: '这是只有登录用户才能看到的秘密数据!',
      timestamp: Date.now(),
    };
  }
}

如果不带 Token 访问 /dashboard/data,NestJS 会直接把 401 抛给你。这是浏览器自带的红色小弹窗还是控制台的报错,取决于你怎么配置。


第五部分:进阶玩法 —— OAuth2 的灵魂是 Refresh Token

如果你只是用 Access Token(有效期短,比如 1 小时),用户就会每过一小时就得重新登录一次。这对于 SSO 来说是不可接受的。用户体验就像坐过山车,刚到顶就要掉下去。

我们需要引入 Refresh Token

Refresh Token 的生命周期很长(比如 30 天),但它不能用于访问 API,只能用来“刷新”新的 Access Token。

后端逻辑扩展

// src/auth/auth.service.ts (简化版)
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}

  async loginWithGoogle(profile: any) {
    // 1. 查库或创建用户
    const user = await this.findUser(profile.email);

    // 2. 生成 Access Token (短期有效)
    const payload = { email: user.email, sub: user.id };
    const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });

    // 3. 生成 Refresh Token (长期有效)
    const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });

    return {
      accessToken,
      refreshToken,
    };
  }

  // 刷新接口
  async refreshTokens(refreshToken: string) {
    try {
      // 验证 Refresh Token 是否有效
      const payload = this.jwtService.verify(refreshToken);
      const user = await this.findUser(payload.email);

      // 生成新的 Access Token
      const newAccessToken = this.jwtService.sign(
        { email: user.email, sub: user.id },
        { expiresIn: '1h' }
      );

      return { accessToken: newAccessToken };
    } catch (err) {
      throw new Error('Refresh Token 无效或过期');
    }
  }
}

前端逻辑扩展

前端需要监听 Token 过期,自动去后台换新 Token。

// src/utils/api.ts (封装 axios)
import axios from 'axios';

const api = axios.create({
  baseURL: 'http://localhost:3000',
});

// 请求拦截器
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('authToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:救命稻草
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // 如果是 401 错误,且不是正在刷新,也不是正在刷新中
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const refreshToken = localStorage.getItem('refreshToken');
        const res = await axios.post('/auth/refresh', { refreshToken });

        const { accessToken } = res.data;
        localStorage.setItem('authToken', accessToken);

        // 重试原请求
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        // 如果 Refresh Token 也挂了,干掉所有 Token,跳转登录页
        localStorage.clear();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

export default api;

这段代码简直是“救命稻草”。当 Access Token 过期,API 返回 401 时,拦截器会自动拿起 Refresh Token 去后台换一个新的 Access Token,然后默默重试刚才失败的请求。用户甚至感觉不到卡顿。


第六部分:那些年我们踩过的坑(专家经验)

写了这么多,我得给你们提个醒。很多人在这个环节会脱发。

  1. CORS(跨域资源共享)
    这是最烦人的家伙。React 运行在 localhost:3000,NestJS 运行在 localhost:5000
    如果你直接在浏览器跳转 window.location.href = 'http://localhost:5000/auth/google',虽然能跳,但容易出问题。
    最好的做法是在 React 里用 fetch 请求 localhost:5000/auth/google,然后捕获重定向。
    但是,如果你在 Google 回调时遇到 CORS 错误,是因为你的 NestJS 配置里没允许跨域。

    // main.ts
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      // 关键配置:允许跨域
      app.enableCors({
        origin: 'http://localhost:3000', // 允许你的前端
        credentials: true, // 允许携带 Cookie(如果用 Cookie 存 Token)
      });
    
      await app.listen(5000);
    }
  2. Cookie vs LocalStorage

    • LocalStorage:简单粗暴,容易受 XSS(跨站脚本攻击)影响。如果你的网站有注入漏洞,黑客能读取 localStorage,拿到你的 Token,然后黑进你的账号。
    • HttpOnly Cookie:安全!但是!React 读取 Cookie 有点麻烦(通常需要用 js-cookie 库,或者复杂的 document.cookie 解析)。
    • 专家建议:对于高安全性要求的 SSO,推荐用 HttpOnly Cookie。你不需要在前端存 Token,React 每次请求 Header 里自动带 Cookie 就行。这样黑客拿不到 Token,因为他们访问不了 Cookie。
  3. Redirect URI 的大小写敏感
    Google 的 API 对 Redirect URI 是大小写敏感的。
    你在 Google Cloud Console 里写的回调地址是 http://localhost:3000/auth/google/callback,那你代码里也必须是这个,不能多一个空格,不能少一个字母。这是掉头发的主要原因之一。

  4. Scope 权限控制
    不要请求所有权限!只请求你需要的。
    在 Google Strategy 里,scope 数组里只填 ['email', 'profile']。不要写 openid(默认就有),不要写 profile(默认就有)。
    意思是:Google 问你要啥,你就说你要啥。不要说“我全都要”,那样用户体验很差,而且会吓跑用户。


第七部分:终极形态 —— 全链路整合

好了,让我们把这些拼起来。你现在拥有了一个看起来像模像样的系统。

  1. 用户 打开 React App。
  2. React 调用 NestJS /auth/google
  3. NestJS 跳转到 Google 登录页。
  4. Google 授权,跳回 NestJS /auth/google/callback
  5. NestJS 验证用户,生成 JWT 和 Refresh Token,并设置 HttpOnly Cookie(推荐方式)。
  6. NestJS 重定向回 React(例如 http://localhost:3000)。
  7. React 检测到 Cookie 里有 Token,设置登录状态。
  8. 用户访问 /dashboard,React 自动调用带 Cookie 的 API。
  9. API 返回数据。

如果 Access Token 过期了,axios 拦截器自动拿 Refresh Token 换新的,用户无感知。


结语:安全是一场马拉松

说到这里,我们的全栈安全链路已经搭建完毕。这不仅仅是代码的堆砌,更是一种思维的转变。

React 负责“体验”,NestJS 负责“防守”,Passport 负责“外交”。

我见过太多项目,为了省事直接把后端接口暴露给前端,或者把 Token 明文存到 localStorage,然后等着被黑客黑。记住,安全不是一种选择,而是一种基础设施。

OAuth2 和 SSO 不是银弹,它只是让你省去了验证用户名密码的繁琐流程。真正的工作还是在你的 validate 方法里——你要确保那个拿着 Google 账号的人,确实是那个你要找的“张三”,而不是李四冒充的。

好了,代码都给你了,剩下的就看你们怎么折腾了。别再裸奔了,赶紧给代码穿上衣服吧!

如果你在配置过程中发现屏幕全是红色的报错,别慌,大概率是 Redirect URI 写错了。删了重写,再试一次。祝你们好运,愿你们的 Session 永不丢失!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注