10 分钟掌握 NestJS+Graphql 的 JWT 鉴权:图解守卫与策略交互流程

70 阅读6分钟

引言

最近完成一个turborepo模式下的Next+Nest的博客网站全栈项目,整体写下来感觉还有很多可以修改优化,接下来可能写一些博客理清思路,欢迎大家去star

技术栈介绍

  • 前端React@19+Next15 + TypeScript + shadcnUI
  • 后端NestJS + TypeScript + GraphQL + Prisma
  • 身份验证JWT

主题

今天主要想讲解一些Nest里JWT的实现过程, 数据库是GraphQL + Prisma,业务逻辑是实现注册和登录,然后去需要token的接口进行jwt验证,这里graphql和prisma的配置就不说了,官网文档其实都有的

支持用户名和密码注册和登录,做一些校验,登录后获取用户信息和 token 存储,登录成功后,跳转到欢迎页面

注册

首先我们先nest g res user模块,然后创建一个Mutation,用Args去接受前端传来的参数,配置dto

@Resolver(() => User)
export class UserResolver {
  constructor(private readonly userService: UserService) {}
  @Mutation(() => User)
  async createUser(@Args('createUserInput') createUserInput: CreateUserInput) {
    return await this.userService.create(createUserInput);
  }
}

可以用class-validator去做参数验证,记得去main.ts

app.useGlobalPipes(new ValidationPipe());

import { InputType, Int, Field } from '@nestjs/graphql';
import { IsEmail } from 'class-validator';

@InputType()
export class CreateUserInput {
  @Field(() => String)
  name: string;

  @Field()
  password: string;

  @Field()
  @IsEmail()
  email: string;

  @Field({ nullable: true })
  bio?: string;

  @Field({ nullable: true })
  avatar?: string;
}

然后到service文件里写create,用argon2加密密码,后面会用argon2的vertify验证,然后注册功能就完成了,到浏览器试一下

import { Injectable } from '@nestjs/common';
import { CreateUserInput } from './dto/create-user.input';
import { UpdateUserInput } from './dto/update-user.input';
import { PrismaService } from 'src/prisma/prisma.service';
import { hash } from 'argon2';

@Injectable()
export class UserService {
  constructor(private prisma: PrismaService) {}
  async create(createUserInput: CreateUserInput) {
    const { password, ...user } = createUserInput;
    const hashedPassword = await hash(password);

    const res = await this.prisma.user.create({
      data: {
        password: hashedPassword,
        ...user,
      },
    });
    console.log('🚀 ~ UserService ~ create ~ res:', res);

    return res;
  }
}


效果

对应的打印也有结果 动画.gif

image.png

登录

首先我们先nest g res auth模块,一样写一个Mutation,需要先通过邮箱去数据库查是否有这个用户,查到在进行登录的token生成

import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { SignInInput } from './dto/signin-input';
import { AuthPayload } from './entities/auth-payload.entity';

@Resolver()
export class AuthResolver {
  constructor(private readonly authService: AuthService) {}

  @Mutation(() => AuthPayload)
  async signIn(@Args('signInInput') signInInput: SignInInput) {
    const user = await this.authService.validateLocalUser(signInInput);
    return await this.authService.login(user);
  }
}

Entity,这是返回值

import { Field, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class AuthPayload {
  @Field(() => Int)
  id: number;
  @Field()
  name: string;
  @Field({ nullable: true })
  avatar?: string;
  @Field()
  accessToken: string;
}

Dto,邮箱作为账号,加上密码

import { Field, InputType } from '@nestjs/graphql';
import { IsString, MinLength } from 'class-validator';

@InputType()
export class SignInInput {
  @Field()
  email: string;

  @Field()
  @IsString()
  @MinLength(1)
  password: string;
}

service文件,validateLocalUser去验证是否有这个用户,并且验证verify密码是否正确,如果有返回user的信息去登录,没有就返回UnauthorizedException

  import { verify } from 'argon2';
  
  async validateLocalUser({ email, password }: SignInInput) {
    const user = await this.prisma.user.findUnique({
      where: {
        email,
      },
    });

    if (!user) throw new UnauthorizedException('User not found');

    const matchedUser = await verify(user.password!, password);

    if (!matchedUser) throw new UnauthorizedException('Invalid password');

    return user;
  }

通过之后在去调用login,因为我们返回是需要生成token的,所以需要安装@nestjs/jwt来生成,npm install @nestjs/jwt,需要去auth.module去import注册这个jwtService,主要配置secret密钥和expiresIn过期时间,这两个内容没有什么要求,随机生成一个就可以,密钥生成

JWT_SECRET=e55a10d70db7d94593ce3275cc712fe79cbd2c5be13a19ce26e826f081a78

JWT_EXPRISEIN=24h

@Module({
  imports: [
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: {
          expiresIn: configService.get<string>('JWT_EXPRISEIN'),
        },
      }),
    }),
  ],
  providers: [
    AuthResolver,
    AuthService,
    PrismaService,
  ],
  controllers: [AuthController],
})
export class AuthModule {}

到login,根据userId生成token或者其他验证用户的参数,返回token给前端,我们到浏览器试一下

  async generateToken(userId: number) {
    const payload: AuthJwtPayload = { sub: userId };
    const accessToken = this.jwtService.sign(payload);
    return { accessToken };
  }

  async login(user: User) {
    const { accessToken } = await this.generateToken(user.id);
    return {
      id: user.id,
      name: user.name,
      avatar: user.avatar,
      accessToken,
    };
  }
效果和打印

这样登录和注册功能就完成了,现在我们去用户文章列表,需要登录并且token有效才可以访问

动画1.gif

image.png

passport

一样我们nest g res post模块,写getUserPost,获取自己的文章列表,这里需要实现就是我们要查到这个用户的信息,那么我们需要知道这个用户的userId,那么我们从请求头的 Authorization 字段提取Token,解析获取id,基本的流程是这样的,nestjs去实现我们需要通过passport

这个实现过程需要我们来倒推,可以看到代码中使用了守卫Guard,guard一般我们是用于请求认证或者鉴权的,所以回去把jwt的验证放在这样,一般我们会从guard中去获取context里的req

npm install @nestjs/passport passport-jwt

  @Query(() => [Post])
  @UseGuards(JwtAuthGuard)
  getUserPost(
    @Context() context,
    @Args('skip', { nullable: true, type: () => Int }) skip?: number,
    @Args('take', { nullable: true, type: () => Int }) take?: number,
  ) {
    const userId = context.req.user.id;

    return this.postService.findPostByUser({
      userId,
      skip: skip ?? 1,
      take: take ?? DEFAULT_PAGE_SIZE,
    });
  }

我们nest g guard jwt-auth的guard文件,guards/jwt.strategy.ts,然后我们去修改文件的默认代码,我们需要将JwtAuthGuard 继承自 AuthGuard('jwt'),触发 Passport 的 JWT 策略

我用的是GraphQL,所以需要从GqlExecutionContext创建context

如果我们用的是mysql或者pg的数据库,是可以直接从canActivate(context: ExecutionContext)直接获取的

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  // JwtAuthGuard 继承自 AuthGuard('jwt'),触发 Passport 的 JWT 策略:
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req; // 从 GraphQL 上下文提取请求对象
  }
}

strategy

我们去写strategy策略,可以建一个strategies/jwt.strategy.ts,这里面有一些PassportStrategy的配置,一样我们要将JwtStrategy去继承PassportStrategy

  • fromAuthHeaderAsBearerToken从请求头的 Authorization 字段提取 Bearer Token,自动验证
  • ignoreExpiration: false, 会拒绝过期令牌
  • 自动触发validate,我们拿到jwt解密后的数据,之前是用userId加密,现在在拿userId去验证User

我们需要@Injectable()声明,让其变成可以被注入的类(依赖)

import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthJwtPayload } from '../types/auth-jwtPayload';
import { AuthService } from '../auth.service';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
    private authService: AuthService,
  ) {
    super({

      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 
      secretOrKey: configService.get<string>('JWT_SECRET'),
      ignoreExpiration: false, //会拒绝过期令牌
    });
  }

  // 解密后的 JWT 载荷(Payload)传入 validate 方法
  // 返回的用户对象会被附加到 req.user,供后续逻辑使用。
  // validate 返回 null 或抛出异常 → 认证失败。
  validate(payload: AuthJwtPayload) {
    const userId = payload.sub;
    return this.authService.jwtValidateUser(userId);
  }
}

auth.service里写一下jwtValidateUser,通过在返回id,返回的用户对象会被附加到 req.user,供后续逻辑使用

  async jwtValidateUser(userId: number) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new UnauthorizedException('User not found');
    const currentUser = { id: user.id };
    return currentUser;
  }

然后我们JwtStrategy使用了authService,记得去auth.module去把JwtStrategy添加到providers,我们去graphql试一下,去postman也一样的

  providers: [
    AuthResolver,
    AuthService,
    PrismaService,
    JwtStrategy,
    GoogleStrategy,
  ],
效果

动画13.gif

至此,JWT守卫的功能就全部实现,这是一种登录方式,平时登录方式其实还会去接三方,像谷歌、GitHub等

基本原理是一致的,代码在这里,欢迎大家去star

谷歌登录部分代码

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { AuthService } from '../auth.service';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configServie: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      clientID: configServie.get<string>('GOOGLE_CLIENT_ID'),
      clientSecret: configServie.get<string>('GOOGLE_CLIENT_SECRET'),
      callbackURL: configServie.get<string>('GOOGLE_CALLBACK_URL'),
      proxy: true,
      scope: ['profile', 'email'],
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: VerifyCallback,
  ) {
    const user = await this.authService.validateGoogleUser({
      email: profile.emails[0].value,
      name: profile.displayName,
      avatar: profile.photosp[0].value,
      password: '',
    });
    done(null, user);
  }
}

仓库地址

欢迎大家去star