实现一个专注应用-后端开发(二)-微信扫码登录(小程序实现)

实现逻辑

调用接口 =》接口调用小程序的生成二维码 =〉二维码对应一个tag,把tag存在redis中,设置一分钟到期,用户扫码跳到小程序登录授权页面,授权后将生成的token和tag绑定,同时,展示二维码的页面,每隔几秒调用一次查询,过期告知过期,如果查到token则登录成功,登录成功销毁tag和token的绑定,如果想要知道用于已经扫码状态,可以多设计tag的状态。

为什么要扫码登录呢,主要是短信收费哇,账户密码登录的话,又太多了,就想试试微信小程序扫码登录。

公用配置模块

有些时候我们会有很多配置项,比如小程序的id,secret,以及其它配置项目,如果每次都写一遍很麻烦,而且改起来也很费事,所以在下一步开发前,我们先来搞配置项。

nest g lib config

一路回车即可

在config/src/下新建configs目录,分开存储不同的配置
目前目录
在这里插入图片描述

引入@nestjs/config

安装

npm install @nestjs/config --save-dev

在config.module.ts中

import { Global, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigModule as ConfigMd } from '@nestjs/config';
import configuration from './configs/index';

@Global()
@Module({
  imports: [
    ConfigMd.forRoot({
      isGlobal: true,
      load: [configuration], // 加载配置文件
    }),
  ],
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}

在config.service.ts中

export { ConfigService } from '@nestjs/config';

在configs/index.ts中

import weapp from './weapp';

export default () => {
  const config = {
    weapp,
  };
  return config;
};

在weapp.ts中

export default {
  appid: 'xxxx',// 小程序后台可以拿到
  secret: 'xxx', // 小程序后台可以拿到
  login_path:"pages/login/index", // 登录页面路径
  env_version:"develop" // 生成的小程序二维码的版 体验/开发/正式
};

env_version: “develop” //开发版

env_version: “trial” //体验版

env_version: “release” //正式版

appid和secret在小程序后台可以拿到

这个时候config就可以在其它模块使用了

如果生成二维码失败 可能考虑要先发一版

使用配置模块

在apps/user/src/user.module.ts中我们引入配置模块
由于等候会用到prisma和redis模块我们一并引入

import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { ConfigModule } from '@app/config';
import { RedisModule } from '@app/redis';
import { PrismaModule } from '@app/prisma';

@Module({
  imports: [ConfigModule, RedisModule, PrismaModule],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

生成小程序二维码

生产二维码需要调用微信的api,需要先获取授权。
在这里插入图片描述
可以在小程序后台拿到这后面两个参数
在这里插入图片描述

获取授权信息

我们在user.service.ts中引入redis和config

import { ConfigService } from '@app/config';
import { RedisService } from '@app/redis';
import { Inject, Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  @Inject(RedisService)
  private redisService: RedisService;
  @Inject(ConfigService)
  private configService: ConfigService;

  
}

获取授权的逻辑是 首先看看redis有没有token缓存,有的话取token否则重新授权。获取授权需要调用网络请求。为了方便我们直接用axios,如果axios用的地方多,统一化,可以再封装一个axios请求模块,这里我们简单使用,如果想搞个axios模块,参考redsi或者config的。
安装axios

npm install axios --save-dev
import { ConfigService } from '@app/config';
import { RedisService } from '@app/redis';
import { Inject, Injectable } from '@nestjs/common';
import axios from 'axios';

@Injectable()
export class UserService {
  @Inject(RedisService)
  private redisService: RedisService;
  @Inject(ConfigService)
  private configService: ConfigService;

  async getWxAuthToken() {
    let weapp_token = await this.redisService.get('weapp_token');
    if (!weapp_token) {
      const appid = this.configService.get('weapp.appid');
      const secret = this.configService.get('weapp.secret');

      const res = await axios.get(
        `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${secret}`,
      );

      const { access_token, expires_in } = res.data;
      await this.redisService.set('weapp_token', access_token, expires_in/2);
      weapp_token = access_token;
    }

    return weapp_token;
  }
}

在user.controller.ts中 我们做个尝试

 @Get()
  async getWxToken() {
    return await this.userService.getWxAuthToken();
  }

启动用户服务 浏览器访问 http://localhost:你的项目端口

npm run start:dev user

如果我们观察请求授权的接口 发现有token和时间 我们只要token,但过期时间也可以用到我们的redis中。但是有可能你掉的时候刚好过期(几率小),所以过期时间我们可以缩小点
在这里插入图片描述

查看我们的redis数据库也发现有数据了
在这里插入图片描述

获取二维码

我们安装uuid 用来获取唯一tag

npm install uuid --save-dev

在user.service.ts中
我们编写代码 获取token 通过token去生成二维码
page 和 env_version就是刚刚我们设置的,如果小程序未上线,可能需要把check_path改为false,然后版本改为开发/体验。
scene呢 我们带着我们自己的tag 这个tag在redis中存一份,然后等着扫码可以用。

import { ConfigService } from '@app/config';
import { RedisService } from '@app/redis';
import { Inject, Injectable } from '@nestjs/common';
import axios from 'axios';
import { v4 as getUuid } from 'uuid';

@Injectable()
export class UserService {
  @Inject(RedisService)
  private redisService: RedisService;
  @Inject(ConfigService)
  private configService: ConfigService;

  async getWxAuthToken() {
    let weapp_token = await this.redisService.get('xxx_weapp_token');
    if (!weapp_token) {
      const appid = this.configService.get('weapp.appid');
      const secret = this.configService.get('weapp.secret');

      const res = await axios.get(
        `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${secret}`,
      );

      const { access_token, expires_in } = res.data;
      await this.redisService.set(
        'xxx_weapp_token',
        access_token,
        expires_in / 2,
      );
      weapp_token = access_token;
    }

    return weapp_token;
  }

  async getWxQrCode() {
    let weapp_token = await this.getWxAuthToken();
    const env_version = this.configService.get('weapp.env_version');
    const page = this.configService.get('weapp.login_path');
    const uuid = getUuid();
    const key = uuid.split('-').join('');
    const res = await axios.post(
      `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${weapp_token}`,
      {
        page,
        scene: key,
        check_path: true,
        env_version,
      },
      {
        responseType: 'arraybuffer',
      },
    );

     await this.redisService.set(
      `timeout_loginTag_xxx_${key}`,
      JSON.stringify({ status: 1 }),
      60,
    );
    return { file: res.data, key };
  }
}

我们修改user.controller.ts 返回一个文件 在响应头中带了一个tag-key方便我们轮询查扫码结果。

import { Controller, Get, Res } from '@nestjs/common';
import { UserService } from './user.service';

@Controller()
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get('/wxQrCode')
  async getWxQrCode(@Res() response) {
    const { file, key } = await this.userService.getWxQrCode();
    response.setHeader('Content-Type', 'image/png');
    response.setHeader(
      'Content-Disposition',
      'attachment; filename=qrcode.png',
    );
    response.setHeader('tag-key', key);

    response.send(file);
  }
}

在apifox中查看效果 可以看到返回了二维码

在这里插入图片描述
在这里插入图片描述

扫码登录处理

在微信开发者工具是可以调试二维码的
在这里插入图片描述
选择二维码后
再次点击添加编译模式 可以看到参数
在这里插入图片描述
拿到这个参数
这里用的uni-app如果用别的如taro都行的,下载hbuilder-x新建一个vue3项目,修改manifest.json的微信小程序id,然后新建一个login页面。
搞好后 你pages.json会有一个
在这里插入图片描述
文件目录
在这里插入图片描述
在login页面编写代码 编译到小程序

<template>
	<view>
		
	</view>
</template>

<script setup lang="ts">
const props = defineProps({
    scene: String,
  });
console.log("scene=" + props.scene);

</script>

<style>

</style>

可以看到控制台打印
在这里插入图片描述

这个时候我们可以来做登录了。

登录

首先我们要调用小程序登录,拿到一些信息。
小程序登录流程
封装请求我们用之前的一个包装方法
记录封装一个请求包装函数

除了包装以外,我们还要做个拦截器
小程序项目 根目录新建api文件夹

uni.addInterceptor做拦截处理,处理请求和响应还有其它

具体使用看 拦截器

api/getRequest.ts

const getRequest = <O, T>(options : any) => {
	let abort : () => void;
	const sendFetch = (data : O) : Promise<T> => {
		if (abort) {
			abort()
		}
		return new Promise((resolve, reject) => {
			const req = uni.request({
				...options,
				data: data,
				success: (res : { data : T ,statusCode:number,message?:string}) => {
					// 简单处理 实际要根据业务来处理 拆离异常处理逻辑
					if(res.statusCode>300){
						// @ts-ignore
						reject(res.data.message)
					}
					resolve(res.data as T)
				},
				fail: (error : any) => {
					
					reject(error)
				}
			}) as UniApp.RequestTask;
			abort = () => {
				console.warn('abort request' + options.url)
				req?.abort()
			};
		})
	}
	return {
		sendFetch,
		abort
	}
}

const debug = false;

uni.addInterceptor('request', {
	invoke(args) {
		// request 触发前拼接 url
		args.url = 'http://localhost:3000/' + args.url;
		if (debug) {
			console.log('send req' + args.url, args.data)
		}
	},
	success(args) {
		if (debug) {
			console.log('send res' , args.data)
		}
	},
	fail(err) {
		console.warn(err)
	},
	complete(res) {
		// console.log('interceptor-complete', res)
	}
})

export default getRequest

login.vue

<template>
	<view class="flex">
		<button type="primary" @click="login">登录</button>
	</view>
</template>

<script setup lang="ts">
import { watch } from 'vue';
import { loginByWx } from '../../api/interface/user';
import { useRequest } from '../../uni_modules/d-hooks/js_sdk/d-hooks';

const props = defineProps({
    scene: String,
  });
console.log("scene=" + props.scene);

const { data, fetch, loading, message } = useRequest(loginByWx())
watch(loading,v=>{
	// console.log(data.value)
	if(!success.value){
		uni.showToast({
			icon:'error',
			title:message.value
		})
	}
	if(v){
		uni.showLoading({
			title:'登录中'
		})
	}else{
		uni.hideLoading()
	}
})


const login = () => {
	uni.login({
		provider:'weixin',
		success(res) {
			const code = res.code;
			fetch({code,scene:props.scene})
			
		}
	})
}



</script>

<style lang="scss">
.flex{
	display: flex;
	justify-content: center;
	align-items: center;
	width: 100%;
	height: 100vh;
	button{
		width: 60%;
	}
}
</style>

api/user.ts

import getRequest from "../getRequest";

interface LoginWxParams {
	code:string,
	scene:string
}

export const loginByWx = () =>getRequest<LoginWxParams,any>({
	url:'loginWx',
	method:'post'
})

这个时候我们发起登录 会把微信授权code给到后端接口

后端处理

处理微信登录流程如下,通过code获取openId这个openId对应我们之前设计的数据表中的wxId,我们通过wxId去查用户,查不到就创建并返回非敏感用户数据,如userId,查到了同上返回。然后我们拿用户的一些数据生成JWT。同时我们也要知道tag,毕竟要知道是哪个二维码登录。

这里的判空 我们做个简单处理
在这里插入图片描述


@Post('loginWx')
  async loginWx(@Body() data: { code: string }) {
    if (!data.code || !data.scene) {
      throw new BadGatewayException('code | scene is required');
    }
    await this.userService.checkTagKey(data.scene);
	const openId = await this.userService.getWxOpenId(data.code);
}

在user.service.ts中 新增获取微信openId逻辑

和之前的获取微信授权token类似,这里我们也是先获取配置,然后调用微信的接口来获取openId。

  async getWxOpenId(code: string) {
    try {
      const appid = this.configService.get('weapp.appid');
      const secret = this.configService.get('weapp.secret');
      const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`;
      const res = await axios.get(url);
      const data = res.data;
      return data.openid;
    } catch (error) {
      throw new BadGatewayException(error);
    }
  }

我们需要校验二维码是否过期
在这里插入图片描述
user.service.ts中新增

async checkTagKey(key: string) {
    const checkKey = await this.redisService.get(`timeout_loginTag_xxx_${key}`);
    const canUse = !!checkKey;
    if (!canUse) {
      throw new BadGatewayException('二维码已过期');
    }
    return checkKey;
  }
 async createUserByWxId(wxId: string) {
    try {
      const username = 'wx' + getUuid();
      const user = await this.prismaService.user.create({
        data: {
          wxId,
          username,
        },
      });
      return user;
    } catch (error) {
      throw new BadGatewayException(error);
    }
  }
  async getUserByWxId(wxId: string) {
    return await this.prismaService.user.findFirst({
      where: {
        wxId,
      },
    });
  }

如果tag没有过期,我们会继续走拿到openId,使用openId来查数据库的用户,如果没有就创建一个。
此时的user.controller.ts的登录接口

 @Post('loginWx')
  async loginWx(@Body() data: { code: string; scene: string }) {
    if (!data.code || !data.scene) {
      throw new BadGatewayException('code | scene is required');
    }
    await this.userService.checkTagKey(data.scene);
    const openId = await this.userService.getWxOpenId(data.code);
    let user = await this.userService.getUserByWxId(openId);
    if (!user) {
      user = await this.userService.createUserByWxId(openId);
    }
  }

接下来我们来集成JWT还有校验。

集成JWT

首先在config中新建一个配置模块auth并且在configs/index中引入。
auth.ts 来配置jwt token和过期时间

export default {
  jwt: {
    token: 'xxxx',
    time: 7 * 24 * 60 * 60,
  },
};

在index.ts中引入

import auth from './auth';
import weapp from './weapp';

export default () => {
  const config = {
    weapp,
    auth,
  };
  return config;
};

我们来安装几个依赖包

npm install @nestjs/jwt passport-jwt @nestjs/passport --save-dev

新建一个库

nest g lib auth

在auth中做所有的授权处理,这次集成微信登录走jwt,下次比如谷歌登录,github登录等等都可以在这个库中完善。

在auth.module.ts中引入ConfigModule,JwtModule,PrismaModule,RedisModule,JwtStrategy
JwtStrategy在下方可见

  import { Global, Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ConfigModule } from '@app/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { PrismaModule } from '@app/prisma';
import { RedisModule } from '@app/redis';

@Global()
@Module({
  imports: [ConfigModule, JwtModule, PrismaModule, RedisModule],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

在auth.service.ts中

import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  @Inject(JwtService)
  private jwtService: JwtService;

  @Inject(ConfigService)
  private configService: ConfigService;

  async loginByJwt(loginMsg: Object) {
    const payload = loginMsg;
    const expiresIn = this.configService.get('auth.jwt.time');
    return {
      access_token: this.jwtService.sign(payload, {
        expiresIn,
        secret: this.configService.get('auth.jwt.token'),
      }),
      expiresIn
    };
  }
}

在user.module.ts中引入auth模块

imports: [ConfigModule, RedisModule, PrismaModule,AuthModule],

在user.service.ts中使用
在这里插入图片描述

  async login(loginMsg: { userId: string }) {
    return await this.authService.loginByJwt(loginMsg);
  }

user.controller.ts中使用
在这里插入图片描述
上述完成后,在小程序中测试下
在这里插入图片描述
但这里其实小程序是不需要返回token的,登录成功即可,把token塞到redis中,然后让提供扫码的页面进行轮询。
所以要登录的时候改变扫码状态 修改user.service.ts中的login
这里redis set下access_token 后面会用到,这里每次都set xxx_token_userId 每次都会把上次的token给覆盖。确保单点登录。

async login(loginMsg: { userId: string }, key: string) {
    const data = await this.authService.loginByJwt(loginMsg);
    await this.redisService.set(
      `xxx_token_${loginMsg.userId}`,
      data.access_token,
      data.expiresIn,
    );
    await this.redisService.set(
      `timeout_loginTag_xxx_${key}`,
      JSON.stringify({ status: 3, ...data }),
      20,
    );
    return data;
  }

新增一个查询扫码状态接口。


  @Post('codeStatus')
  async codeStatus(@Body() data: { scene: string }) {
    if (!data.scene) {
      throw new BadGatewayException('scene is required');
    }
    return await this.userService.checkTagKey(data.scene);
  }

做好这些后,前端通过轮询调用codeStatus就能拿到token了,当status为3的时候说明扫码登录成功,为2的没做,当然可以尝试做下,比如已经扫码但是未登录。

校验JWT

在libs/auth/src/目录下新建jwt.strategy.ts
在这里插入图片描述
这里做token的检测和解析,我们需要引入之前redis服务,引入config服务,PrismaService服务。

完善 jwt.strategy.ts 这里在headers中可以获取前端传过来的一些自定义header,如果有一些登录后携带的header需要获取在这里可以获取。比如token,这里的passport-jwt只校验之前jwt生产的token是否合理(是否过你配置项目中的时间,是否是jwt服务生成的),额外的比如我们提前销毁token,黑名单等等,可以在这里处理。

这里的payload呢一般是我们生成jwt的时候携带的信息。生成的时候带了userId,这一步可以从userId获取user。

有了jwt的策略,下一步怎么用呢。我们可以搞几个装饰器来用。

// src/auth/jwt.strategy.ts
import { PrismaService } from '@app/prisma';
import { RedisService } from '@app/redis';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly redisService: RedisService,
    private readonly prismaService: PrismaService,
    configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('auth.jwt.token'), // 替换为你的 JWT 密钥
      ignoreExpiration: false, // 确保过期时间被检查
      passReqToCallback: true, // 传递 request 对象到 validate 方法
    });
  }

  async validate(request: Request, payload: { userId: string }) {
    const headers = request.headers;
    const authorization = headers['authorization'];
    const token = authorization?.split(' ')[1];
    // 从redis中获取token 拿到的话说明 token还是生效的
    const redisToken = await this.redisService.get(`xxx_token_${payload.userId}`);
    if (redisToken) {
      const user = await this.prismaService.user.findUnique({
        where: {
          id: payload.userId,
        },
      });
      return user;
    }
    throw new UnauthorizedException();
  }
}

使用JWT(装饰器)

我们如何使用jwt策略呢。
在控制器上方直接用。

 @UseGuards(AuthGuard('jwt'))
  @Post('test')
  async test() {
    return 'test';
  }

当然我们还可以简写。
在auth/src/目录下新建 auth.decorator.ts 来写一些自定义装饰器 装饰器本身也算是一个函数,我们来写一个RequireLogin。而jwt策略返回的user。会被注入到request中,我们获取下,createParamDecorator第一个参数是装饰器的参数,比如传’id’ 我们就返回user.id 不传就是user。

import {
  createParamDecorator,
  ExecutionContext,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

export interface AuthUser {
  id: string;
  username: string;
  email: string | null;
  wxId: string | null;
  createdAt: Date;
  updatedAt: Date;
}
interface Request {
  user: AuthUser;
}
type AuthUserKeys = keyof AuthUser;

export const RequireLogin = () => UseGuards(AuthGuard('jwt'));

export const UserInfo = createParamDecorator(
  (data: AuthUserKeys, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest<Request>();

    if (!request.user) {
      return null;
    }
    return data ? request.user[data] : request.user;
  },
);

在auth/src/index.ts中 批量导出auth.decorator

export * from './auth.module';
export * from './auth.service';
export * from './auth.decorator';

使用

  @RequireLogin()
  @Post('test')
  async test(@UserInfo() user: AuthUser) {
    return user;
  }

我们在apifox中做个尝试。
不携带token 告诉你没权限。
在这里插入图片描述
带了token正常了。

在这里插入图片描述

在redis中删除刚刚的token 或者再进行一次登录 覆盖之前的token 注销功能只要把redis中的token去掉即可。xxx 这种是为了区分token的类型或者你有多个应用,公用一套扫码。
在这里插入图片描述

当前代码进度

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码哈士奇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值