Skip to content
On this page

基础篇-鉴权与登录


前言

统一的用户中心作为基础服务,为了方便团队同学使用,一般会将 OA 系统、钉钉、飞书、企业微信等等各种第三方常用服务的用户数据打通,使得团队成员可以快速登录。

DevOps 小册中,使用了 GitLab 作为三方应用授权,避免用户重复登录,飞书也提供了一样的三方授权能力。

在本章中,我们将学习使用 NestJS 的守卫模块结合之前封装过的飞书用户模块进行三方授权登录,并保存用户信息,为用户系统的业务开发做完最后一步的准备工作。

飞书对接

飞书应用第三方网站免登的步骤如下。

  1. 网页后端发现用户未登录,请求身份验证
  2. 用户登录后,开放平台生成登录预授权码,302跳转至重定向地址。
  3. 网页后端调用获取登录用户身份校验登录预授权码合法性,获取到用户身份。
  4. 如需其他用户信息,网页后端可调用获取用户信息(身份验证)

授权流程图如下所示:

image.png

接下来,我们按照步骤逐步实现飞书的三方授权

请求用户身份验证

第一步:开启网页能力并配置重定向链接。

image.png

如上图所示,点击网页菜单开启网页能力之后,在安全设置菜单中,添加回调 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_uristring重定向 URL(使用第一步配置的重定向 URL 即可)
app_idstring固定的应用标识,在应用后台【凭证和基础信息】中可见
statestring用来维护请求和回调状态的附加字符串, 在授权完成回调时会附加此参数,应用可以根据此字符串来判断上下文关系

所以对于我们的应用,请求身份的链接为:https://open.feishu.cn/open-apis/authen/v1/index?app_id=cli_xxxxxxd&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauth,在浏览器直接输入此链接如果出现如下的飞书授权界面,则代表我们已经正常配置成功了:

image.png

第三步:获取登录预授权码。这一步比较简单,正常出现飞书应用授权的界面之后,点击授权【按钮】即可获取到对应的登录预授权码。

image.png

出现上图的界面并不意外,毕竟这个链接是随便填写的,飞书并不会真的去校验这个链接是否真实存在。当我们点击授权之后,它会将登录预授权码放在重定向 URLcode 参数中直接转发,所以即使这个请求是假的,也能顺利拿到对应的 code

第四步:获取用户凭证。在这一步中,使用第三步获取到的登录预授权码,也就是重定向 URL Query 参数中的 code 向飞书换取真正的用户凭证,注意 code 的有效期只有 5 分钟,且只能使用一次,过期或已使用的 code 都无法再次换取真实用户凭证。

  1. 在 `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;
};
  1. 在 `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;
}
  1. 在 `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 等回调值。

image.png

之后可以将 access_token 的值缓存起来,使用 access_token 调用飞书提供的任意接口,但前提是这个应用拥有对应的模块接口权限才能够正常调用。

第五步: 刷新用户凭证。安全起见,飞书获取的 access_tokenrefresh_token 均存在有效期。access_token 的有效期为 2 小时,过期之前可以通过有效期更长的 refresh_token 缓存新的 access_token,来保证能够正常调用飞书接口。

  1. 在 `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;
};
  1. 在 `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 模块、PassportJWT 来完成登录模块的开发。

首选需要安装对应的依赖:

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.tsjwt-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.tsjwt-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 模块实现了 canActivatehandleRequest 的重写,分别是针对于自定义逻辑与异常捕获的处理。

因为我们使用了 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 { }

JwtModuleAuthModule 中注册,并将其他的 ControllerServices 等都导入,最后记得将 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 中:

image.png

第七步:一般来说,登录权限需要全局开启,只有少部分的接口通过白名单开放给外部使用,所以需要将 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 的信息,如果一切正常的话,则出现如下图界面:

image.png

写在最后

本章主要介绍了如何利用飞书的三方接口以及 NestJS 提供的 Guards 能力,使用 PassportJWT 来完成第三方应用授权的功能,减少用户的使用成本。

其中,我们学习了 Guards 模块、Passport 以及 JWT 的相关知识,有兴趣的同学可以与其他的框架如 Egg 的接入做一个对比,设计理念代码编写的不同可以从登录功能的实现中深刻体会到。

另外,本章并未过多的介绍 Guards 模块,NestJS 源文档对此的描述已经足够完备,想要了解更多的细节或者设计可以去源文档直接查阅,作为实战类型的小册,本章之后的主体内容将聚焦在如何完成业务开发上,不会再针对某一个模块功能做更详细的解释。

如果你有什么疑问,欢迎在评论区提出或者加群沟通。 👏