NestJS 教程 | 使用 Passport-Google-OAuth20 实现谷歌登录插图

前言

实现谷歌登录功能是许多网络应用必备的一项功能,而 Nestjs 利用 passport-google-oauth20 这个库能够轻松实现这一目标。在网络世界中,谷歌登录已成为最为著名的第三方登录方式之一,几乎所有的 Web 应用都会提供谷歌登录的选项。

在之前的文章中,我们已经学习了如何使用 passport 的 jwt 策略来实现本地的 jwt 鉴权登录处理。Passport 本身就是一个实现了策略模式的库,通过调用不同的策略可以实现不同的功能。其中,谷歌登录就是其中之一。这大大简化了我们的开发过程。

需要注意的是,谷歌登录对网络的要求非常高。在我们国内的网络环境中,我尝试使用了 Windows 下的 Clash 进行网络代理,即使设置了全局规则,但 Nestjs 依然无法访问到谷歌的服务,导致出现了报错信息:”Failed to obtain access token”。最终,我通过路由器上的 OpenClash 解决了这个问题。如果你没有这样的设备,也可以通过手机热点的方式来解决,即手机开启网络代理后,通过连接手机热点来使电脑也能够访问谷歌服务。当然,还可能存在其他的解决办法,欢迎在评论区分享你的经验。

接下来,我们将开始具体的教程。

配置数据库表字段

谷歌登录后,我们可以获取到谷歌账号的一些信息,比如固定不变的id字段,以及用户的谷歌邮箱字段,当然我们还可以拿到用户的名字和昵称,但是这些不是很重要,最有用的就是id和email了,为此我们在用户的表结构上,加上这两个字段。

prisma的User配置:

schema.prisma

// 用户表
model User {
  id          Int        @id @default(autoincrement()) @db.UnsignedInt
  email       String     @unique @db.VarChar(64)
  password    String     @db.VarChar(255)
  nickname    String     @db.VarChar(64)
  avatar      String?    @db.VarChar(255)
  googleId    String?    @db.VarChar(64)
  googleEmail String?    @db.VarChar(64)
}

由于用户不是只能谷歌登录,所以这两个字段是可选。

配置完毕后我们运行migrate命令:

dotenv -e .env.development -- npx prisma migrate dev

指定使用.env.development环境变量文件。

然后输入本次改动内容:add google login,当然这个随便填就是了,看自己想法。

然后prisma就会将mysql数据库与你配置的表结构进行同步处理,对应的字段就会有了。

安装插件

pnpm i passport-google-oauth20
pnpm i @types/passport-google-oauth20 -D

注意该插件的文档和实际使用出入有点大,所以仅参考。

这里就不重复安装passport@nestjs/passport依赖了,可以先去看一下之前的passport的文章。

谷歌开发者创建凭据

打开地址:https://console.cloud.google.com

创建一个新项目,注意是新项目。

打开新建的项目,点击API与服务,或者不用点击。

找到左侧的凭据,点击顶部创建凭据,选择 OAuth 客户端ID

NestJS 教程 | 使用 Passport-Google-OAuth20 实现谷歌登录插图1

应用类型选择web引用,自己取个名称,添加已获授权的 JavaScript 来源的链接地址,实际上就是你需要使用谷歌登录的域名,比如我的域名是www.ceshi.com,那么我就填:

https://www.ceshi.com

如果你是本地测试用

http://localhost

不能使用ip地址就很遗憾的,不过也有办法,比如自定义DNS跳转,这个自己去研究吧。

接着添加已获授权的重定向 URI,这个就是当用户在网页点击并谷歌登录完成后,重定向回来的连接,比如比较常见的:

https://www.ceshi.com/auth/google/callback

点击创建,如果不对,后续还可以重新编辑。

谷歌登录的流程

用户访问Nestjs中控制器定义的Get请求地址,通过passport的策略,会重定向到谷歌登录页面,用户进行登录后,谷歌比对在已获授权的重定向 URI的配置中的网址,与Nestjs中策略传递的callbackURL参数是否有相同的,有的话就重定向到这个参数地址,并在url后面携带对应的query参数。

我们再将query参数传递到另一个api地址上,通过passport的策略解析,得到谷歌用户信息,然后就可以自己自定义操作了,比如关联用户信息之类的。

如果已获授权的重定向 URI的配置中的网址,与Nestjs中策略传递的callbackURL参数没有相同的,就报错。

实现谷歌登录策略

创建文件:google.strategy.ts

import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-google-oauth20";
import type { VerifyCallback } from "passport-google-oauth20";

export const GOOGLE_STRATEGY = "google";

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, GOOGLE_STRATEGY) {
    constructor(private readonly config: ConfigService) {
        super({
            clientID: config.get("GOOGLE_CLIENT_ID"),
            clientSecret: config.get("GOOGLE_CLIENT_SECRET"),
            callbackURL: config.get("GOOGLE_REDIRECT_URL"),
            scope: ["email", "profile"]
        });
    }

    async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise<any> {
        done(null, profile);
    }
}

我们创建了一个常量用于标识该策略的名称,这个后续通过passport的守卫函数调用的时候需要用到。

然后实现了两个方法,一个是构造函数constructor,它通过依赖注入获取到config服务,通过config服务获取到环境变量中配置的数据。

  • clientID 客户端id
  • clientSecret 客户端密匙
  • callbackURL 登录成功后的重定向链接,重定向回来会携带query参数
  • scope 需要的权限范围

其中clientIDclientSecret在创建的OAuth里面。

NestJS 教程 | 使用 Passport-Google-OAuth20 实现谷歌登录插图2

自己把他存到环境变量中去,或者写死也行。

scope这个配置就有点难找了,你可以完全照搬,不过文档中也有说:

文档地址:OAuth 2 范围

我们找到Google OAuth2 API v2这个选项:

NestJS 教程 | 使用 Passport-Google-OAuth20 实现谷歌登录插图3

红线标出来的就是对应的配置字段。

我们只需要"email", "profile"就够了。

app模块注入策略

在全局注入服务。

import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { GoogleStrategy } from "@/common/passport";


const isDev = process.env.NODE_ENV === "development";

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true,
            envFilePath: [isDev ? ".env.development" : ".env.production"]
        }),
    ],
    controllers: [AppController],
    providers: [
        GoogleStrategy,
        AppService,
    ]
})
export class AppModule {}

局部使用

由于谷歌登录是一个部分api使用的功能,此时我们就不需要将其作为一个全局守卫来使用了。

找到需要使用的控制器文件,如下使用:

import { Controller, Get, UseGuards, Req } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { AuthGuard } from "@nestjs/passport";
import { GOOGLE_STRATEGY } from "@/common/passport";
import type { GoogleLoginData } from "@/common/types";

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

    /** Google登录 */
    @Get("google")
    @UseGuards(AuthGuard(GOOGLE_STRATEGY))
    googleLogin() {
        /* TODO document why this method 'googleLogin' is empty */
    }

    /** Google登录回调 */
    @Get("google/callback")
    @UseGuards(AuthGuard(GOOGLE_STRATEGY))
    googleCallback(@Req() req) {
        const user: GoogleLoginData = req.user;
        return this.authService.googleLogin(user);
    }
}

引入的UseGuardsAuthGuard用于声明局部守卫,GOOGLE_STRATEGY则是策略的名字,上面也有说。

我们先通过浏览器请求get地址:http://localhost:3000/auth/google,此时重定向到谷歌登录

谷歌登录后又重定向回来:http://localhost:3000/auth/google/callback,此时链接上存在对应的参数,经过守卫处理后,会在req对象上赋值user属性,这个user就是对应的谷歌用户信息了。

然后我调用了authService.googleLogin,将获取到的谷歌用户信息传入,然后服务去生成token。

GoogleLoginData是我自定义的类型声明,大家自己根据实际返回的内容定义。

到这里基本上就完成了,但是我在给大伙分享点自定义处理。

自定义谷歌登录守卫

由于我设计的数据库中,只会存在一个账号,就是管理员账号,所以谷歌登录后我希望它直接就关联已存在的用户,然后我希望在调用谷歌登录的时候会有一个校验操作,如果不存在一个用户,或者用户量过多,都进行报错处理。

但是这些功能,策略本身病没有提供,所以我们可以像之前jwt鉴权一样,自己继承并自定义。

创建守卫:google.guard.ts

import { GOOGLE_STRATEGY } from "@/common/passport";
import { PrismaService } from "@/modules/prisma/prisma.service";
import { BadRequestException, ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import type { User } from "@prisma/client";

@Injectable()
export class GoogleGuard extends AuthGuard(GOOGLE_STRATEGY) {
    /** 数据库用户数据 */
    private dbUser: User = {
        id: 0,
        email: "",
        password: "",
        nickname: "",
        avatar: "",
        googleId: "",
        googleEmail: "",
        createdAt: undefined,
        updatedAt: undefined,
        deletedAt: undefined
    };

    constructor(private readonly prisma: PrismaService) {
        super();
    }

    async canActivate(context: ExecutionContext) {
        // 查询是否存在一个用户
        const findCount = await this.prisma.user.count();
        if (!Number.isFinite(findCount) || findCount <= 0) {
            throw new BadRequestException("用户不存在或者用户数量不在合法范围内!");
        }
        if (findCount > 1) {
            throw new BadRequestException("用户只能存在一位,请检查用户表用户是否超出!");
        }
        // 获取用户数据
        this.dbUser = await this.prisma.user.findFirst();

        return (await super.canActivate(context)) as boolean;
    }

    handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser {
        const data = super.handleRequest(err, user, info, context, status);
        data.dbUser = this.dbUser;
        return data;
    }
}

canActivate中查询用户表,如果数量不是1个就进行报错处理。

如果确实是一个,我们将用户信息保存起来,因为handleRequest方法只能是同步的,所以没法在这里进行prisma的异步查询。

canActivate会在用户访问:http://localhost:3000/auth/google时运行,handleRequest则是在访问http://localhost:3000/auth/google/callback时运行。

为了方便,我将查询的数据库用户信息也存放在data中了,这个data就是req.user的值。

然后我们再去控制器使用:

import { Controller, Get, UseGuards, Req } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { GoogleGuard } from "@/common/guards";
import type { GoogleLoginData } from "@/common/types";

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

    /** Google登录 */
    @Get("google")
    @UseGuards(GoogleGuard)
    googleLogin() {
        /* TODO document why this method 'googleLogin' is empty */
    }

    /** Google登录回调 */
    @Get("google/callback")
    @UseGuards(GoogleGuard)
    googleCallback(@Req() req) {
        const user: GoogleLoginData = req.user;
        return this.authService.googleLogin(user);
    }
}

这种方式相对来说使用简洁一些。

服务具体如何生成token,这个之前文章就有分享,就不重复赘述了,直接放个简略代码:

import { Injectable } from "@nestjs/common";
import { PrismaService } from "@/modules/prisma/prisma.service";
import type { GoogleLoginData } from "@/common/types";

@Injectable()
export class AuthService {
    constructor(
        private readonly prisma: PrismaService,
    ) {}


    /** 谷歌登录 */
    async googleLogin(data: GoogleLoginData) {
        const { id, emails } = data;
        let { dbUser } = data;

        // 更新用户数据
        if (!dbUser.googleId && !dbUser.googleEmail) {
            dbUser = await this.prisma.user.update({
                where: {
                    id: dbUser.id
                },
                data: {
                    googleId: id,
                    googleEmail: emails[0].value
                }
            });
        }

        return this.generateDoubleToken(dbUser);
    }

}

实战扩展

在真正的项目中,我们谷歌登录成功肯定不能直接重定向到Google登录回调的api地址,毕竟是前后端分离了,后端只会返回api数据,不会去种cookie这些了,生成的token数据也需要前端自己取出后存在本地缓存中。

所以正确的做法就是前端定义一个用来重定向处理的路由,比如前端路由:https://xxxx.com/google,在已获授权的重定向 URI中将该url地址添加上。

后端的callbackURL也配置成该地址。

此时用户登录后重定向到的还是前端spa页面,前端就可以从链接上取出query参数,然后通过axios的get请求,请求后端定义的Google登录回调的api地址,效果和谷歌直接重定向到该地址是一样的,甚至我们还能携带额外的参数,并不会影响到谷歌登录策略。

然后api再返回token,前端拿到token进行处理。这样用户体验才是正常的。