使用 OAuth2.0 的 NestJS 身份验证:配置和操作
系列介绍
本系列将介绍NestJS中针对以下类型 API 的OAuth2.0 身份验证的完整实现:
- 快速REST API;
- Fastify REST API;
- Apollo GraphQL API。
它分为5个部分:
- 配置和操作;
- 快速本地 OAuth REST API;
- Fastify 本地 OAuth REST API;
- Apollo 本地 OAuth GraphQL API;
- 向我们的 API 添加外部 OAuth 提供程序;
让我们开始本系列的第一部分。
教程简介
在本教程中,我将介绍实现任何类型的 OAuth 系统所需的所有常见操作:
- 用户增删改查;
- 单个用户令牌撤销的用户版本控制;
- JWT 令牌生成;
- 带有令牌黑名单的授权模块。
TLDR:如果你没有 45 分钟阅读本文,可以在此repo中找到代码
概述
本地 OAuth 系统是一种通过 JSON Web 令牌(JWT)进行身份验证的身份验证系统,其中我们使用访问和刷新令牌对:
- 访问令牌:我们用于验证当前用户身份的令牌,通过将其作为 Bearer 令牌发送到 Authorization 标头上。它的生命周期较短,为 5 到 15 分钟;
- 刷新令牌:此令牌通常在签名的 HTTP 专用 cookie 上发送,并用于刷新访问令牌,这是因为刷新令牌具有从 20 分钟到 7 天的较长寿命。
设置
首先创建一个新的 NestJS 应用程序并在 VSCode 上打开它:
$ npm i -g @nestjs/cli
$ nest new nest-local-oauth -s
$ code nest-local-oauth
创建一个新的 yarn 配置文件(.yarnrc.yml):
nodeLinker: node-modules
安装最新版本的yarn:
$ yarn set version stable
$ yarn plugin import interactive-tools
在安装软件包之前,将 yarn cache 添加到.gitignore:
### Yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
在tsconfig.json添加"esModuleInterop":
{
  "compilerOptions": {
    "...": "...",
    "esModuleInterop": true
  }
}
最后安装软件包并升级到最新版本:
$ yarn install
$ yarn upgrade-interactive
技术
对于所有适配器,我们将使用相同的技术栈:
- MikroORM:与我们的数据库交互;
- Bcrypt:用于散列密码,请注意,对于新项目,您应该使用 argon2,因为它更安全,但是由于 bcrypt 仍然是常态,我将解释如何使用它进行构建;
- JSON Web Tokens:本认证系统的核心;
- UUID:我们需要唯一的标识符才能将我们的令牌列入黑名单;
- DayJS:用于日期操作。
因此首先安装所有软件包:
$ yarn add @mikro-orm/core @mikro-orm/postgresql @mikro-orm/nestjs bcrypt jsonwebtoken uuid dayjs
$ yarn add -D @mikro-orm/cli @types/bcrypt @types/jsonwebtoken @types/nodemailer @types/uuid
配置
开始之前我们需要准备几样东西:
- 令牌的秘密和生命周期;
- Cookie 的名称和秘密;
- 电子邮件配置;
- 数据库网址。
代币类型
对于完整的身份验证系统,我们需要 3 种类型的令牌:
- Access:用于授权的访问令牌;
- Refresh:用于刷新访问令牌的刷新令牌;
- 重置:用于通过电子邮件重置用户密码;
- 确认:用于确认用户。
因此访问令牌将如下所示:
# JWT tokens
JWT_ACCESS_TIME=600
JWT_CONFIRMATION_SECRET='random_string'
JWT_CONFIRMATION_TIME=3600
JWT_RESET_PASSWORD_SECRET='random_string'
JWT_RESET_PASSWORD_TIME=1800
JWT_REFRESH_SECRET='random_string'
JWT_REFRESH_TIME=604800
由于访问令牌需要由网关(如果没有网关,则由其他服务)解码,因此需要使用公钥和私钥对。您可以在此处生成一个 2048 位 RSA 密钥,并将其添加到keys项目根目录下的目录中。
Cookie 配置
我们的刷新令牌将通过仅 http 签名的 cookie 发送,因此我们需要一个用于刷新 cookie 名称和密钥的变量。
# Refresh token
REFRESH_COOKIE='cookie_name'
COOKIE_SECRET='random_string'
电子邮件配置
要发送电子邮件,我们将使用Nodemailer se,我们只需要添加典型的电子邮件配置参数:
# Email config
EMAIL_HOST='smtp.gmail.com'
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER='johndoe@gmail.com'
EMAIL_PASSWORD='your_email_password'
数据库配置
对于数据库,我们只需要 PostgreSQL URL:
# Database config
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/auth'
常规配置
其他移动通用变量:
- Node 环境,转为生产;
- APP ID,api 的 UUID;
- PORT,服务器上的 API 端口(通常为 5000);
- 前端域。
APP_ID='00000-00000-00000-00000-00000'
NODE_ENV='development'
PORT=4000
DOMAIN='localhost:3000'
配置模块
对于配置,我们可以使用 nestjs ConfigModule,因此首先在文件夹config上创建一个文件夹src。然后在config文件夹中开始添加包含配置接口的 interfaces 文件夹:
JWT 接口:
// jwt.interface.ts
export interface ISingleJwt {
  secret: string;
  time: number;
}
export interface IAccessJwt {
  publicKey: string;
  privateKey: string;
  time: number;
}
export interface IJwt {
  access: IAccessJwt;
  confirmation: ISingleJwt;
  resetPassword: ISingleJwt;
  refresh: ISingleJwt;
}
电子邮件配置界面:
// email-config.interface.ts
interface IEmailAuth {
  user: string;
  pass: string;
}
export interface IEmailConfig {
  host: string;
  port: number;
  secure: boolean;
  auth: IEmailAuth;
}
配置界面:
// config.interface.ts
import { MikroOrmModuleOptions } from '@mikro-orm/nestjs';
import { IEmailConfig } from './email-config.interface';
import { IJwt } from './jwt.interface';
export interface IConfig {
  id: string;
  port: number;
  domain: string;
  db: MikroOrmModuleOptions;
  jwt: IJwt;
  emailService: IEmailConfig;
}
创建配置函数:
// index.ts
import { LoadStrategy } from '@mikro-orm/core';
import { defineConfig } from '@mikro-orm/postgresql';
import { readFileSync } from 'fs';
import { join } from 'path';
import { IConfig } from './interfaces/config.interface';
export function config(): IConfig {
  const publicKey = readFileSync(
    join(__dirname, '..', '..', 'keys/public.key'),
    'utf-8',
  );
  const privateKey = readFileSync(
    join(__dirname, '..', '..', 'keys/private.key'),
    'utf-8',
  );
  return {
    id: process.env.APP_ID,
    port: parseInt(process.env.PORT, 10),
    domain: process.env.DOMAIN,
    jwt: {
      access: {
        privateKey,
        publicKey,
        time: parseInt(process.env.JWT_ACCESS_TIME, 10),
      },
      confirmation: {
        secret: process.env.JWT_CONFIRMATION_SECRET,
        time: parseInt(process.env.JWT_CONFIRMATION_TIME, 10),
      },
      resetPassword: {
        secret: process.env.JWT_RESET_PASSWORD_SECRET,
        time: parseInt(process.env.JWT_RESET_PASSWORD_TIME, 10),
      },
      refresh: {
        secret: process.env.JWT_REFRESH_SECRET,
        time: parseInt(process.env.JWT_REFRESH_TIME, 10),
      },
    },
    emailService: {
      host: process.env.EMAIL_HOST,
      port: parseInt(process.env.EMAIL_PORT, 10),
      secure: process.env.EMAIL_SECURE === 'true',
      auth: {
        user: process.env.EMAIL_USER,
        pass: process.env.EMAIL_PASSWORD,
      },
    },
    db: defineConfig({
      clientUrl: process.env.DATABASE_URL,
      entities: ['dist/**/*.entity.js', 'dist/**/*.embeddable.js'],
      entitiesTs: ['src/**/*.entity.ts', 'src/**/*.embeddable.ts'],
      loadStrategy: LoadStrategy.JOINED,
      allowGlobalContext: true,
    }),
  };
}
安装配置模块:
$ yarn add @nestjs/config joi
创建验证模式:
// config.schema.ts
import Joi from 'joi';
export const validationSchema = Joi.object({
  APP_ID: Joi.string().uuid({ version: 'uuidv4' }).required(),
  NODE_ENV: Joi.string().required(),
  PORT: Joi.number().required(),
  URL: Joi.string().required(),
  DATABASE_URL: Joi.string().required(),
  JWT_ACCESS_TIME: Joi.number().required(),
  JWT_CONFIRMATION_SECRET: Joi.string().required(),
  JWT_CONFIRMATION_TIME: Joi.number().required(),
  JWT_RESET_PASSWORD_SECRET: Joi.string().required(),
  JWT_RESET_PASSWORD_TIME: Joi.number().required(),
  JWT_REFRESH_SECRET: Joi.string().required(),
  JWT_REFRESH_TIME: Joi.number().required(),
  REFRESH_COOKIE: Joi.string().required(),
  COOKIE_SECRET: Joi.string().required(),
  EMAIL_HOST: Joi.string().required(),
  EMAIL_PORT: Joi.number().required(),
  EMAIL_SECURE: Joi.bool().required(),
  EMAIL_USER: Joi.string().email().required(),
  EMAIL_PASSWORD: Joi.string().required(),
});
最后将其导入到app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { config } from './config';
import { validationSchema } from './config/config.schema';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema,
      load: [config],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Mikro-ORM 配置
Mikro-ORM 需要将配置文件添加到我们的文件package.json夹中,因此src创建该文件:
// mikro-orm.config.ts
import { LoadStrategy, Options } from '@mikro-orm/core';
import { defineConfig } from '@mikro-orm/postgresql';
const config: Options = defineConfig({
  clientUrl: process.env.DATABASE_URL,
  entities: ['dist/**/*.entity.js', 'dist/**/*.embeddable.js'],
  entitiesTs: ['src/**/*.entity.ts', 'src/**/*.embeddable.ts'],
  loadStrategy: LoadStrategy.JOINED,
  allowGlobalContext: true,
});
export default config;
并在我们的package.json文件上添加配置文件:
{
  "...": "...",
  "mikro-orm": {
    "useTsNode": true,
    "configPaths": [
      "./src/mikro-orm.config.ts",
      "./dist/mikro-orm.config.js"
    ]
  }
}
为了在我们的文件夹中使用它,config我们需要为以下项添加一个类MikroOrmModule:
// mikro-orm.config.ts
import {
  MikroOrmModuleOptions,
  MikroOrmOptionsFactory,
} from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MikroOrmConfig implements MikroOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
  public createMikroOrmOptions(): MikroOrmModuleOptions {
    return this.configService.get<MikroOrmModuleOptions>('db');
  }
}
并在我们的文件上异步注册模块app.module.ts:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
// ...
import { MikroOrmConfig } from './config/mikroorm.config';
@Module({
  imports: [
    // ...
    MikroOrmModule.forRootAsync({
      imports: [ConfigModule],
      useClass: MikroOrmConfig,
    }),
  ],
  // ...
})
export class AppModule {}
通用模块
我喜欢有一个用于实体验证、错误处理和字符串操作的通用全局模块。
对于实体和输入验证,我们将使用类验证器,如文档中所述:
$ yarn add class-transformer class-validator
并将其添加到主文件:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();
创建模块和服务:
$ nest g mo common
$ nest g s common
在common文件夹中将装饰器添加Global到模块并导出服务:
import { Global, Module } from '@nestjs/common';
import { CommonService } from './common.service';
@Global()
@Module({
  providers: [CommonService],
  exports: [CommonService],
})
export class CommonModule {}
我个人喜欢将所有正则表达式放在 common 中,因此创建一个consts包含regex.const.ts文件的文件夹:
// checks if a password has at least one uppercase letter and a number or special character
export const PASSWORD_REGEX =
  /((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/;
// checks if a string has only letters, numbers, spaces, apostrophes, dots and dashes
export const NAME_REGEX = /(^[\p{L}\d'\.\s\-]*$)/u;
// checks if a string is a valid slug, useful for usernames
export const SLUG_REGEX = /^[a-z\d]+(?:(\.|-|_)[a-z\d]+)*$/;
// validates if passwords are valid bcrypt hashes
export const BCRYPT_HASH = /\$2[abxy]?\$\d{1,2}\$[A-Za-z\d\./]{53}/;
另一件事是用于常见检查的实用函数,创建一个utils带有验证文件的函数:
// validation.util.ts
export const isUndefined = (value: unknown): value is undefined =>
  typeof value === 'undefined';
export const isNull = (value: unknown): value is null => value === null;
公共服务
首先添加LoggerService:
import { Dictionary, EntityRepository } from '@mikro-orm/core';
import { Injectable, Logger, LoggerService } from '@nestjs/common';
@Injectable()
export class CommonService {
  private readonly loggerService: LoggerService;
  constructor() {
    this.loggerService = new Logger(CommonService.name);
  }
}
我们需要以下方法:
实体验证器
import { Dictionary } from '@mikro-orm/core';
import { 
  BadRequestException, 
  NotFoundException,
  // ...
} from '@nestjs/common';
import { validate } from 'class-validator';
@Injectable()
export class CommonService {
  // ...
  /**
   * Validate Entity
   *
   * Validates an entities with the class-validator library
   */
  public async validateEntity(entity: Dictionary): Promise<void> {
    const errors = await validate(entity);
    const messages: string[] = [];
    for (const error of errors) {
      messages.push(...Object.values(error.constraints));
    }
    if (errors.length > 0) {
      throw new BadRequestException(messages.join(',\n'));
    }
  }
}
Promise 错误包装器
import { Dictionary, EntityRepository } from '@mikro-orm/core';
import {
  BadRequestException,
  ConflictException,
  InternalServerErrorException,
  // ...
} from '@nestjs/common';
// ...
@Injectable()
export class CommonService {
  // ...
  /**
   * Throw Duplicate Error
   *
   * Checks is an error is of the code 23505, PostgreSQL's duplicate value error,
   * and throws a conflict exception
   */
  public async throwDuplicateError<T>(promise: Promise<T>, message?: string) {
    try {
      return await promise;
    } catch (error) {
      this.loggerService.error(error);
      if (error.code === '23505') {
        throw new ConflictException(message ?? 'Duplicated value in database');
      }
      throw new BadRequestException(error.message);
    }
  }
  /**
   * Throw Internal Error
   *
   * Function to abstract throwing internal server exception
   */
  public async throwInternalError<T>(promise: Promise<T>): Promise<T> {
    try {
      return await promise;
    } catch (error) {
      this.loggerService.error(error);
      throw new InternalServerErrorException(error);
    }
  }
}
实体动作
import { Dictionary, EntityRepository } from '@mikro-orm/core';
// ...
@Injectable()
export class CommonService {
  // ...
  /**
   * Check Entity Existence
   *
   * Checks if a findOne query didn't return null or undefined
   */
  public checkEntityExistence<T extends Dictionary>(
    entity: T | null | undefined,
    name: string,
  ): void {
    if (isNull(entity) || isUndefined(entity)) {
      throw new NotFoundException(`${name} not found`);
    }
  }
  /**
   * Save Entity
   *
   * Validates, saves and flushes entities into the DB
   */
  public async saveEntity<T extends Dictionary>(
    repo: EntityRepository<T>,
    entity: T,
    isNew = false,
  ): Promise<void> {
    await this.validateEntity(entity);
    if (isNew) {
      repo.persist(entity);
    }
    await this.throwDuplicateError(repo.flush());
  }
  /**
   * Remove Entity
   *
   * Removes an entities from the DB.
   */
  public async removeEntity<T extends Dictionary>(
    repo: EntityRepository<T>,
    entity: T,
  ): Promise<void> {
    await this.throwInternalError(repo.removeAndFlush(entity));
  }
}
字符串操作
首先安装slugify:
$ yarn add slugify
现在添加方法:
// ...
import slugify from 'slugify';
// ...
@Injectable()
export class CommonService {
  // ...
  /**
   * Format Name
   *
   * Takes a string trims it and capitalizes every word
   */
  public formatName(title: string): string {
    return title
      .trim()
      .replace(/\n/g, ' ')
      .replace(/\s\s+/g, ' ')
      .replace(/\w\S*/g, (w) => w.replace(/^\w/, (l) => l.toUpperCase()));
  }
  /**
   * Generate Point Slug
   *
   * Takes a string and generates a slug with dtos as word separators
   */
  public generatePointSlug(str: string): string {
    return slugify(str, { lower: true, replacement: '.', remove: /['_\.\-]/g });
  }
}
消息生成
有些端点必须返回单个消息字符串,对于这些类型的端点,我喜欢用 id 创建消息接口,这样更容易在前端进行过滤,创建一个interfaces文件夹并添加以下文件:
// message.interface.ts
export interface IMessage {
  id: string;
  message: string;
}
并为其创建一个方法:
// ...
import { v4 } from 'uuid';
// ...
@Injectable()
export class CommonService {
  // ...
  public generateMessage(message: string): IMessage {
    return { id: v4(), message };
  }
} 
用户模块
在创建 auth 模块之前,我们需要一种对用户执行 CRUD 操作的方法,因此创建一个新的用户模块和服务:
$ nest g mo users
$ nest g s users
用户实体
在创建实体之前,我们需要一个包含我们所需用户的界面,创建一个interfaces文件夹和user.interface.ts文件:
export interface IUser {
  id: number;
  name: string;
  username: string;
  email: string;
  password: string;
  confirmed: boolean;
  createdAt: Date;
  updatedAt: Date;
}
现在在实体上实现它,首先创建一个entities文件夹:
// user.entity.ts
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { IsBoolean, IsEmail, IsString, Length, Matches } from 'class-validator';
import {
  BCRYPT_HASH,
  NAME_REGEX,
  SLUG_REGEX,
} from '../../common/consts/regex.const';
import { IUser } from '../interfaces/users.interface';
@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
  @PrimaryKey()
  public id: number;
  @Property({ columnType: 'varchar', length: 100 })
  @IsString()
  @Length(3, 100)
  @Matches(NAME_REGEX, {
    message: 'Name must not have special characters',
  })
  public name: string;
  @Property({ columnType: 'varchar', length: 106 })
  @IsString()
  @Length(3, 106)
  @Matches(SLUG_REGEX, {
    message: 'Username must be a valid slugs',
  })
  public username: string;
  @Property({ columnType: 'varchar', length: 255 })
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;
  @Property({ columnType: 'boolean', default: false })
  @IsBoolean()
  public confirmed: true | false = false; // since it is saved on the db as binary
  @Property({ columnType: 'varchar', length: 60 })
  @IsString()
  @Length(59, 60)
  @Matches(BCRYPT_HASH)
  public password: string;
  @Property({ onCreate: () => new Date() })
  public createdAt: Date = new Date();
  @Property({ onUpdate: () => new Date() })
  public updatedAt: Date = new Date();
}
添加实体users.module.ts并导出UserService:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { UserEntity } from './entities/user.entity';
import { UsersService } from './users.service';
@Module({
  imports: [MikroOrmModule.forFeature([UserEntity])],
  providers: [UsersService],
  exports: [UserService]
})
export class UsersModule {}
用户版本控制
出于安全目的,我们需要能够对用户凭证(例如密码更改)进行版本控制,因此如果他们更改任何凭证,我们就可以撤销所有刷新令牌。
我们通过Credentials为用户创建一个 JSON 参数来实现这一点。首先创建它的接口:
// credentials.interface.ts
export interface ICredentials {
  version: number;
  lastPassword: string;
  passwordUpdatedAt: number;
  updatedAt: number;
}
在我们的 JSON 类型的新embeddables文件夹中添加可嵌入的凭证:
import { Embeddable, Property } from '@mikro-orm/core';
import dayjs from 'dayjs';
import { ICredentials } from '../interfaces/credentials.interface';
@Embeddable()
export class CredentialsEmbeddable implements ICredentials {
  @Property({ default: 0 })
  public version = 0;
  @Property({ default: '' })
  public lastPassword = '';
  @Property({ default: dayjs().unix() })
  public passwordUpdatedAt: number = dayjs().unix();
  @Property({ default: dayjs().unix() })
  public updatedAt: number = dayjs().unix();
  public updatePassword(password: string): void {
    this.version++;
    this.lastPassword = password;
    const now = dayjs().unix();
    this.passwordUpdatedAt = now;
    this.updatedAt = now;
  }
  public updateVersion(): void {
    this.version++;
    this.updatedAt = dayjs().unix();
  }
}
并更新我们的用户界面和实体:
// user.interface.ts
import { ICredentials } from './credentials.interface';
export interface IUser {
  // ...
  credentials: ICredentials;
  // ...
}
// user.entity.ts
import { Embedded, Entity, PrimaryKey, Property } from '@mikro-orm/core';
// ...
import { IUser } from '../interfaces/users.interface';
@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
  // ...
  @Embedded(() => CredentialsEmbeddable)
  public credentials: CredentialsEmbeddable = new CredentialsEmbeddable();
  // ...
}
用户服务
用户服务将主要涵盖我们的用户 CRUD 操作,注入usersRepository装饰@InjectRepository器和CommonService:
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable } from '@nestjs/common';
import { CommonService } from '../common/common.service';
import { UserEntity } from './entities/user.entity';
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly usersRepository: EntityRepository<UserEntity>,
    private readonly commonService: CommonService,
  ) {}
}
CRUD 操作
用户创作
要创建用户,我们需要三个参数:
- name:用户的名称;
- 电子邮件:全部小写的唯一电子邮件;
- 密码:用户密码(注意,当我们添加外部提供商时,此字段是可选的)。
// ...
@Injectable()
export class UsersService {
  // ...
  public async create(
    email: string,
    name: string,
    password: string,
  ): Promise<UserEntity> {
    const formattedEmail = email.toLowerCase();
    await this.checkEmailUniqueness(formattedEmail);
    const formattedName = this.commonService.formatName(name);
    const user = this.usersRepository.create({
      email: formattedEmail,
      name: formattedName,
      username: await this.generateUsername(formattedName),
      password: await hash(password, 10),
    });
    await this.commonService.saveEntity(this.usersRepository, user, true);
    return user;
  }
  // ...
  private async checkEmailUniqueness(email: string): Promise<void> {
    const count = await this.usersRepository.count({ email });
    if (count > 0) {
      throw new ConflictException('Email already in use');
    }
  }
  /**
   * Generate Username
   *
   * Generates a unique username using a point slug based on the name
   * and if it's already in use, it adds the usernames count to the end
   */
  private async generateUsername(name: string): Promise<string> {
    const pointSlug = this.commonService.generatePointSlug(name);
    const count = await this.usersRepository.count({
      username: {
        $like: `${pointSlug}%`,
      },
    });
    if (count > 0) {
      return `${pointSlug}${count}`;
    }
    return pointSlug;
  }
  // ...
}
用户阅读
我们需要三种读取方法:
- 
  ID:通过 ID 获取用户的主要读取方法 // ... @Injectable() export class UsersService { // ... public async findOneById(id: number): Promise<UserEntity> { const user = await this.usersRepository.findOne({ id }); this.commonService.checkEntityExistence(user, 'User'); return user; } // ... }
- 
  电子邮件:主要用于身份验证,通过电子邮件获取用户 import { // ... UnauthorizedException, } from '@nestjs/common'; // ... import { isNull, isUndefined } from '../common/utils/validation.util'; @Injectable() export class UsersService { // ... public async findOneByEmail(email: string): Promise<UserEntity> { const user = await this.usersRepository.findOne({ email: email.toLowerCase(), }); this.throwUnauthorizedException(user); return user; } // necessary for password reset public async uncheckedUserByEmail(email: string): Promise<UserEntity> { return this.usersRepository.findOne({ email: email.toLowerCase(), }); } // ... private throwUnauthorizedException( user: undefined | null | UserEntity, ): void { if (isUndefined(user) || isNull(user)) { throw new UnauthorizedException('Invalid credentials'); } } // ... }
- 
  凭证:用于令牌生成和验证 // ... @Injectable() export class UsersService { // ... public async findOneByCredentials( id: number, version: number, ): Promise<UserEntity> { const user = await this.usersRepository.findOne({ id }); this.throwUnauthorizedException(user); if (user.credentials.version !== version) { throw new UnauthorizedException('Invalid credentials'); } return user; } // ... }
- 
  用户名:用于获取用户和身份验证 // ... @Injectable() export class UsersService { // ... public async findOneByUsername( username: string, forAuth = false, ): Promise<UserEntity> { const user = await this.usersRepository.findOne({ username: username.toLowerCase(), }); if (forAuth) { this.throwUnauthorizedException(user); } else { this.commonService.checkEntityExistence(user, 'User'); } return user; } // ... }
用户更新
在创建更新之前,我们需要创建一些dtos数据传输对象。一个用于更改电子邮件:
// change-email.dto.ts
import { IsEmail, IsString, Length, MinLength } from 'class-validator';
export abstract class ChangeEmailDto {
  @IsString()
  @MinLength(1)
  public password!: string;
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;
}
还有一个用于改变用户的其余部分:
// update-user.dto.ts
import { IsString, Length, Matches, ValidateIf } from 'class-validator';
import { NAME_REGEX, SLUG_REGEX } from '../../common/consts/regex.const';
import { isNull, isUndefined } from '../../common/utils/validation.util';
export abstract class UpdateUserDto {
  @IsString()
  @Length(3, 106)
  @Matches(SLUG_REGEX, {
    message: 'Username must be a valid slugs',
  })
  @ValidateIf(
    (o: UpdateUserDto) =>
      !isUndefined(o.username) || isUndefined(o.name) || isNull(o.name),
  )
  public username?: string;
  @IsString()
  @Length(3, 100)
  @Matches(NAME_REGEX, {
    message: 'Name must not have special characters',
  })
  @ValidateIf(
    (o: UpdateUserDto) =>
      !isUndefined(o.name) || isUndefined(o.username) || isNull(o.username),
  )
  public name?: string;
}
由于用户是我们 API 上的关键实体,因此我喜欢将更新分为几种方法,因此有端点/变异:
- 
  用户更新: // ... import { UsernameDto } from './dtos/username.dto'; @Injectable() export class UsersService { // ... public async update(userId: number, dto: UpdateUserDto): Promise<UserEntity> { const user = await this.findOneById(userId); const { name, username } = dto; if (!isUndefined(name) && !isNull(name)) { if (name === user.name) { throw new BadRequestException('Name must be different'); } user.name = this.commonService.formatName(name); } if (!isUndefined(username) && !isNull(username)) { const formattedUsername = dto.username.toLowerCase(); if (user.username === formattedUsername) { throw new BadRequestException('Username should be different'); } await this.checkUsernameUniqueness(formattedUsername); user.username = formattedUsername; } await this.commonService.saveEntity(this.usersRepository, user); return user; } // ... private async checkUsernameUniqueness(username: string): Promise<void> { const count = await this.usersRepository.count({ username }); if (count > 0) { throw new ConflictException('Username already in use'); } } // ... }
- 
  电子邮件更新: // ... import { ChangeEmailDto } from './dtos/change-email.dto'; @Injectable() export class UsersService { // ... public async updateEmail( userId: number, dto: ChangeEmailDto, ): Promise<UserEntity> { const user = await this.userById(userId); const { email, password } = dto; if (!(await compare(password, user.password))) { throw new BadRequestException('Invalid password'); } const formattedEmail = email.toLowerCase(); await this.checkEmailUniqueness(formattedEmail); user.credentials.updateVersion(); user.email = formattedEmail; await this.commonService.saveEntity(this.usersRepository, user); return user; } // ... }
- 
  密码更新和重置: // ... import { compare, hash } from 'bcrypt'; // ... @Injectable() export class UsersService { // ... public async updatePassword( userId: number, password: string, newPassword: string, ): Promise<UserEntity> { const user = await this.userById(userId); if (!(await compare(password, user.password))) { throw new BadRequestException('Wrong password'); } if (await compare(newPassword, user.password)) { throw new BadRequestException('New password must be different'); } user.credentials.updatePassword(user.password); user.password = await hash(newPassword, 10); await this.commonService.saveEntity(this.usersRepository, user); return user; } public async resetPassword( userId: number, version: number, password: string, ): Promise<UserEntity> { const user = await this.findOneByCredentials(userId, version); user.credentials.updatePassword(user.password); user.password = await hash(password, 10); await this.commonService.saveEntity(this.usersRepository, user); return user; } // ... }
用户删除
请注意,我仍然会向用户返回该函数,如果我们需要实现通知系统,请随意返回 void:
// ...
@Injectable()
export class UsersService {
  // ...
  public async remove(userId: number): Promise<UserEntity> {
    const user = await this.findOneById(userId);
    await this.commonService.removeEntity(this.usersRepository, user);
    return user;
  }
  // ...
}
JWT 模块
尽管 nestjs 有自己的JwtService,但我们需要针对我们拥有的各种类型的令牌自定义一个:
$ nest g mo jwt
$ nest g s jwt
并从模块导出服务:
import { Module } from '@nestjs/common';
import { JwtService } from './jwt.service';
@Module({
  providers: [JwtService],
  exports: [JwtService],
})
export class JwtModule {}
代币类型
枚举
创建一个enums文件夹并添加以下枚举:
export enum TokenTypeEnum {
  ACCESS = 'access',
  REFRESH = 'refresh',
  CONFIRMATION = 'confirmation',
  RESET_PASSWORD = 'resetPassword',
}
接口
每个令牌都从前一个令牌扩展而来,创建一个interfaces文件夹并为每种类型的令牌添加一个接口。
基础代币
所有令牌都会有一个iat(发行时间)、exp(到期时间)、iss(发行者)、aud(受众)和sub(主题)字段,因此我们需要为所有令牌建立一个基础:
// token-base.interface.ts
export interface ITokenBase {
  iat: number;
  exp: number;
  iss: string;
  aud: string;
  sub: string;
}
访问令牌
访问令牌将仅包含用户的 ID:
// access-token.interface.ts
import { ITokenBase } from './token-base.interface';
export interface IAccessPayload {
  id: number;
}
export interface IAccessToken extends IAccessPayload, ITokenBase {}
电子邮件令牌
电子邮件令牌将包含用户的 ID 和版本:
// email-token.interface.ts
import { IAccessPayload } from './access-token.interface';
import { ITokenBase } from './token-base.interface';
export interface IEmailPayload extends IAccessPayload {
  version: number;
}
export interface IEmailToken extends IEmailPayload, ITokenBase {}
刷新令牌
刷新令牌将包含用户的 id 和版本,以及作为令牌标识符的 uuid:
// refresh-token.interface.ts
import { IEmailPayload } from './email-token.interface';
import { ITokenBase } from './token-base.interface';
export interface IRefreshPayload extends IEmailPayload {
  tokenId: string;
}
export interface IRefreshToken extends IRefreshPayload, ITokenBase {}
服务
首先注入ConfigService和CommonService:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommonService } from '../common/common.service';
@Injectable()
export class JwtService {
  private readonly jwtConfig: IJwt;
  private readonly issuer: string;
  private readonly domain: string;
  constructor(
    private readonly configService: ConfigService,
    private readonly commonService: CommonService,
  ) {}
}
由于 jsonwebtoken 库仍然使用回调来实现异步行为,因此让我们创建异步签名和验证函数:
// ...
import * as jwt from 'jsonwebtoken';
import { IAccessPayload } from './interfaces/access-token.interface';
import { IEmailPayload } from './interfaces/email-token.interface';
import { IRefreshToken } from './interfaces/refresh-token.interface';
@Injectable()
export class JwtService {
  // ...
  private static async generateTokenAsync(
    payload: IAccessPayload | IEmailPayload | IRefreshPayload,
    secret: string,
    options: jwt.SignOptions,
  ): Promise<string> {
    return new Promise((resolve, rejects) => {
      jwt.sign(payload, secret, options, (error, token) => {
        if (error) {
          rejects(error);
          return;
        }
        resolve(token);
      });
    });
  }
  private static async verifyTokenAsync<T>(
    token: string,
    secret: string,
    options: jwt.VerifyOptions,
  ): Promise<T> {
    return new Promise((resolve, rejects) => {
      jwt.verify(token, secret, options, (error, payload: T) => {
        if (error) {
          rejects(error);
          return;
        }
        resolve(payload);
      });
    });
  }
}
开始设置jwt配置和域:
// ...
import { IJwt } from '../config/interfaces/jwt.interface';
// ...
@Injectable()
export class JwtService {
  private readonly jwtConfig: IJwt;
  private readonly issuer: string;
  private readonly domain: string;
  constructor(
    private readonly configService: ConfigService,
    private readonly commonService: CommonService,
  ) {
    this.jwtConfig = this.configService.get<IJwt>('jwt');
    this.issuer = this.configService.get<string>('id');
    this.domain = this.configService.get<string>('domain');
  }
  // ...
}
IUser创建使用和作为参数生成令牌的方法TokenTypesEnum:
// ...
import { v4 } from 'uuid';
import { IJwt } from '../config/interfaces/jwt.interface';
import { IUser } from '../users/interfaces/user.interface';
// ...
@Injectable()
export class JwtService {
  // ...
  public async generateToken(
    user: IUser,
    tokenType: TokenTypeEnum,
    domain?: string | null,
    tokenId?: string,
  ): Promise<string> {
    const jwtOptions: jwt.SignOptions = {
      issuer: this.issuer,
      subject: user.email,
      audience: domain ?? this.domain,
      algorithm: 'HS256', // only needs a secret
    };
    switch (tokenType) {
      case TokenTypeEnum.ACCESS:
        const { privateKey, time: accessTime } = this.jwtConfig.access;
        return this.commonService.throwInternalError(
          JwtService.generateTokenAsync({ id: user.id }, privateKey, {
            ...jwtOptions,
            expiresIn: accessTime,
            algorithm: 'RS256', // to use public and private key
          }),
        );
      case TokenTypeEnum.REFRESH:
        const { secret: refreshSecret, time: refreshTime } =
          this.jwtConfig.refresh;
        return this.commonService.throwInternalError(
          JwtService.generateTokenAsync(
            {
              id: user.id,
              version: user.credentials.version,
              tokenId: tokenId ?? v4(),
            },
            refreshSecret,
            {
              ...jwtOptions,
              expiresIn: refreshTime,
            },
          ),
        );
      case TokenTypeEnum.CONFIRMATION:
      case TokenTypeEnum.RESET_PASSWORD:
        const { secret, time } = this.jwtConfig[tokenType];
        return this.commonService.throwInternalError(
          JwtService.generateTokenAsync(
            { id: user.id, version: user.credentials.version },
            secret,
            {
              ...jwtOptions,
              expiresIn: time,
            },
          ),
        );
    }
  }
}
然后创建一个方法来验证和解码我们的令牌:
import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
} from '@nestjs/common';
// ...
import {
  // ...
  IAccessToken,
} from './interfaces/access-token.interface';
import { IEmailPayload, IEmailToken } from './interfaces/email-token.interface';
import {
  // ...
  IRefreshToken,
} from './interfaces/refresh-token.interface';
// ...
@Injectable()
export class JwtService {
  // ...
  private static async throwBadRequest<
    T extends IAccessToken | IRefreshToken | IEmailToken,
  >(promise: Promise<T>): Promise<T> {
    try {
      return await promise;
    } catch (error) {
      if (error instanceof jwt.TokenExpiredError) {
        throw new BadRequestException('Token expired');
      }
      if (error instanceof jwt.JsonWebTokenError) {
        throw new BadRequestException('Invalid token');
      }
      throw new InternalServerErrorException(error);
    }
  }
  // ...
  public async verifyToken<
    T extends IAccessToken | IRefreshToken | IEmailToken,
  >(token: string, tokenType: TokenTypeEnum): Promise<T> {
    const jwtOptions: jwt.VerifyOptions = {
      issuer: this.issuer,
      audience: new RegExp(this.domain),
    };
    switch (tokenType) {
      case TokenTypeEnum.ACCESS:
        const { publicKey, time: accessTime } = this.jwtConfig.access;
        return JwtService.throwBadRequest(
          JwtService.verifyTokenAsync(token, publicKey, {
            ...jwtOptions,
            maxAge: accessTime,
            algorithms: ['RS256'],
          }),
        );
      case TokenTypeEnum.REFRESH:
      case TokenTypeEnum.CONFIRMATION:
      case TokenTypeEnum.RESET_PASSWORD:
        const { secret, time } = this.jwtConfig[tokenType];
        return JwtService.throwBadRequest(
          JwtService.verifyTokenAsync(token, secret, {
            ...jwtOptions,
            maxAge: time,
            algorithms: ['HS256'],
          }),
        );
    }
  }
}
邮件模块
为了确认用户身份并重置密码,我们需要发送电子邮件。首先安装 nodemailer 和 handlebars:
$ yarn add nodemailer handlebars
$ yarn add @types/nodemailer @types/handlebars
创建邮件模块和服务:
$ nest g mo mailer
$ nest g s mailer
并从模块导出服务:
import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';
@Module({
  providers: [MailerService],
  exports: [MailerService],
})
export class MailerModule {}
模板
我们将使用 handlebars 创建确认和密码重置的模板。
接口
创建interfaces文件夹并添加模板数据的接口:
// template-data.interface.ts
export interface ITemplatedData {
  name: string;
  link: string;
}
我们将拥有以下模板:
// templates.interface.ts
import { TemplateDelegate } from 'handlebars';
import { ITemplatedData } from './template-data.interface';
export interface ITemplates {
  confirmation: TemplateDelegate<ITemplatedData>;
  resetPassword: TemplateDelegate<ITemplatedData>;
}
HTML(HBS)
创建用于确认的电子邮件模板:
<!-- confirmation.hbs -->
<html lang='en'>
  <body>
    <p>Hello {{name}},</p>
    <br />
    <p>Welcome to [Your app],</p>
    <p>
      Click
      <b><a href='{{link}}' target='_blank'>here</a></b>
      to activate your acount or go to this link:
      {{link}}
    </p>
    <p><small>This link will expire in an hour.</small></p>
    <br />
    <p>Best of luck,</p>
    <p>[Your app] Team</p>
  </body>
</html>
还有一个用于密码重置:
<!-- reset-password.hbs -->
<html lang='en'>
  <body>
    <p>Hello {{name}},</p>
    <br />
    <p>Your password reset link:
      <b><a href='{{link}}' target='_blank'>here</a></b></p>
    <p>Or go to this link: ${{link}}</p>
    <p><small>This link will expire in 30 minutes.</small></p>
    <br />
    <p>Best regards,</p>
    <p>[Your app] Team</p>
  </body>
</html> 
要编译模板,您需要添加assets一个nest-cli.json:
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      "mailer/templates/**/*"
    ],
    "watchAssets": true
  }
}
服务
首先导入ConfigService:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MailerService {
  constructor(private readonly configService: ConfigService) {}
}
并添加电子邮件客户端配置以及记录器:
import { Injectable, Logger, LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createTransport, Transporter } from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
@Injectable()
export class MailerService {
  private readonly loggerService: LoggerService;
  private readonly transport: Transporter<SMTPTransport.SentMessageInfo>;
  private readonly email: string;
  private readonly domain: string;
  constructor(private readonly configService: ConfigService) {
    const emailConfig = this.configService.get<IEmailConfig>('emailService');
    this.transport = createTransport(emailConfig);
    this.email = `"My App" <${emailConfig.auth.user}>`;
    this.domain = this.configService.get<string>('domain');
    this.loggerService = new Logger(MailerService.name);
  }
}
如您所见,我们尚未添加模板,首先创建一个解析器方法:
// ...
import { readFileSync } from 'fs';
import Handlebars from 'handlebars';
// ...
import { ITemplatedData } from './interfaces/template-data.interface';
@Injectable()
export class MailerService {
  // ...
  private static parseTemplate(
    templateName: string,
  ): Handlebars.TemplateDelegate<ITemplatedData> {
    const templateText = readFileSync(
      join(__dirname, 'templates', templateName),
      'utf-8',
    );
    return Handlebars.compile<ITemplatedData>(templateText, { strict: true });
  }
}
并将我们的模板添加到配置中:
// ...
import { ITemplates } from './interfaces/templates.interface';
@Injectable()
export class MailerService {
  // ...
  private readonly templates: ITemplates;
  constructor(private readonly configService: ConfigService) {
    this.templates = {
      confirmation: MailerService.parseTemplate('confirmation.hbs'),
      resetPassword: MailerService.parseTemplate('reset-password.hbs'),
    };
  }
  // ...
}
电子邮件应该异步发送,因此请创建使用以下符号的公共方法.then:
// ...
@Injectable()
export class MailerService {
  // ...
  public sendEmail(
    to: string,
    subject: string,
    html: string,
    log?: string,
  ): void {
    this.transport
      .sendMail({
        from: this.email,
        to,
        subject,
        html,
      })
      .then(() => this.loggerService.log(log ?? 'A new email was sent.'))
      .catch((error) => this.loggerService.error(error));
  }
}
并且我们的两个模板各有一个方法:
// ...
import { IUser } from '../users/interfaces/user.interface';
@Injectable()
export class MailerService {
  // ...
  public sendConfirmationEmail(user: IUser, token: string): void {
    const { email, name } = user;
    const subject = 'Confirm your email';
    const html = this.templates.confirmation({
      name,
      link: `https://${this.domain}/auth/confirm/${token}`,
    });
    this.sendEmail(email, subject, html, 'A new confirmation email was sent.');
  }
  public sendResetPasswordEmail(user: IUser, token: string): void {
    const { email, name } = user;
    const subject = 'Reset your password';
    const html = this.templates.resetPassword({
      name,
      link: `https://${this.domain}/auth/reset-password/${token}`,
    });
    this.sendEmail(
      email,
      subject,
      html,
      'A new reset password email was sent.',
    );
  }
  // ...
}
授权模块
创建授权模块:
$ nest g mo auth
$ nest g s auth
实体
auth 模块只有一个实体,即黑名单令牌,在interfaces文件夹上创建其接口:
// blacklisted-token.interface.ts
import { IUser } from '../../users/interfaces/user.interface';
export interface IBlacklistedToken {
  tokenId: string;
  user: IUser;
  createdAt: Date;
}
现在只需在文件夹的实体上实现它entities:
// blacklisted-token.entity.ts
import {
  Entity,
  ManyToOne,
  PrimaryKeyType,
  Property,
  Unique,
} from '@mikro-orm/core';
import { UserEntity } from '../../users/entities/user.entity';
import { IBlacklistedToken } from '../interfaces/blacklisted-token.interface';
@Entity({ tableName: 'blacklisted_tokens' })
@Unique({ properties: ['tokenId', 'user'] })
export class BlacklistedTokenEntity implements IBlacklistedToken {
  @Property({
    primary: true,
    columnType: 'uuid',
  })
  public tokenId: string;
  @ManyToOne({
    entity: () => UserEntity,
    onDelete: 'cascade',
    primary: true,
  })
  public user: UserEntity;
  @Property({ onCreate: () => new Date() })
  public createdAt: Date;
  [PrimaryKeyType]: [string, number];
}
tokenId它具有和ID的复合键user。
将实体添加到模块以及UserModel、JwtModule和MailerModule:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { MailerModule } from '../mailer/mailer.module';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { BlacklistedTokenEntity } from './entity/blacklisted-token.entity';
@Module({
  imports: [
    MikroOrmModule.forFeature([BlacklistedTokenEntity]),
    UsersModule,
    JwtModule,
    MailerModule,
  ],
  providers: [AuthService],
})
export class AuthModule {}
服务
首先注入blacklistedTokensRepository、、和:CommonServiceUsersServiceJwtServiceMailerService
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable } from '@nestjs/common';
import { CommonService } from '../common/common.service';
import { JwtService } from '../jwt/jwt.service';
import { MailerService } from '../mailer/mailer.service';
import { UsersService } from '../users/users.service';
import { BlacklistedTokenEntity } from './entity/blacklisted-token.entity';
@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(BlacklistedTokenEntity)
    private readonly blacklistedTokensRepository: EntityRepository<BlacklistedTokenEntity>,
    private readonly commonService: CommonService,
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
    private readonly mailerService: MailerService,
  ) {}
}
DTO
我们需要几个 dto,因此创建一个dtos目录,其中包含以下文件:
密码DTO
我们需要两个密码参数,一个作为主密码,一个用于注册和密码更新的确认密码。
// passwords.dto.ts
import { IsString, Length, Matches, MinLength } from 'class-validator';
import { PASSWORD_REGEX } from '../../common/consts/regex.const';
export abstract class PasswordsDto {
  @IsString()
  @Length(8, 35)
  @Matches(PASSWORD_REGEX, {
    message:
      'Password requires a lowercase letter, an uppercase letter, and a number or symbol',
  })
      public password1!: string;
      @IsString()
      @MinLength(1)
      public password2!: string;
}
注册 DTO
用于注册。
// sign-up.dto.ts
import { IsEmail, IsString, Length, Matches } from 'class-validator';
import { NAME_REGEX } from '../../common/consts/regex.const';
import { PasswordsDto } from './passwords.dto';
export abstract class SignUpDto extends PasswordsDto {
  @IsString()
  @Length(3, 100, {
    message: 'Name has to be between 3 and 50 characters.',
  })
  @Matches(NAME_REGEX, {
    message: 'Name can only contain letters, dtos, numbers and spaces.',
  })
  public name!: string;
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email!: string;
}
登录 DTO
对于登录,可以使用电子邮件或用户名。
// sign-in.dto.ts
import { IsEmail, IsString, Length, Matches } from 'class-validator';
import { NAME_REGEX } from '../../common/consts/regex.const';
import { PasswordsDto } from './passwords.dto';
export abstract class SignUpDto extends PasswordsDto {
  @IsString()
  @Length(3, 100, {
    message: 'Name has to be between 3 and 50 characters.',
  })
  @Matches(NAME_REGEX, {
    message: 'Name can only contain letters, dtos, numbers and spaces.',
  })
  public name!: string;
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email!: string;
}
电子邮件 DTO
仅包含用于发送密码重置电子邮件的电子邮件的 dto。
// email.dto.ts
export abstract class EmailDto {
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;
}
重置密码DTO
用于重置密码,提供令牌。
// reset-password.dto.ts
import { IsJWT, IsString } from 'class-validator';
import { PasswordsDto } from './passwords.dto';
export abstract class ResetPasswordDto extends PasswordsDto {
  @IsString()
  @IsJWT()
  public resetToken!: string;
}
更改密码DTO
用于更新用户密码。
// change-password.dto.ts
import { IsString, MinLength } from 'class-validator';
import { PasswordsDto } from './passwords.dto';
export abstract class ChangePasswordDto extends PasswordsDto {
  @IsString()
  @MinLength(1)
  public password!: string;
}
接口
大多数身份验证服务方法将返回相同的三个字段:
- 用户;
- 访问令牌;
- 刷新令牌。
因此创建一个名为的接口IAuthResult:
// auth-result.interface.ts
import { IUser } from '../../users/interfaces/user.interface';
export interface IAuthResult {
  user: IUser;
  accessToken: string;
  refreshToken: string;
}
方法
我们将首先创建一个私有方法,以便利用以下方法更快地生成访问和刷新令牌Promise.all:
// ...
@Injectable()
export class AuthService {
  // ...
  private async generateAuthTokens(
    user: UserEntity,
    domain?: string,
    tokenId?: string,
  ): Promise<[string, string]> {
    return Promise.all([
      this.jwtService.generateToken(
        user,
        TokenTypeEnum.ACCESS,
        domain,
        tokenId,
      ),
      this.jwtService.generateToken(
        user,
        TokenTypeEnum.REFRESH,
        domain,
        tokenId,
      ),
    ]);
  } 
}
注册方式
// ...
import { SignUpDto } from './dtos/sign-up.dto';
// ...
@Injectable()
export class AuthService {
  // ...
  public async signUp(dto: SignUpDto, domain?: string): Promise<IMessage> {
    const { name, email, password1, password2 } = dto;
    this.comparePasswords(password1, password2);
    const user = await this.usersService.create(email, name, password1);
    const confirmationToken = await this.jwtService.generateToken(
      user,
      TokenTypeEnum.CONFIRMATION,
      domain,
    );
    this.mailerService.sendConfirmationEmail(user, confirmationToken);
    return this.commonService.generateMessage('Registration successful');
  }
  private comparePasswords(password1: string, password2: string): void {
    if (password1 !== password2) {
      throw new BadRequestException('Passwords do not match');
    }
  }
  // ...
}
登录方式
// ...
import {
  // ...
  UnauthorizedException,
} from '@nestjs/common';
import { compare } from 'bcrypt';
import { isEmail } from 'class-validator';
import { SLUG_REGEX } from '../common/consts/regex.const';
// ...
import { IAuthResult } from './interfaces/auth-result.interface';
@Injectable()
export class AuthService {
  // ...
  public async singIn(dto: SignInDto, domain?: string): Promise<IAuthResult> {
    const { emailOrUsername, password } = dto;
    const user = await this.userByEmailOrUsername(emailOrUsername);
    if (!(await compare(password, user.password))) {
      await this.checkLastPassword(user.credentials, password);
    }
    if (!user.confirmed) {
      const confirmationToken = await this.jwtService.generateToken(
        user,
        TokenTypeEnum.CONFIRMATION,
        domain,
      );
      this.mailerService.sendConfirmationEmail(user, confirmationToken);
      throw new UnauthorizedException(
        'Please confirm your email, a new email has been sent',
      );
    }
    const [accessToken, refreshToken] = await this.generateAuthTokens(
      user,
      domain,
    );
    return { user, accessToken, refreshToken };
  }
  // validates the input and fetches the user by email or username
  private async userByEmailOrUsername(
    emailOrUsername: string,
  ): Promise<UserEntity> {
    if (emailOrUsername.includes('@')) {
      if (!isEmail(emailOrUsername)) {
        throw new BadRequestException('Invalid email');
      }
      return this.usersService.userByEmail(emailOrUsername);
    }
    if (
       emailOrUsername.length < 3 ||
       emailOrUsername.length > 106 ||
       !SLUG_REGEX.test(emailOrUsername)
     ) {
      throw new BadRequestException('Invalid username');
    }
    return this.usersService.userByUsername(emailOrUsername, true);
  }
  // checks if your using your last password
  private async checkLastPassword(
    credentials: ICredentials,
    password: string,
  ): Promise<void> {
    const { lastPassword, passwordUpdatedAt } = credentials;
    if (lastPassword.length === 0 || !(await compare(password, lastPassword))) {
      throw new UnauthorizedException('Invalid credentials');
    }
    const now = dayjs();
    const time = dayjs.unix(passwordUpdatedAt);
    const months = now.diff(time, 'month');
    const message = 'You changed your password ';
    if (months > 0) {
      throw new UnauthorizedException(
        message + months + (months > 1 ? ' months ago' : ' month ago'),
      );
    }
    const days = now.diff(time, 'day');
    if (days > 0) {
      throw new UnauthorizedException(
        message + days + (days > 1 ? ' days ago' : ' day ago'),
      );
    }
    const hours = now.diff(time, 'hour');
    if (hours > 0) {
      throw new UnauthorizedException(
        message + hours + (hours > 1 ? ' hours ago' : ' hour ago'),
      );
    }
    throw new UnauthorizedException(message + 'recently');
  }
  // ...
}
刷新令牌访问
// ...
@Injectable()
export class AuthService {
  // ...
  public async refreshTokenAccess(
    refreshToken: string,
    domain?: string,
  ): Promise<IAuthResult> {
    const { id, version, tokenId } =
      await this.jwtService.verifyToken<IRefreshToken>(
        refreshToken,
        TokenTypeEnum.REFRESH,
      );
    await this.checkIfTokenIsBlacklisted(id, tokenId);
    const user = await this.usersService.userByCredentials(id, version);
    const [accessToken, newRefreshToken] = await this.generateAuthTokens(
      user,
      domain,
      tokenId,
    );
    return { user, accessToken, refreshToken: newRefreshToken };
  }
  // checks if a token given the ID of the user and ID of token exists on the database
  private async checkIfTokenIsBlacklisted(
    userId: number,
    tokenId: string,
  ): Promise<void> {
    const count = await this.blacklistedTokensRepository.count({
      user: userId,
      tokenId,
    });
    if (count > 0) {
      throw new UnauthorizedException('Token is invalid');
    }
  }
  // ...
}
登出
// ...
@Injectable()
export class AuthService {
  // ...
  public async logout(refreshToken: string): Promise<IMessage> {
    const { id, tokenId } = await this.jwtService.verifyToken<IRefreshToken>(
      refreshToken,
      TokenTypeEnum.REFRESH,
    );
    await this.blacklistToken(id, tokenId);
    return this.commonService.generateMessage('Logout successful');
  }
  // creates a new blacklisted token in the database with the
  // ID of the refresh token that was removed with the logout
  private async blacklistToken(userId: number, tokenId: string): Promise<void> {
    const blacklistedToken = this.blacklistedTokensRepository.create({
      user: userId,
      tokenId,
    });
    await this.commonService.saveEntity(
      this.blacklistedTokensRepository,
      blacklistedToken,
      true,
    );
  }
  // ...
}
重置密码邮箱
// ...
import { isNull, isUndefined } from '../common/utils/validation.util';
@Injectable()
export class AuthService {
  // ...
  public async resetPasswordEmail(
    dto: EmailDto,
    domain?: string,
  ): Promise<IMessage> {
    const user = await this.usersService.uncheckedUserByEmail(dto.email);
    if (!isUndefined(user) && !isNull(user)) {
      const resetToken = await this.jwtService.generateToken(
        user,
        TokenTypeEnum.RESET_PASSWORD,
        domain,
      );
      this.mailerService.sendResetPasswordEmail(user, resetToken);
    }
    return this.commonService.generateMessage('Reset password email sent');
  }
  // ...
}
重置密码
// ...
@Injectable()
export class AuthService {
  // ...
  public async resetPassword(dto: ResetPasswordDto): Promise<IMessage> {
    const { password1, password2, resetToken } = dto;
    const { id, version } = await this.jwtService.verifyToken<IEmailToken>(
      resetToken,
      TokenTypeEnum.RESET_PASSWORD,
    );
    this.comparePasswords(password1, password2);
    await this.usersService.resetPassword(id, version, password1);
    return this.commonService.generateMessage('Password reset successful');
  }
  // ...
}
更改密码
// ...
@Injectable()
export class AuthService {
  // ...
  public async changePassword(
    userId: number,
    dto: ChangePasswordDto,
  ): Promise<IAuthResult> {
    const { password1, password2, password } = dto;
    this.comparePasswords(password1, password2);
    const user = await this.usersService.updatePassword(
      userId,
      password,
      password1,
    );
    const [accessToken, refreshToken] = await this.generateAuthTokens(user);
    return { user, accessToken, refreshToken };
  }
  // ...
}
可选部分
使用 Redis 缓存进行优化
由于令牌会过期,将其永久保存在磁盘上会非常浪费,因此最好将其保存在缓存中。NestJS 自带了缓存,CacheModule因此请先安装以下软件包:
$ yarn add cache-manager ioredis cache-manager-redis-yet
配置
添加config.interface.tsioredis选项:
// ...
import { RedisOptions } from 'ioredis';
export interface IConfig {
  // ...
  redis: RedisOptions;
}
现在大多数托管的 redis 服务(AWS ElastiCache、Digital Ocean Redis DB 等)实际上都会为您提供一个 URL 而不是选项,因此我们需要构建一个解析器,在新utils文件夹中添加:
// redis-url-parser.util.ts
import { RedisOptions } from 'ioredis';
export const redisUrlParser = (url: string): RedisOptions => {
  if (url.includes('://:')) {
    const arr = url.split('://:')[1].split('@');
    const secondArr = arr[1].split(':');
    return {
      password: arr[0],
      host: secondArr[0],
      port: parseInt(secondArr[1], 10),
    };
  }
  const connectionString = url.split('://')[1];
  const arr = connectionString.split(':');
  return {
    host: arr[0],
    port: parseInt(arr[1], 10),
  };
};
将 redis URL 添加到.env文件(如果使用 docker-compose,则添加到其中)、模式和index文件:
REDIS_URL='redis://localhost:6379'
// index.ts
// ...
import { redisUrlParser } from './utils/redis-url-parser.util';
export function config(): IConfig {
  // ...
  return {
    // ...
    redis: redisUrlParser(process.env.REDIS_URL),
  };
}
// config.schema.ts
import Joi from 'joi';
export const validationSchema = Joi.object({
  // ...
  REDIS_URL: Joi.string().required(),
});
最后,为了能够使用,CacheModule我们需要为缓存创建一个配置类:
// cache.config.ts
import {
  CacheModuleOptions,
  CacheOptionsFactory,
  Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-ioredis-yet';
@Injectable()
export class CacheConfig implements CacheOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
  async createCacheOptions(): Promise<CacheModuleOptions> {
    return {
      store: await redisStore({
        ...this.configService.get('redis'),
        ttl: this.configService.get<number>('jwt.refresh.time') * 1000,
      }),
    };
  }
}
并将其添加到app.module.ts:
// ...
import { CacheModule, Module } from '@nestjs/common';
// ...
@Module({
  imports: [
    // ...
    CacheModule.registerAsync({
      isGlobal: true,
      imports: [ConfigModule],
      useClass: CacheConfig,
    }),
    // ...
  ],
  // ...
})
export class AppModule {}
授权模块
首先删除entities目录、blacklisted-token.interface.ts文件,然后将其从模块中删除:
import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { MailerModule } from '../mailer/mailer.module';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
@Module({
  imports: [UsersModule, JwtModule, MailerModule],
  providers: [AuthService],
})
export class AuthModule {}
授权服务
首先更改blacklistedTokensRepository缓存管理器:
import {
  // ...
  CACHE_MANAGER,
  // ...
} from '@nestjs/common';
import { Cache } from 'cache-manager';
// ...
@Injectable()
export class AuthService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
    // ...
  ) {}
  // ...
}
现在更新该blacklistToken方法及其logout依赖关系:
// ...
import dayjs from 'dayjs';
// ...
@Injectable()
export class AuthService {
  // ...
  public async logout(refreshToken: string): Promise<IMessage> {
    const { id, tokenId, exp } =
      await this.jwtService.verifyToken<IRefreshToken>(
        refreshToken,
        TokenTypeEnum.REFRESH,
      );
    await this.blacklistToken(id, tokenId, exp);
    return this.commonService.generateMessage('Logout successful');
  }
  // ...
  // checks if a blacklist token given a redis key exist on cache
  private async blacklistToken(
    userId: number,
    tokenId: string,
    exp: number,
  ): Promise<void> {
    const now = dayjs().unix();
    const ttl = (exp - now) * 1000;
    if (ttl > 0) {
      await this.commonService.throwInternalError(
        this.cacheManager.set(`blacklist:${userId}:${tokenId}`, now, ttl),
      );
    }
  }
  // ...
}
如您所见,我们使用典型的 redis 键,以冒号将其分为三部分:
- 键的标题:“黑名单”
- 用户ID:该Token所属的用户id;
- Token ID:被列入黑名单的Token的ID。
我保存了它的创建日期,但你不能只将 0 或 1(二进制)保存为真或假。
此外,我们检查令牌是否存在的方式也发生了变化:
// ...
@Injectable()
export class AuthService {
  // ...
  private async checkIfTokenIsBlacklisted(
    userId: number,
    tokenId: string,
  ): Promise<void> {
    const time = await this.cacheManager.get<number>(
      `blacklist:${userId}:${tokenId}`,
    );
    if (!isUndefined(time) && !isNull(time)) {
      throw new UnauthorizedException('Invalid token');
    }
  }
  // ...
}
结论
通过它,您可以为完整的本地身份验证类型的 API 创建基础。
本教程的完整代码可以在这个repo上找到。
关于作者
大家好!我是 Afonso Barracha,您信赖的计量经济学家,因对 GraphQL 情有独钟而进入了后端开发领域。如果您喜欢这篇文章,不妨请我喝杯咖啡,表达一下您的喜爱。
最近,我一直在深入研究一些更高级的主题。因此,我把每周分享想法的习惯改为每月发布一到两次。这样,我就能确保为大家带来尽可能高质量的内容。
不要错过我的任何最新文章——请在dev和LinkedIn上关注我,获取最新资讯。我非常欢迎您加入我们不断壮大的社区!期待与您相见!
文章来源:https://dev.to/tugascript/nestjs-authentication-with-oauth20-configuration-and-operations-41k 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com