各位老铁,大家好!
今天我们要聊一个既性感又让人头疼的话题:安全。
想象一下,你辛苦搭好了一个全栈应用,就像你装修了一栋豪宅。前端 React 是那漂亮的大落地窗和真皮沙发,看着就舒服;后端 NestJS 是那坚固的承重墙和安保系统。但是,如果你连门都不锁,或者锁的钥匙只给你自己,那这豪宅和垃圾堆有什么区别?
在 Web 开发的世界里,我们通常不亲手做锁(太累了,也不安全),我们用第三方。今天的主角就是那个著名的开源库——Passport.js。它不是让你带身份证的“边检人员”,而是你应用大门外那个穿着西装、一脸严肃的保镖。
我们的目标是构建一条完美的 OAuth2 SSO(单点登录)链路。也就是说,用户只要在一个地方登录了(比如 Google),以后访问我们的所有系统都不用再输密码了。
好了,废话不多说,戴上你的开发者眼镜,我们开始干活。
第一部分:后端的“护城河” —— NestJS 配置
首先,我们要在 NestJS 这边筑起一道墙。这里我们要用到 @nestjs/passport 和 passport-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 里的“抓人”环节。
我们要处理两个核心路由:
/auth/google:告诉 Google “我想让这个用户登录”,然后 Google 把用户踢回这里。/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,然后默默重试刚才失败的请求。用户甚至感觉不到卡顿。
第六部分:那些年我们踩过的坑(专家经验)
写了这么多,我得给你们提个醒。很多人在这个环节会脱发。
-
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); } -
Cookie vs LocalStorage
- LocalStorage:简单粗暴,容易受 XSS(跨站脚本攻击)影响。如果你的网站有注入漏洞,黑客能读取 localStorage,拿到你的 Token,然后黑进你的账号。
- HttpOnly Cookie:安全!但是!React 读取 Cookie 有点麻烦(通常需要用
js-cookie库,或者复杂的document.cookie解析)。 - 专家建议:对于高安全性要求的 SSO,推荐用 HttpOnly Cookie。你不需要在前端存 Token,React 每次请求 Header 里自动带 Cookie 就行。这样黑客拿不到 Token,因为他们访问不了 Cookie。
-
Redirect URI 的大小写敏感
Google 的 API 对 Redirect URI 是大小写敏感的。
你在 Google Cloud Console 里写的回调地址是http://localhost:3000/auth/google/callback,那你代码里也必须是这个,不能多一个空格,不能少一个字母。这是掉头发的主要原因之一。 -
Scope 权限控制
不要请求所有权限!只请求你需要的。
在 Google Strategy 里,scope数组里只填['email', 'profile']。不要写openid(默认就有),不要写profile(默认就有)。
意思是:Google 问你要啥,你就说你要啥。不要说“我全都要”,那样用户体验很差,而且会吓跑用户。
第七部分:终极形态 —— 全链路整合
好了,让我们把这些拼起来。你现在拥有了一个看起来像模像样的系统。
- 用户 打开 React App。
- React 调用 NestJS
/auth/google。 - NestJS 跳转到 Google 登录页。
- Google 授权,跳回 NestJS
/auth/google/callback。 - NestJS 验证用户,生成 JWT 和 Refresh Token,并设置 HttpOnly Cookie(推荐方式)。
- NestJS 重定向回 React(例如
http://localhost:3000)。 - React 检测到 Cookie 里有 Token,设置登录状态。
- 用户访问
/dashboard,React 自动调用带 Cookie 的 API。 - API 返回数据。
如果 Access Token 过期了,axios 拦截器自动拿 Refresh Token 换新的,用户无感知。
结语:安全是一场马拉松
说到这里,我们的全栈安全链路已经搭建完毕。这不仅仅是代码的堆砌,更是一种思维的转变。
React 负责“体验”,NestJS 负责“防守”,Passport 负责“外交”。
我见过太多项目,为了省事直接把后端接口暴露给前端,或者把 Token 明文存到 localStorage,然后等着被黑客黑。记住,安全不是一种选择,而是一种基础设施。
OAuth2 和 SSO 不是银弹,它只是让你省去了验证用户名密码的繁琐流程。真正的工作还是在你的 validate 方法里——你要确保那个拿着 Google 账号的人,确实是那个你要找的“张三”,而不是李四冒充的。
好了,代码都给你了,剩下的就看你们怎么折腾了。别再裸奔了,赶紧给代码穿上衣服吧!
如果你在配置过程中发现屏幕全是红色的报错,别慌,大概率是 Redirect URI 写错了。删了重写,再试一次。祝你们好运,愿你们的 Session 永不丢失!