使用 OAuth2.0 的 NestJS 身份验证:配置和操作

2025-06-07

使用 OAuth2.0 的 NestJS 身份验证:配置和操作

系列介绍

本系列将介绍NestJS中针对以下类型 API 的OAuth2.0 身份验证的完整实现:

它分为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
Enter fullscreen mode Exit fullscreen mode

创建一个新的 yarn 配置文件(.yarnrc.yml):

nodeLinker: node-modules
Enter fullscreen mode Exit fullscreen mode

安装最新版本的yarn:

$ yarn set version stable
$ yarn plugin import interactive-tools
Enter fullscreen mode Exit fullscreen mode

在安装软件包之前,将 yarn cache 添加到.gitignore

### Yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
Enter fullscreen mode Exit fullscreen mode

tsconfig.json添加"esModuleInterop"

{
  "compilerOptions": {
    "...": "...",
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

最后安装软件包并升级到最新版本:

$ yarn install
$ yarn upgrade-interactive
Enter fullscreen mode Exit fullscreen mode

技术

对于所有适配器,我们将使用相同的技术栈:

  • 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
Enter fullscreen mode Exit fullscreen mode

配置

开始之前我们需要准备几样东西:

  • 令牌的秘密和生命周期;
  • 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
Enter fullscreen mode Exit fullscreen mode

由于访问令牌需要由网关(如果没有网关,则由其他服务)解码,因此需要使用公钥和私钥对。您可以在此处生成一个 2048 位 RSA 密钥,并将其添加到keys项目根目录下的目录中。

Cookie 配置

我们的刷新令牌将通过仅 http 签名的 cookie 发送,因此我们需要一个用于刷新 cookie 名称和密钥的变量。

# Refresh token
REFRESH_COOKIE='cookie_name'
COOKIE_SECRET='random_string'
Enter fullscreen mode Exit fullscreen mode

电子邮件配置

要发送电子邮件,我们将使用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'
Enter fullscreen mode Exit fullscreen mode

数据库配置

对于数据库,我们只需要 PostgreSQL URL:

# Database config
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/auth'
Enter fullscreen mode Exit fullscreen mode

常规配置

其他移动通用变量:

  • Node 环境,转为生产;
  • APP ID,api 的 UUID;
  • PORT,服务器上的 API 端口(通常为 5000);
  • 前端域。
APP_ID='00000-00000-00000-00000-00000'
NODE_ENV='development'
PORT=4000
DOMAIN='localhost:3000'
Enter fullscreen mode Exit fullscreen mode

配置模块

对于配置,我们可以使用 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;
}
Enter fullscreen mode Exit fullscreen mode

电子邮件配置界面

// email-config.interface.ts

interface IEmailAuth {
  user: string;
  pass: string;
}

export interface IEmailConfig {
  host: string;
  port: number;
  secure: boolean;
  auth: IEmailAuth;
}
Enter fullscreen mode Exit fullscreen mode

配置界面

// 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;
}
Enter fullscreen mode Exit fullscreen mode

创建配置函数:

// 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,
    }),
  };
}
Enter fullscreen mode Exit fullscreen mode

安装配置模块:

$ yarn add @nestjs/config joi
Enter fullscreen mode Exit fullscreen mode

创建验证模式:

// 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(),
});
Enter fullscreen mode Exit fullscreen mode

最后将其导入到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 {}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

并在我们的package.json文件上添加配置文件:

{
  "...": "...",
  "mikro-orm": {
    "useTsNode": true,
    "configPaths": [
      "./src/mikro-orm.config.ts",
      "./dist/mikro-orm.config.js"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

为了在我们的文件夹中使用它,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');
  }
}
Enter fullscreen mode Exit fullscreen mode

并在我们的文件上异步注册模块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 {}
Enter fullscreen mode Exit fullscreen mode

通用模块

我喜欢有一个用于实体验证、错误处理和字符串操作的通用全局模块。

对于实体和输入验证,我们将使用类验证器,如文档中所述:

$ yarn add class-transformer class-validator
Enter fullscreen mode Exit fullscreen mode

并将其添加到主文件:

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();
Enter fullscreen mode Exit fullscreen mode

创建模块和服务:

$ nest g mo common
$ nest g s common
Enter fullscreen mode Exit fullscreen mode

common文件夹中将装饰器添加Global到模块并导出服务:

import { Global, Module } from '@nestjs/common';
import { CommonService } from './common.service';

@Global()
@Module({
  providers: [CommonService],
  exports: [CommonService],
})
export class CommonModule {}
Enter fullscreen mode Exit fullscreen mode

我个人喜欢将所有正则表达式放在 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}/;
Enter fullscreen mode Exit fullscreen mode

另一件事是用于常见检查的实用函数,创建一个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;
Enter fullscreen mode Exit fullscreen mode

公共服务

首先添加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);
  }
}
Enter fullscreen mode Exit fullscreen mode

我们需要以下方法:

实体验证器

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'));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

实体动作

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));
  }

}
Enter fullscreen mode Exit fullscreen mode

字符串操作

首先安装slugify

$ yarn add slugify
Enter fullscreen mode Exit fullscreen mode

现在添加方法:

// ...
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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

消息生成

有些端点必须返回单个消息字符串,对于这些类型的端点,我喜欢用 id 创建消息接口,这样更容易在前端进行过滤,创建一个interfaces文件夹并添加以下文件:

// message.interface.ts

export interface IMessage {
  id: string;
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

并为其创建一个方法:

// ...
import { v4 } from 'uuid';
// ...

@Injectable()
export class CommonService {
  // ...

  public generateMessage(message: string): IMessage {
    return { id: v4(), message };
  }
} 
Enter fullscreen mode Exit fullscreen mode

用户模块

在创建 auth 模块之前,我们需要一种对用户执行 CRUD 操作的方法,因此创建一个新的用户模块和服务:

$ nest g mo users
$ nest g s users
Enter fullscreen mode Exit fullscreen mode

用户实体

在创建实体之前,我们需要一个包含我们所需用户的界面,创建一个interfaces文件夹和user.interface.ts文件:

export interface IUser {
  id: number;
  name: string;
  username: string;
  email: string;
  password: string;
  confirmed: boolean;
  createdAt: Date;
  updatedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

现在在实体上实现它,首先创建一个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();
}
Enter fullscreen mode Exit fullscreen mode

添加实体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 {}
Enter fullscreen mode Exit fullscreen mode

用户版本控制

出于安全目的,我们需要能够对用户凭证(例如密码更改)进行版本控制,因此如果他们更改任何凭证,我们就可以撤销所有刷新令牌。

我们通过Credentials为用户创建一个 JSON 参数来实现这一点。首先创建它的接口:

// credentials.interface.ts

export interface ICredentials {
  version: number;
  lastPassword: string;
  passwordUpdatedAt: number;
  updatedAt: number;
}
Enter fullscreen mode Exit fullscreen mode

在我们的 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

并更新我们的用户界面和实体:

// 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();

  // ...
}
Enter fullscreen mode Exit fullscreen mode

用户服务

用户服务将主要涵盖我们的用户 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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

用户阅读

我们需要三种读取方法:

  1. 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;
      }
    
      // ...
    }
    
  2. 电子邮件:主要用于身份验证,通过电子邮件获取用户

    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');
        }
      }
    
      // ...
    }
    
  3. 凭证:用于令牌生成和验证

    // ...
    
    @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;
      }
    
      // ...
    }
    
  4. 用户名:用于获取用户和身份验证

    // ...
    
    @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;
}
Enter fullscreen mode Exit fullscreen mode

还有一个用于改变用户的其余部分:

// 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;
}
Enter fullscreen mode Exit fullscreen mode

由于用户是我们 API 上的关键实体,因此我喜欢将更新分为几种方法,因此有端点/变异:

  1. 用户更新:

    // ...
    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');
        }
      }
    
      // ...
    }
    
  2. 电子邮件更新:

    // ...
    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;
      }
    
      // ...
    }
    
  3. 密码更新和重置:

    // ...
    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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

JWT 模块

尽管 nestjs 有自己的JwtService,但我们需要针对我们拥有的各种类型的令牌自定义一个:

$ nest g mo jwt
$ nest g s jwt
Enter fullscreen mode Exit fullscreen mode

并从模块导出服务:

import { Module } from '@nestjs/common';
import { JwtService } from './jwt.service';

@Module({
  providers: [JwtService],
  exports: [JwtService],
})
export class JwtModule {}
Enter fullscreen mode Exit fullscreen mode

代币类型

枚举

创建一个enums文件夹并添加以下枚举:

export enum TokenTypeEnum {
  ACCESS = 'access',
  REFRESH = 'refresh',
  CONFIRMATION = 'confirmation',
  RESET_PASSWORD = 'resetPassword',
}
Enter fullscreen mode Exit fullscreen mode

接口

每个令牌都从前一个令牌扩展而来,创建一个interfaces文件夹并为每种类型的令牌添加一个接口。

基础代币

所有令牌都会有一个iat(发行时间)、exp(到期时间)、iss(发行者)、aud(受众)和sub(主题)字段,因此我们需要为所有令牌建立一个基础:

// token-base.interface.ts

export interface ITokenBase {
  iat: number;
  exp: number;
  iss: string;
  aud: string;
  sub: string;
}
Enter fullscreen mode Exit fullscreen mode

访问令牌

访问令牌将仅包含用户的 ID:

// access-token.interface.ts

import { ITokenBase } from './token-base.interface';

export interface IAccessPayload {
  id: number;
}

export interface IAccessToken extends IAccessPayload, ITokenBase {}

Enter fullscreen mode Exit fullscreen mode

电子邮件令牌

电子邮件令牌将包含用户的 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 {}
Enter fullscreen mode Exit fullscreen mode

刷新令牌

刷新令牌将包含用户的 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 {}
Enter fullscreen mode Exit fullscreen mode

服务

首先注入ConfigServiceCommonService

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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

由于 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);
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

开始设置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');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
            },
          ),
        );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

然后创建一个方法来验证和解码我们的令牌:

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'],
          }),
        );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

邮件模块

为了确认用户身份并重置密码,我们需要发送电子邮件。首先安装 nodemailer 和 handlebars:

$ yarn add nodemailer handlebars
$ yarn add @types/nodemailer @types/handlebars
Enter fullscreen mode Exit fullscreen mode

创建邮件模块和服务:

$ nest g mo mailer
$ nest g s mailer
Enter fullscreen mode Exit fullscreen mode

并从模块导出服务:

import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';

@Module({
  providers: [MailerService],
  exports: [MailerService],
})
export class MailerModule {}
Enter fullscreen mode Exit fullscreen mode

模板

我们将使用 handlebars 创建确认和密码重置的模板。

接口

创建interfaces文件夹并添加模板数据的接口:

// template-data.interface.ts

export interface ITemplatedData {
  name: string;
  link: string;
}
Enter fullscreen mode Exit fullscreen mode

我们将拥有以下模板:

// templates.interface.ts

import { TemplateDelegate } from 'handlebars';
import { ITemplatedData } from './template-data.interface';

export interface ITemplates {
  confirmation: TemplateDelegate<ITemplatedData>;
  resetPassword: TemplateDelegate<ITemplatedData>;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

还有一个用于密码重置:

<!-- 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> 
Enter fullscreen mode Exit fullscreen mode

要编译模板,您需要添加assets一个nest-cli.json

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      "mailer/templates/**/*"
    ],
    "watchAssets": true
  }
}
Enter fullscreen mode Exit fullscreen mode

服务

首先导入ConfigService

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MailerService {
  constructor(private readonly configService: ConfigService) {}
}
Enter fullscreen mode Exit fullscreen mode

并添加电子邮件客户端配置以及记录器:

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

如您所见,我们尚未添加模板,首先创建一个解析器方法:

// ...
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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

并将我们的模板添加到配置中:

// ...
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'),
    };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

电子邮件应该异步发送,因此请创建使用以下符号的公共方法.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));
  }
}
Enter fullscreen mode Exit fullscreen mode

并且我们的两个模板各有一个方法:

// ...
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.',
    );
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

授权模块

创建授权模块:

$ nest g mo auth
$ nest g s auth
Enter fullscreen mode Exit fullscreen mode

实体

auth 模块只有一个实体,即黑名单令牌,在interfaces文件夹上创建其接口:

// blacklisted-token.interface.ts

import { IUser } from '../../users/interfaces/user.interface';

export interface IBlacklistedToken {
  tokenId: string;
  user: IUser;
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

现在只需在文件夹的实体上实现它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];
}
Enter fullscreen mode Exit fullscreen mode

tokenId它具有和ID的复合键user

将实体添加到模块以及UserModelJwtModuleMailerModule

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 {}
Enter fullscreen mode Exit fullscreen mode

服务

首先注入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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

注册 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;
}
Enter fullscreen mode Exit fullscreen mode

登录 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;
}
Enter fullscreen mode Exit fullscreen mode

电子邮件 DTO

仅包含用于发送密码重置电子邮件的电子邮件的 dto。

// email.dto.ts

export abstract class EmailDto {
  @IsString()
  @IsEmail()
  @Length(5, 255)
  public email: string;
}
Enter fullscreen mode Exit fullscreen mode

重置密码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;
}
Enter fullscreen mode Exit fullscreen mode

更改密码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;
}
Enter fullscreen mode Exit fullscreen mode

接口

大多数身份验证服务方法将返回相同的三个字段:

  • 用户;
  • 访问令牌;
  • 刷新令牌。

因此创建一个名为的接口IAuthResult

// auth-result.interface.ts

import { IUser } from '../../users/interfaces/user.interface';

export interface IAuthResult {
  user: IUser;
  accessToken: string;
  refreshToken: string;
}
Enter fullscreen mode Exit fullscreen mode

方法

我们将首先创建一个私有方法,以便利用以下方法更快地生成访问和刷新令牌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,
      ),
    ]);
  } 
}
Enter fullscreen mode Exit fullscreen mode

注册方式

// ...
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');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

登录方式

// ...
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');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

刷新令牌访问

// ...

@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');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

登出

// ...

@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,
    );
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

重置密码邮箱

// ...
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');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

重置密码

// ...

@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');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

更改密码

// ...

@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 };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

可选部分

使用 Redis 缓存进行优化

由于令牌会过期,将其永久保存在磁盘上会非常浪费,因此最好将其保存在缓存中。NestJS 自带了缓存,CacheModule因此请先安装以下软件包:

$ yarn add cache-manager ioredis cache-manager-redis-yet
Enter fullscreen mode Exit fullscreen mode

配置

添加config.interface.tsioredis选项:

// ...
import { RedisOptions } from 'ioredis';

export interface IConfig {
  // ...
  redis: RedisOptions;
}
Enter fullscreen mode Exit fullscreen mode

现在大多数托管的 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),
  };
};
Enter fullscreen mode Exit fullscreen mode

将 redis URL 添加到.env文件(如果使用 docker-compose,则添加到其中)、模式和index文件:

REDIS_URL='redis://localhost:6379'
Enter fullscreen mode Exit fullscreen mode
// 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(),
});
Enter fullscreen mode Exit fullscreen mode

最后,为了能够使用,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,
      }),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

并将其添加到app.module.ts

// ...
import { CacheModule, Module } from '@nestjs/common';
// ...

@Module({
  imports: [
    // ...
    CacheModule.registerAsync({
      isGlobal: true,
      imports: [ConfigModule],
      useClass: CacheConfig,
    }),
    // ...
  ],
  // ...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

授权模块

首先删除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 {}
Enter fullscreen mode Exit fullscreen mode

授权服务

首先更改blacklistedTokensRepository缓存管理器:

import {
  // ...
  CACHE_MANAGER,
  // ...
} from '@nestjs/common';
import { Cache } from 'cache-manager';
// ...

@Injectable()
export class AuthService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
    // ...
  ) {}

  // ...
}
Enter fullscreen mode Exit fullscreen mode

现在更新该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),
      );
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

如您所见,我们使用典型的 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');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

结论

通过它,您可以为完整的本地身份验证类型的 API 创建基础。

本教程的完整代码可以在这个repo上找到。

关于作者

大家好!我是 Afonso Barracha,您信赖的计量经济学家,因对 GraphQL 情有独钟而进入了后端开发领域。如果您喜欢这篇文章,不妨请我喝杯咖啡,表达一下您的喜爱。

最近,我一直在深入研究一些更高级的主题。因此,我把每周分享想法的习惯改为每月发布一到两次。这样,我就能确保为大家带来尽可能高质量的内容。

不要错过我的任何最新文章——请在devLinkedIn上关注我,获取最新资讯。我非常欢迎您加入我们不断壮大的社区!期待与您相见!

文章来源:https://dev.to/tugascript/nestjs-authentication-with-oauth20-configuration-and-operations-41k
PREV
你的第一次开发者求职经历:糟透了,而且你并不孤单。第一部分
NEXT
理解 ES6 JavaScript 中的生成器