You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在Next.js与NestJS中集成Google OAuth 2.0的实现方案问询

Next.js + NestJS 实现 Google OAuth 2.0 完整指南

一、整体交互流程

  1. 用户在Next.js客户端点击Google登录按钮,客户端重定向到Google OAuth授权页面
  2. 用户完成Google账号验证并授权后,Google将授权码发送到NestJS后端的回调地址
  3. 后端用授权码向Google请求获取访问令牌(access_token)和用户信息
  4. 后端验证用户信息后,生成自定义的JWT访问令牌和刷新令牌,将刷新令牌存入HttpOnly Cookie,返回访问令牌给客户端
  5. 客户端存储访问令牌到内存,后续发起API请求时携带该令牌
  6. 访问令牌过期后,客户端用Cookie中的刷新令牌请求后端获取新的访问令牌

二、Next.js 客户端实现

1. 环境配置

在项目根目录创建.env.local,添加Google OAuth配置和NextAuth密钥:

GOOGLE_CLIENT_ID=你的Google客户端ID
GOOGLE_CLIENT_SECRET=你的Google客户端密钥
NEXTAUTH_SECRET=随机生成的密钥(可用`openssl rand -hex 32`生成)
NEXT_PUBLIC_API_URL=http://localhost:3001(你的NestJS后端地址)

2. 集成NextAuth.js

创建app/api/auth/[...nextauth]/route.ts配置认证路由:

import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";

export const authOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: { params: { scope: "openid email profile" } },
    }),
  ],
  session: { strategy: "jwt" },
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.accessToken = token.accessToken as string;
      session.user.refreshToken = token.refreshToken as string;
      session.user.expiresAt = token.expiresAt as number;
      return session;
    },
  },
  secret: process.env.NEXTAUTH_SECRET!,
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

3. 登录状态管理与UI

创建登录按钮组件app/components/LoginButton.tsx:

"use client";

import { useSession, signIn, signOut } from "next-auth/react";

export default function LoginButton() {
  const { data: session } = useSession();

  if (session) {
    return (
      <div>
        <p>欢迎, {session.user.name}</p>
        <button onClick={() => signOut()}>退出登录</button>
      </div>
    );
  }
  return <button onClick={() => signIn("google")}>使用Google登录</button>;
}

在页面中使用该组件,通过useSession钩子判断登录状态,渲染受保护内容。

4. API请求拦截器

创建app/lib/axios.ts配置请求拦截,自动携带令牌并处理过期刷新:

import axios from "axios";
import { getSession, updateSession } from "next-auth/react";

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  withCredentials: true,
});

api.interceptors.request.use(async (config) => {
  const session = await getSession();
  if (session?.user.accessToken) {
    config.headers.Authorization = `Bearer ${session.user.accessToken}`;
  }
  return config;
});

api.interceptors.response.use(
  (res) => res,
  async (err) => {
    const originalRequest = err.config;
    if (err.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      const session = await getSession();
      if (session?.user.refreshToken) {
        try {
          const refreshRes = await api.post("/auth/refresh");
          const newAccessToken = refreshRes.data.accessToken;
          // 更新session中的令牌
          await updateSession({
            ...session,
            user: { ...session.user, accessToken: newAccessToken },
          });
          originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
          return api(originalRequest);
        } catch (refreshErr) {
          // 刷新失败,清除session并跳转登录
          await signOut();
          return Promise.reject(refreshErr);
        }
      }
    }
    return Promise.reject(err);
  }
);

export default api;

三、NestJS 后端实现

1. 安装依赖

npm install @nestjs/passport passport-google-oauth20 @nestjs/jwt passport-jwt cookie-parser csurf
npm install -D @types/passport-google-oauth20 @types/passport-jwt @types/cookie-parser

2. 配置环境变量

创建.env文件:

GOOGLE_CLIENT_ID=你的Google客户端ID
GOOGLE_CLIENT_SECRET=你的Google客户端密钥
GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback
JWT_SECRET=随机生成的JWT密钥
PORT=3001
FRONTEND_URL=http://localhost:3000(你的Next.js前端地址)

3. 配置Google OAuth策略

创建src/auth/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) {
    super({
      clientID: configService.get("GOOGLE_CLIENT_ID"),
      clientSecret: configService.get("GOOGLE_CLIENT_SECRET"),
      callbackURL: configService.get("GOOGLE_CALLBACK_URL"),
      scope: ["email", "profile"],
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: VerifyCallback
  ) {
    const user = {
      email: profile.emails[0].value,
      firstName: profile.name.givenName,
      lastName: profile.name.familyName,
      avatar: profile.photos[0].value,
      googleId: profile.id,
    };
    done(null, user);
  }
}

4. JWT认证配置

创建src/auth/jwt.strategy.ts:

import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get("JWT_SECRET"),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, email: payload.email };
  }
}

创建src/auth/jwt-auth.guard.ts:

import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

5. 认证服务与控制器

创建src/auth/auth.service.ts:

import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";

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

  async generateTokens(user: any) {
    const payload = { sub: user.email, email: user.email };
    const accessToken = this.jwtService.sign(payload, { expiresIn: "15m" });
    const refreshToken = this.jwtService.sign(payload, { expiresIn: "7d" });
    return { accessToken, refreshToken };
  }

  async validateRefreshToken(token: string) {
    try {
      return this.jwtService.verify(token);
    } catch (err) {
      return null;
    }
  }
}

创建src/auth/auth.controller.ts:

import { Controller, Get, UseGuards, Res, Req, Post } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { AuthService } from "./auth.service";
import { Response, Request } from "express";
import { ConfigService } from "@nestjs/config";

@Controller("auth")
export class AuthController {
  constructor(
    private authService: AuthService,
    private configService: ConfigService
  ) {}

  @Get("google")
  @UseGuards(AuthGuard("google"))
  async googleAuth() {}

  @Get("google/callback")
  @UseGuards(AuthGuard("google"))
  async googleAuthRedirect(@Req() req, @Res() res: Response) {
    const { accessToken, refreshToken } = await this.authService.generateTokens(
      req.user
    );
    // 设置HttpOnly Cookie存储Refresh Token
    res.cookie("refresh_token", refreshToken, {
      httpOnly: true,
      secure: this.configService.get("NODE_ENV") === "production",
      sameSite: "strict",
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
    });
    // 重定向回客户端,并携带accessToken
    res.redirect(
      `${this.configService.get("FRONTEND_URL")}?accessToken=${accessToken}`
    );
  }

  @Post("refresh")
  async refreshToken(@Req() req: Request, @Res() res: Response) {
    const refreshToken = req.cookies["refresh_token"];
    if (!refreshToken) return res.status(401).send("Refresh token missing");

    const payload = await this.authService.validateRefreshToken(refreshToken);
    if (!payload) return res.status(401).send("Invalid refresh token");

    const newAccessToken = this.jwtService.sign(
      { sub: payload.sub, email: payload.email },
      { expiresIn: "15m" }
    );
    res.send({ accessToken: newAccessToken });
  }
}

6. 配置Auth模块

修改src/auth/auth.module.ts:

import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { GoogleStrategy } from "./google.strategy";
import { JwtStrategy } from "./jwt.strategy";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";

@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get("JWT_SECRET"),
      }),
      inject: [ConfigService],
    }),
    ConfigModule,
  ],
  providers: [GoogleStrategy, JwtStrategy, AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

7. 保护API端点

在需要保护的控制器中使用JwtAuthGuard:

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

@Controller("users")
export class UsersController {
  @Get("profile")
  @UseGuards(JwtAuthGuard)
  getProfile() {
    return { message: "受保护的用户资料", data: { email: "user@example.com" } };
  }
}

8. 启用Cookie解析与CSRF防护

修改src/main.ts:

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import * as cookieParser from "cookie-parser";
import * as csurf from "csurf";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors({
    origin: process.env.FRONTEND_URL,
    credentials: true,
  });
  app.use(cookieParser());
  // 启用CSRF防护,从Cookie获取令牌
  app.use(
    csurf({
      cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "strict",
      },
    })
  );
  // 暴露CSRF令牌给客户端
  app.use((req, res, next) => {
    res.cookie("XSRF-TOKEN", req.csrfToken());
    next();
  });
  await app.listen(process.env.PORT || 3001);
}
bootstrap();

四、令牌管理细节

1. 令牌生成

  • Access Token: 由NestJS签发,有效期15分钟,存储在客户端内存中,用于API请求认证
  • Refresh Token: 由NestJS签发,有效期7天,存储在HttpOnly Cookie中,用于刷新Access Token

2. 令牌验证

  • 后端通过JwtStrategy验证Access Token的签名和有效期,验证通过后才允许访问受保护API
  • 刷新令牌时,后端验证Refresh Token的有效性,验证通过后签发新的Access Token

3. 令牌续订

  • 客户端通过Axios拦截器检测401响应,自动触发刷新令牌流程
  • 刷新成功后更新内存中的Access Token,继续原请求;失败则清除登录状态,跳转登录页面

五、安全策略

  1. 强制HTTPS: 生产环境必须启用HTTPS,防止令牌被窃听
  2. 令牌存储:
    • Access Token仅存于内存,避免XSS攻击窃取
    • Refresh Token存储在HttpOnly、Secure、SameSite=Strict的Cookie中,防止JS读取和CSRF攻击
  3. CSRF防护: 启用CSRF中间件,客户端请求时携带XSRF-Token
  4. 输入验证: 所有API请求参数使用class-validator进行验证,防止注入攻击
  5. 环境变量: 敏感配置(如密钥、客户端ID)存储在环境变量中,不硬编码到代码

六、交互流程图

sequenceDiagram
    participant 用户
    participant Next.js客户端
    participant NestJS后端
    participant Google OAuth服务

    用户->>Next.js客户端: 点击Google登录按钮
    Next.js客户端->>Google OAuth服务: 重定向到授权页面(带client_id、scope)
    Google OAuth服务->>用户: 展示登录授权界面
    用户->>Google OAuth服务: 确认授权
    Google OAuth服务->>NestJS后端: 重定向到回调地址,携带授权code
    NestJS后端->>Google OAuth服务: 用code、client_id、client_secret请求令牌
    Google OAuth服务->>NestJS后端: 返回access_token、id_token、用户信息
    NestJS后端->>NestJS后端: 验证用户信息,生成自定义JWT令牌
    NestJS后端->>Next.js客户端: 设置HttpOnly Cookie存refresh_token,返回access_token
    Next.js客户端->>Next.js客户端: 存储access_token到内存,更新登录状态
    用户->>Next.js客户端: 访问受保护页面/发起API请求
    Next.js客户端->>NestJS后端: 携带Authorization: Bearer <access_token>请求API
    NestJS后端->>NestJS后端: 验证access_token有效性
    NestJS后端->>Next.js客户端: 返回API数据
    Note over Next.js客户端,NestJS后端: Access Token过期时
    Next.js客户端->>NestJS后端: 携带Cookie中的refresh_token请求刷新
    NestJS后端->>NestJS后端: 验证refresh_token有效性
    NestJS后端->>Next.js客户端: 返回新的access_token
    Next.js客户端->>Next.js客户端: 更新内存中的access_token,继续请求

内容的提问来源于stack exchange,提问作者Romel Gomez

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 21:25:53