前言
实现谷歌登录功能是许多网络应用必备的一项功能,而 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数据库与你配置的表结构进行同步处理,对应的字段就会有了。
安装插件
注意该插件的文档和实际使用出入有点大,所以仅参考。
这里就不重复安装passport
和@nestjs/passport
依赖了,可以先去看一下之前的passport的文章。
谷歌开发者创建凭据
打开地址:https://console.cloud.google.com
创建一个新项目,注意是新项目。
打开新建的项目,点击API与服务,或者不用点击。
找到左侧的凭据,点击顶部创建凭据,选择 OAuth 客户端ID
应用类型选择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
客户端idclientSecret
客户端密匙callbackURL
登录成功后的重定向链接,重定向回来会携带query参数scope
需要的权限范围
其中clientID
和clientSecret
在创建的OAuth里面。
自己把他存到环境变量中去,或者写死也行。
scope
这个配置就有点难找了,你可以完全照搬,不过文档中也有说:
文档地址:OAuth 2 范围
我们找到Google OAuth2 API v2这个选项:
红线标出来的就是对应的配置字段。
我们只需要"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);
}
}
引入的UseGuards
和AuthGuard
用于声明局部守卫,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进行处理。这样用户体验才是正常的。
📮评论