[NestJS 带你飞!] DAY23 - Authentication (上)

相信各位在使用各大网站提供的功能时,都会需要注册帐号来获得更多的使用体验,比如:google、facebook 等,这种帐户机制可以说是非常重要的一环,在现今的应用上已经可以视为标配。

而一个应用程序可能会有非常多种的注册方式,比如:本地帐号注册方式、使用 facebook 注册、使用 google 注册等,每一种帐号注册方式都有一套自己的 策略(Strategy),那要怎麽管理各种 帐户验证(Authentication) 的策略也是非常重要的,我们会希望各种策略都能采用同一套标准来进行开发,这时候就可以透过一些工具来辅助我们处理这件事,在 node.js 圈子中,最热门的帐户验证管理工具即 Passport.js (简称:passport),而 Nest 也有将其包装成模组,让开发人员轻松在 Nest 中使用 passport,模组名称为 PassportModule

passport 介绍

passport 采用了 策略模式 来管理各种验证方式,它主要由两个部分构成整个帐户验证程序,分别为:passportpassport strategypassport 本身是用来处理 验证流程 的,而 passport strategy 则是 验证机制,两者缺一不可,整个 passport 生态系有上百种的验证机制让开发人员使用,如:facebook 验证策略、google 验证策略、本地验证策略等,完美解决各种验证机制的处理。
https://ithelp.ithome.com.tw/upload/images/20210701/20119338qiADpV5Im1.png

在 Nest 中,passport strategy 会与 Guard 进行搭配,透过 AuthGuardstrategy 包装起来,就可以透过 Nest 的 Guard 机制来与 passport 做完美的搭配!
https://ithelp.ithome.com.tw/upload/images/20210706/20119338WcjWCkYcvr.png

安装 passport

透过 npm 来安装 passport,需要安装 Nest 包装的模组以及 passport 本身:

$ npm install @nestjs/passport passport

注意:目前仅安装了 passport,前面有提到还需要 passport strategy 来满足完整的验证程序,这部分後面会再额外进行安装。

实作帐户注册

在开始实作帐户验证之前,需要先设计一个帐户注册的 API,好让使用者可以顺利注册成为会员,这里我们以 MongoDB 作为资料库,并使用上一篇的技巧来完成资料库的操作。

注意:这里会略过 MongooseModuleAppModule 注册的部分,详情可以参考上一篇的「连线 MongoDB」。

定义 schema

既然是帐户注册,那就跟「使用者」的资料息息相关,故我们要建立一个名为 Userschema 来定义使用者的资料结构。我们在 src/common/models 下新增一个名为 user.model.ts 的档案,并将使用者的 schemaDocumentschema 实体 与 ModelDefinition 在这里做定义:

import { ModelDefinition, Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop({
    required: true,
    minlength: 6,
    maxlength: 16
  })
  username: string;
  
  @Prop({
    required: true
  })
  email: string;

  @Prop({
    type: raw({
      hash: String,
      salt: String
    }),
    required: true
  })
  password: Record<string, any>;
}

export const UserSchema = SchemaFactory.createForClass(User);

export const USER_MODEL_TOKEN = User.name;

export const UserDefinition: ModelDefinition = {
  name: USER_MODEL_TOKEN,
  schema: UserSchema,
};

可以看到共设计了三个栏位,分别为:usernameemailpassword,其中,password 为巢状结构,原因是我们不希望密码直接储存在资料库里面,而是透过密码学中的加盐来替密码进行加密。

盐加密

https://ithelp.ithome.com.tw/upload/images/20210701/20119338HLYbGc9lX2.png

盐加密经常用在密码管理,它的概念很简单,就是将 输入值(input)某个特定的值(salt) 进行加密,最後会得出一个 结果(hash),只要将 salthash 存入资料库就可以避免把原始密码直接储存的问题,不过为什麽是储存这两个值呢?这就要解释一下解密的原理了,使用者在登入的时候,会提供我们 usernamepassword 这两个值,这时候我们就要用使用者提供的 username 去找出对应的使用者资料,如果有找到的话就要来验证 password 的值是否正确,我们只要用 passwordsalt 再进行一次加密,并用计算出来的值跟 hash 做比对,如果完全相同就表示这个使用者提供的密码与当初在注册时提供的密码是相同的。

我们来实作一个共用的方法来处理盐加密,在 src/core/utils 下新增一个 common.utility.ts 档案,并设计一个静态方法 encryptBySalt,它有两个参数:inputsalt,其中,salt 的预设值为 randomBytes 计算出来的值,而 inputsalt 透过 pbkdf2Sync 进行 SHA-256 加密并迭代 1000 次,最终返回 hashsalt

import { randomBytes, pbkdf2Sync } from 'crypto';

export class CommonUtility {

  public static encryptBySalt(
    input: string,
    salt = randomBytes(16).toString('hex'),
  ) {
    const hash = pbkdf2Sync(input, salt, 1000, 64, 'sha256').toString('hex');
    return { hash, salt };
  }

}

模组设计

在完成 schema 与加密演算之後,就可以来设计注册的 API 了,我们会需要建立两个模组:UserModuleAuthModuleUserModule 是用来处理与使用者相关的操作,而 AuthModule 则是处理与身分验证有关的操作,基本上 AuthModule 必定与 UserModule 产生依赖,因为要有使用者才有办法做身分验证!

使用者模组

透过 CLI 在 src/features 下产生 UserModuleUserService

$ nest generate module features/user
$ nest generate service features/user

UserModule 因为要对使用者资料进行操作,需要使用 MongooseModule 来建立 model,又因为 AuthModule 会依赖於 UserModule 去操作使用者资料,故我们要将 UserService 汇出让 AuthModule 可以透过 UserService 去操作使用者资料:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { UserDefinition } from '../../common/models/user.model';
import { UserService } from './user.service';

@Module({
  imports: [MongooseModule.forFeature([UserDefinition])],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

根据我们设计的使用者资料结构,我们可以设计一个 DTO 来给定参数型别与进行简单的资料验证,在 src/features/user/dto 下新增 create-user.dto.ts

import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @MinLength(6)
  @MaxLength(16)
  public readonly username: string;

  @MinLength(8)
  @MaxLength(20)
  public readonly password: string;

  @IsNotEmpty()
  public readonly email: string;
}

我们直接在 AppModule 透过依赖注入的方式来启用 ValidationPipe

import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './features/user/user.module';
import { AuthModule } from './features/auth/auth.module';

import MongoConfigFactory from './config/mongo.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [MongoConfigFactory],
      isGlobal: true
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
      }),
    }),
    UserModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
    { // 注入全域 Pipe
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

最後,我们要在 UserService 注入 model 并设计 createUser(user: CreateUserDto) 方法来建立使用者,其中,password 需要透过盐加密来处理:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument, USER_MODEL_TOKEN } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  createUser(user: CreateUserDto) {
    const { username, email } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    return this.userModel.create({
      username,
      email,
      password,
    });
  }
}

如果觉得 import 时的路径太繁琐,可以做一个 index.ts 来将对外的部分做统一的汇出:

export { UserModule } from './user.module';
export { UserService } from './user.service';
export { CreateUserDto } from './dto/create-user.dto';

验证模组

透过 CLI 在 src/features 下产生 AuthModuleAuthController

$ nest generate module features/auth
$ nest generate controller features/auth

AuthModule 中汇入 UserModule

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UserModule } from '../user';

@Module({
  imports: [UserModule],
  controllers: [AuthController],
})
export class AuthModule {}

接着,在 AuthController 设计一个 [POST] /auth/signup 的 API,并调用 UserServicecreateUser(user: CreateUserDto) 方法来建立使用者:

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto, UserService } from '../user';

@Controller('auth')
export class AuthController {
  constructor(private readonly userService: UserService) {}

  @Post('/signup')
  signup(@Body() user: CreateUserDto) {
    return this.userService.createUser(user);
  }
}

透过 Postman 进行测试:
https://ithelp.ithome.com.tw/upload/images/20210705/20119338Ca3i3PBpO3.png

实作本地帐户登入

帐户验证与登入息息相关,在登入的过程中,会进行一些帐号密码的检测,检测通过之後便完成登入程序。本地帐户登入可以使用 passport-local 这个 strategypassport 进行搭配,透过 npm 进行安装即可:

$ npm install passport-local
$ npm install @types/passport-local -D

实作策略

首先,我们需要先在 UserService 添加一个 findUser 方法来取得使用者资料,用途是让使用者输入 usernamepassword 後,可以去资料库中寻找对应的使用者:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { FilterQuery, Model } from 'mongoose';

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument, USER_MODEL_TOKEN } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  createUser(user: CreateUserDto) {
    const { username, email } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    return this.userModel.create({
      username,
      email,
      password,
    });
  }

  findUser(filter: FilterQuery<UserDocument>) {
    return this.userModel.findOne(filter).exec();
  }
}

透过 CLI 产生 AuthService 来处理检测帐户的工作:

$ nest generate service features/auth

AuthService 设计一个 validateUser(username: string, password: string) 的方法,先透过 username 寻找对应的使用者资料,再针对使用者输入的密码与 salt 进行盐加密,如果结果与资料库中的 hash 相同,就回传使用者资料,否则回传 null

import { Injectable } from '@nestjs/common';
import { CommonUtility } from '../../core/utils/common.utility';
import { UserService } from '../user';

@Injectable()
export class AuthService {

  constructor(private readonly userService: UserService) {}
  
  async validateUser(username: string, password: string) {
    const user = await this.userService.findUser({ username });
    const { hash } = CommonUtility.encryptBySalt(password, user?.password?.salt);
    if (!user || hash !== user?.password?.hash) {
      return null;
    }
    return user;
  }

}

完成了使用者验证的方法後,就要来将它与 passport 的机制接上,我们需要建立一个 Provider 来作为 strategy,透过该 strategy 即可与 passport 进行介接。

src/features/auth 底下建立一个 stratgies 资料夹并建立 local.strategy.ts,在这个档案中实作一个 LocalStrategyclass,需特别注意的是该 class 要继承 passport-localstrategy,但需要透过 Nest 制作的 function 来与它做串接,并实作 validate(username: string, password: string) 方法,该方法即为 passport 流程的 进入点,在这里我们就用呼叫刚刚在 AuthService 实作的方法来进行帐号验证:

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';

import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  
  constructor(private readonly authService: AuthService) {
    super();
  }

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return { username: user.username, email: user.email };
  }

}

别忘了在 AuthModule 汇入 PassportModule 与在 providers 里面添加 LocalStrategy

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';

import { AuthController } from './auth.controller';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';

@Module({
  imports: [
    PassportModule,
    UserModule
  ],
  controllers: [
    AuthController
  ],
  providers: [
    AuthService,
    LocalStrategy
  ],
})
export class AuthModule {}

使用 AuthGuard

实作完 stragegy 以後,就要实作一个 API 来处理登入验证,我们在 AuthController 添加一个 signin 方法并套用 AuthGuard,因为我们是使用 passport-local 这个 strategy,所以要在 AuthGuard 带入 local 这个字串,passport 会自动与 LocalStrategy 进行串接,然後 passport 会将 LocalStrategyvalidate 方法回传的值写入 请求物件user 属性中,这样就可以在 Controller 中使用该使用者的资讯:

import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

import { Request } from 'express';

import { CreateUserDto, UserService } from '../user';

@Controller('auth')
export class AuthController {
  constructor(private readonly userService: UserService) {}

  @Post('/signup')
  signup(@Body() user: CreateUserDto) {
    return this.userService.createUser(user);
  }

  @UseGuards(AuthGuard('local'))
  @Post('/signin')
  signin(@Req() request: Request) {
      return request.user;
  }
}

透过 Postman 进行测试,会顺利得到对应的使用者资料:
https://ithelp.ithome.com.tw/upload/images/20210706/20119338NDRSU2EStw.png

小结

今天我们实作了注册与登入的功能并且初步了解到 passport 在 Nest 中如何使用,不过这些都只算是 登入前 的处理,还有 登入後 要怎麽保持授权状态等步骤要处理,这部分就留到下篇跟各位详细说明吧!这里附上今天的懒人包:

  1. passport 采用策略模式来管理各种帐户验证的方式。
  2. passport 需要搭配 strategy 来完成完整的帐户验证程序。
  3. AuthGuard 作为 Guard 并指定要使用哪个 strategy 进行验证。
  4. 储存密码不可以明码储存,建议使用盐加密处理。
  5. 运用 passport-local 来处理本地帐户登入的验证。

<<:  day 30 - 结语

>>:  Flexbox-30天学会HTML+CSS,制作精美网站

Day16-"与字串相关的函式-2"

复制字串 i.strcpy() 宣告时宣告另一空字元字串,当strcpy()执行完毕时,就会将此字...

[Day13] 使用OpenCV & Dlib作人脸侦测需要知道的一些事

本文开始 回顾一下过去四天提到的,使用OpenCV & Dlib做人脸侦测的方法: Open...

Day29 测试写起乃 - 加入到 Travis CI

写完测试当然要加入到 CI 里做自动化测试拉! 但本人也是第一次串所以见谅见谅 这次我们使用 Tra...

[Day09] 团队系统设计 - PO 系统

上篇文章中,我提出了一个「规画系统」,其系统的起始点,是由 PO 与 Designer 组成的子系统...

冒险村12 - rescue exception

12 - rescue exception 异常处理在开发过程中时常可见,举例来说 Rails 中找...