使用 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
、、和:CommonService
UsersService
JwtService
MailerService
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.ts
ioredis选项:
// ...
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