Appearance
基础篇-鉴权与登录
前言
统一的用户中心作为基础服务,为了方便团队同学使用,一般会将 OA 系统、钉钉、飞书、企业微信等等各种第三方常用服务的用户数据打通,使得团队成员可以快速登录。
在 DevOps 小册中,使用了 GitLab 作为三方应用授权,避免用户重复登录,飞书也提供了一样的三方授权能力。
在本章中,我们将学习使用 NestJS 的守卫模块结合之前封装过的飞书用户模块进行三方授权登录,并保存用户信息,为用户系统的业务开发做完最后一步的准备工作。
飞书对接
飞书应用第三方网站免登的步骤如下。
- 网页后端发现用户未登录,请求身份验证;
- 用户登录后,开放平台生成登录预授权码,302跳转至重定向地址。
- 网页后端调用获取登录用户身份校验登录预授权码合法性,获取到用户身份。
- 如需其他用户信息,网页后端可调用获取用户信息(身份验证)。
授权流程图如下所示:
接下来,我们按照步骤逐步实现飞书的三方授权
请求用户身份验证
第一步:开启网页能力并配置重定向链接。
如上图所示,点击网页菜单开启网页能力之后,在安全设置菜单中,添加回调 URL 地址。这里我们使用的是 http://127.0.0.1:8080/auth,你可以根据自己的喜好来设定。
第二步:请求用户身份验证。
根据飞书的文档组装身份验证请求接口:https://open.feishu.cn/open-apis/authen/v1/index?redirect_uri={REDIRECT_URI}&app_id={APPID}&state={STATE} ,参数说明如下所示:
| 参数 | 类型 | 必须 | 说明 |
|---|---|---|---|
| redirect_uri | string | 是 | 重定向 URL(使用第一步配置的重定向 URL 即可) |
| app_id | string | 是 | 固定的应用标识,在应用后台【凭证和基础信息】中可见 |
| state | string | 否 | 用来维护请求和回调状态的附加字符串, 在授权完成回调时会附加此参数,应用可以根据此字符串来判断上下文关系 |
所以对于我们的应用,请求身份的链接为:https://open.feishu.cn/open-apis/authen/v1/index?app_id=cli_xxxxxxd&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauth,在浏览器直接输入此链接如果出现如下的飞书授权界面,则代表我们已经正常配置成功了:
第三步:获取登录预授权码。这一步比较简单,正常出现飞书应用授权的界面之后,点击授权【按钮】即可获取到对应的登录预授权码。
出现上图的界面并不意外,毕竟这个链接是随便填写的,飞书并不会真的去校验这个链接是否真实存在。当我们点击授权之后,它会将登录预授权码放在重定向 URL 的 code 参数中直接转发,所以即使这个请求是假的,也能顺利拿到对应的 code。
第四步:获取用户凭证。在这一步中,使用第三步获取到的登录预授权码,也就是重定向 URL Query 参数中的 code 向飞书换取真正的用户凭证,注意 code 的有效期只有 5 分钟,且只能使用一次,过期或已使用的 code 都无法再次换取真实用户凭证。
在 `src/helper/feishu/auth.ts` 中添加新的换取用户凭证方法:
ts
export const getUserToken = async ({ code, app_token }) => {
const { data } = await methodV({
url: `/authen/v1/access_token`,
method: 'POST',
headers: {
Authorization: `Bearer ${app_token}`,
},
params: {
grant_type: 'authorization_code',
code,
},
});
return data;
};
在 `src/user/feishu/feishu.service.ts` 中添加新的换取用户凭证的 `Service`:
ts
async getUserToken(code: string) {
const app_token = await this.getAppToken()
const dto: GetUserTokenDto = {
code,
app_token
};
const res: any = await getUserToken(dto);
if (res.code !== 0) {
throw new BusinessException(res.msg);
}
return res.data;
}
在 `src/user/feishu/feishu.controller.ts` 中添加新的换取用户凭证的 `Controller`:
ts
@Public()
@ApiOperation({
summary: '获取用户凭证',
})
@Post('getUserToken')
getUserToken(@Body() params: GetUserTokenDto) {
const { code } = params
return this.feishuService.getUserToken(code);
}
打开 Swagger 调试 getUserToken 接口,将第三步获取的临时登录凭证输入参数调试。如果配置正常的话,此时可以拿到飞书的用户信息和真实的用户凭证 access_token,以及 refresh_token 等回调值。
之后可以将 access_token 的值缓存起来,使用 access_token 调用飞书提供的任意接口,但前提是这个应用拥有对应的模块接口权限才能够正常调用。
第五步: 刷新用户凭证。安全起见,飞书获取的 access_token 和 refresh_token 均存在有效期。access_token 的有效期为 2 小时,过期之前可以通过有效期更长的 refresh_token 缓存新的 access_token,来保证能够正常调用飞书接口。
在 `src/helper/feishu/auth.ts` 中新增刷新用户 `access_token` 方法:
ts
export const refreshUserToken = async ({ refreshToken, app_token }) => {
const { data } = await methodV({
url: `/authen/v1/refresh_access_token`,
method: 'POST',
headers: {
Authorization: `Bearer ${app_token}`,
},
params: {
grant_type: 'refresh_token',
refresh_token: refreshToken,
app_token,
},
});
return data;
};
在 `src/user/feishu/feishu.service.ts` 中添加**刷新**、**存储**、**读取** `access_token` 的 `Service`:
ts
async setUserCacheToken(tokenInfo: any) {
const {
refresh_token,
access_token,
user_id,
expires_in,
refresh_expires_in,
} = tokenInfo;
// 缓存用户的 token
await this.cacheManager.set(`${this.USER_TOKEN_CACHE_KEY}_${user_id}`, access_token, {
ttl: expires_in - 60,
});
// 缓存用户的 fresh token
await this.cacheManager.set(
`${this.USER_TOKEN_CACHE_KEY}_${user_id}`,
refresh_token,
{
ttl: refresh_expires_in - 60,
},
);
}
async getCachedUserToken(userId: string) {
let userToken: string = await this.cacheManager.get(
`${this.USER_TOKEN_CACHE_KEY}_${userId}`,
);
// 如果 token 失效
if (!userToken) {
const refreshToken: string = await this.cacheManager.get(
`${this.USER_TOKEN_CACHE_KEY}_${userId}`,
);
if (!refreshToken) {
throw new BusinessException({
code: BUSINESS_ERROR_CODE.TOKEN_INVALID,
message: 'token 已失效',
});
}
// 获取新的用户 token
const usrTokenInfo = await this.getUserTokenByRefreshToken(refreshToken);
// 更新缓存的用户 token
await this.setUserCacheToken(usrTokenInfo);
userToken = usrTokenInfo.access_token;
}
return userToken;
}
async getUserTokenByRefreshToken(refreshToken: string) {
return await refreshUserToken({
refreshToken,
app_token: await this.getAppToken(),
});
}
根据方法名可以清晰地知道对应的功能,我就不过多介绍了。至此,飞书应用的三方授权模块对接完毕。
鉴权与登录
上述步骤只是对接了飞书应用,还不足够完成登录态。接下来,我们要借助 NestJS 提供的 Guards 模块、Passport 与 JWT 来完成登录模块的开发。
首选需要安装对应的依赖:
shell
$ yarn add @nestjs/passport passport
$ yarn add -D @types/passport-local
$ yarn add @nestjs/jwt passport-jwt
$ yarn add fastify-cookie
第一步:新建 auth.service.ts。
ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { FeishuUserInfo } from 'src/user/feishu/feishu.dto';
import { FeishuService } from 'src/user/feishu/feishu.service';
import { User } from '@/user/user.mongo.entity';
import { UserService } from 'src/user/user.service';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private userService: UserService,
private feishuService: FeishuService,
) { }
// 验证飞书用户
async validateFeishuUser(code: string): Promise<Payload> {
const feishuInfo: FeishuUserInfo = await this.getFeishuTokenByApplications(
code,
);
// 将飞书的用户信息同步到数据库
const user: User = await this.userService.createOrUpdateByFeishu(
feishuInfo,
);
return {
userId: user.id,
username: user.username,
name: user.name,
email: user.email,
feishuAccessToken: feishuInfo.accessToken,
feishuUserId: feishuInfo.feishuUserId,
};
}
// jwt 登录
async login(user: Payload) {
return {
access_token: this.jwtService.sign(user),
};
}
// 获取飞书用户信息
async getFeishuTokenByApplications(code: string) {
const data = await this.feishuService.getUserToken(code);
const feishuInfo: FeishuUserInfo = {
accessToken: data.access_token,
avatarBig: data.avatar_big,
avatarMiddle: data.avatar_middle,
avatarThumb: data.avatar_thumb,
avatarUrl: data.avatar_url,
email: data.email,
enName: data.en_name,
mobile: data.mobile,
name: data.name,
feishuUnionId: data.union_id,
feishuUserId: data.user_id,
};
return feishuInfo;
}
}
上述代码中分为两个模块,一个是获取飞书用户信息以及对获取到的用户信息本地落库,另外一个是调用 jwtService 进行登录。
第二步:新建 /src/auth/strategies 目录,添加 feishu-auth.strategy.ts 与 jwt-auth.strategy.ts 两个文件:
ts
// feishu-auth.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, Query, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { Strategy } from 'passport-custom';
import { FastifyRequest } from 'fastify'
@Injectable()
export class FeishuStrategy extends PassportStrategy(Strategy, 'feishu') {
constructor(private authService: AuthService) {
super();
}
async validate(req: FastifyRequest): Promise<Payload> {
const q: any = req.query;
const user = await this.authService.validateFeishuUser(q.code as string);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
ts
// jwt-auth.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { jwtConstants } from '../constants';
import { FastifyRequest } from "fastify";
const cookieExtractor = function (req: FastifyRequest) {
let token = null;
if (req && req.cookies) {
token = req.cookies['jwt'];
}
return token;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: cookieExtractor,
ignoreExpiration: jwtConstants.ignoreExpiration,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: Payload): Promise<Payload> {
return { ...payload };
}
}
FeishuStrategy 根据 passport 提供的方法,自定义了飞书的专属策略,调用 authService 中的 validateFeishuUser 方法,从飞书获取对应的用户信息。JwtStrategy 则是使用 passport-jwt拓展的功能,对 cookie 做了拦截、解密等功能。
注意无论是使用 passport 自带的三方功能或者自行拓展 passport,都需要对 validate 方法进行重写以便实现自己的业务逻辑。
第三步:新建 /src/auth/guards 目录,添加 feishu-auth.guard.ts 与 jwt-auth.guard.ts 两个文件:
ts
// feishu-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class FeishuAuthGuard extends AuthGuard('feishu') { }
这里要注意,因为 FeishuAuthGuard 已经继承了通用的 AuthGuard,验证逻辑在 FeishuStrategy 实现了,所以并没有额外的代码出现,如果有其他的逻辑则需要对不同的方法进行重写已完成需求。
ts
// jwt-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { BUSINESS_ERROR_CODE } from '@/common/exceptions/business.error.codes';
import { BusinessException } from '@/common/exceptions/business.exception';
import { IS_PUBLIC_KEY } from '../constants';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const loginAuth = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (loginAuth) {
return true;
}
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
throw (
err ||
new BusinessException({
code: BUSINESS_ERROR_CODE.TOKEN_INVALID,
message: 'token 已失效',
})
);
}
return user;
}
}
JwtAuthGuard 模块实现了 canActivate 与 handleRequest 的重写,分别是针对于自定义逻辑与异常捕获的处理。
因为我们使用了 JwtAuthGuard 作为全局验证,但有的时候也是需要针对于部分接口开启白名单。例如,登录接口就需要开启白名单,毕竟把登录接口也拦截了,整个项目就无法正常使用了。
第四步:在第二步中,FeishuStrategy 将获取到的飞书用户信息返回出来,被路由守卫挂载到 request 上,用户信息里面的内容会在后期频繁使用到,所以我们自定义一个用户的装饰器 PayloadUser,方便后期使用。
ts
export const PayloadUser = createParamDecorator(
(data, ctx: ExecutionContext): Payload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
第五步:新建 src/auth/auth.controller.ts。
ts
import {
Controller,
Post,
UseGuards,
Body,
Res,
Get,
Query,
Req,
Response
} from '@nestjs/common';
import { FeishuAuthGuard } from './guards/feishu-auth.guard';
import { AuthService } from './auth.service';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { GetTokenByApplications, LoginDto } from './auth.dto';
import { Public } from './constants';
import { PayloadUser } from '@/helper';
import { FeishuService } from 'src/user/feishu/feishu.service';
import { FastifyReply } from 'fastify'
@ApiTags('用户认证')
@Controller('auth')
export class AuthController {
constructor(
private authService: AuthService,
private readonly feishuService: FeishuService,
) { }
@ApiOperation({
summary: '飞书 Auth2 授权登录',
description: '通过 code 获取`access_token`https://open.feishu.cn/open-apis/authen/v1/index?app_id=cli_xxxxxx&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauth',
})
@UseGuards(FeishuAuthGuard)
@Public()
@Get('/feishu/auth2')
async getFeishuTokenByApplications(
@PayloadUser() user: Payload,
@Res({ passthrough: true }) response: FastifyReply,
@Query() query: GetTokenByApplications,
) {
const { access_token } = await this.authService.login(user);
response.setCookie('jwt', access_token);
return access_token
}
@ApiOperation({
summary: '解析 token',
description: '解析 token 信息',
})
@Get('/token/info')
async getTokenInfo(@PayloadUser() user: Payload) {
return user;
}
}
在 getFeishuTokenByApplications 方法中我们使用了 @UseGuards(FeishuAuthGuard) 与 @Public() 两个装饰器,分别是飞书应用授权拦截与开启接口白名单。
在经过了 @UseGuards(FeishuAuthGuard) 之后,可以使用 @PayloadUser 获取到的飞书用户信息,再将用户信息进行 JWT 注册。
第六步:新建 /src/auth/auth.module.ts。
ts
import { Module } from '@nestjs/common';
import { UserModule } from 'src/user/user.module';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { AuthController } from './auth.controller';
import { FeishuStrategy } from './strategies/feishu.strategy';
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: jwtConstants.expiresIn },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, FeishuStrategy],
exports: [AuthService],
})
export class AuthModule { }
将 JwtModule 在 AuthModule 中注册,并将其他的 Controller、Services 等都导入,最后记得将 AuthModule 导入 app.module.ts。
/src/auth/constants.ts 的内容如下:
import { SetMetadata } from '@nestjs/common';
export const jwtConstants = {
secret: 'yyds', // 秘钥,不对外公开。
expiresIn: '15s', // 时效时长
ignoreExpiration: true, // 是否忽略 token 时效
};
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
将飞书应用对接中获取的临时登录凭证填入 Swagger 测试接口中执行,如下图所示,JWT Token 已经正常返回了,并且被 NestJS 后端注入到 cookie 中:
第七步:一般来说,登录权限需要全局开启,只有少部分的接口通过白名单开放给外部使用,所以需要将 JWT 的自定义路由挂载到全局,修改 app.module.ts,添加全局 APP_GUARD 模块。
javascript
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [
CacheModule.register({
isGlobal: true,
store: redisStore,
host: getConfig('REDIS_CONFIG').host,
port: getConfig('REDIS_CONFIG').port,
auth_pass: getConfig('REDIS_CONFIG').auth,
db: getConfig('REDIS_CONFIG').db
}),
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
load: [getConfig]
}),
AuthModule,
PageModule
],
controllers: [],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
在正常写入 JWT Token 以及添加全局 JWT 路由拦截后,可以通过 Swagger 中的 /token/info 接口来测试是否能正常解析 token 的信息,如果一切正常的话,则出现如下图界面:
写在最后
本章主要介绍了如何利用飞书的三方接口以及 NestJS 提供的 Guards 能力,使用 Passport 与 JWT 来完成第三方应用授权的功能,减少用户的使用成本。
其中,我们学习了 Guards 模块、Passport 以及 JWT 的相关知识,有兴趣的同学可以与其他的框架如 Egg 的接入做一个对比,设计理念与代码编写的不同可以从登录功能的实现中深刻体会到。
另外,本章并未过多的介绍 Guards 模块,NestJS 源文档对此的描述已经足够完备,想要了解更多的细节或者设计可以去源文档直接查阅,作为实战类型的小册,本章之后的主体内容将聚焦在如何完成业务开发上,不会再针对某一个模块功能做更详细的解释。
如果你有什么疑问,欢迎在评论区提出或者加群沟通。 👏