iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 21
0
Software Development

今晚我想來點 Express 佐 MVC 分層架構系列 第 21

[今晚我想來點 Express 佐 MVC 分層架構] DAY 21 - 實作帳戶機制 (上)

大部分的系統都會有帳戶機制,其中涉及的部分不外乎就是註冊、登入與驗證,這部分內容較多,我會拆成上下兩篇,這篇會先實作註冊帳號的部分。

規劃路由

不曉得大家還記不記得前面有一張圖裡面有一個 ? Route,那正是驗證機制所需要的路由,我們叫它 Auth Route,又因為一個應用可能會有多種驗證機制,舉例來說:可以用本地帳戶、Google 或 Facebook 進行登入,所以這裡可以再將路由切細一點,切出一個 LocalAuthRoute 來實作本地帳戶,如下圖:
https://ithelp.ithome.com.tw/upload/images/20200907/20119338f8DfGVM9LP.png

新增 Model

實作本地帳戶驗證機制一定會依賴資料庫,所以我們來規劃 Schema 與製作 Model。下方為 local-auth.model.ts 的程式碼:

import { model, Schema } from 'mongoose';
import { CoreDocument } from '../types/model.type';
import { EmailValidator } from '../validators';

const LocalAuthSchema = new Schema(
  {
    username: {
      type: String,
      required: true,
      minlength: 3,
      maxlength: 12
    },
    password: {
      salt: {
        type: String,
        required: true
      },
      hash: {
        type: String,
        required: true
      }
    },
    email: {
      type: String,
      required: true,
      validate: {
        validator: EmailValidator
      }
    }
  }
);

export interface LocalAuthDocument extends CoreDocument {
  username: string;
  password: {
    salt: string;
    hash: string;
  };
  email: string;
}

export const LocalAuthModel = model<LocalAuthDocument>('User', LocalAuthSchema);

可以看到 password 的欄位並不是直接儲存的,而是有 salthash 兩個欄位,原因是密碼屬於較敏感的資訊,必須加密再儲存進資料庫當中,而 salt 就是替密碼加鹽,能夠大幅提升被攻破的難度!

新增 Repository

在設計完 LocalAuthModel 後,就來新增 LocalAuthRepository 吧!新增一個 local-auth.repository.ts 並實作 addUsergetUser 方法:

import crypto from 'crypto';
import { LocalAuthModel, LocalAuthDocument } from '../models/local-auth.model';

export class LocalAuthRepository {

  public async addUser(
    username: string,
    password: string,
    email: string
  ): Promise<LocalAuthDocument> {
    const { salt, hash } = this.hashPassword(password);
    const user = new LocalAuthModel({
      username,
      password: { salt, hash },
      email
    });
    const document = await user.save();
    return document;
  }

  public async getUser(
    options: { username?: string, email?: string } 
  ): Promise<LocalAuthDocument | null> {
    const params = Object.keys(options)
                   .filter(key => !!(options as any)[key])
                   .map(key => {
                     return { [key]: (options as any)[key] };
                   });
    const getCondition = () => {
      if ( params.length > 1 ) {
        return {
          $or: params
        };
      }
      return params[0];
    };
    const user = await LocalAuthModel.findOne(getCondition());
    return user;
  }

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

}

可以看到 addUser 方法有使用到 hashPassword,這就是替密碼加鹽,我的實作方法是沒有指定 salt 的情況下會是隨機字串,並把傳入的 passwordsaltsha256 進行迭代,最後再傳回結果。另外,getUserusernameemail 兩種查詢方式,這樣就能更有彈性地找出使用者資訊。

新增路由

整理一下資料夾結構,並將路由相關的檔案也一併列上來:

├── src
|   ├── index.ts
|   ├── app.ts
|   ├── app.routing.ts
|   ├── + bases
|   ├── + common/resonse
|   ├── + exceptions
|   ├── main
|   |   ├── + api
|   |   └── auth
|   |       ├── auth.routing.ts               //本篇新增
|   |       └── local
|   |           ├── local-auth.service.ts     //本篇新增
|   |           ├── local-auth.controller.ts  //本篇新增
|   |           └── local-auth.routing.ts     //本篇新增
|   ├── models
|   |   ├── local-auth.model.ts               //本篇新增
|   |   └── todo.model.ts
|   ├── repositories
|   |   ├── local-auth.repository.ts          //本篇新增
|   |   └── todo.repository.ts                //本篇新增
|   ├── + dtos
|   ├── + types
|   ├── + environments
|   ├── + database
|   └── + validators
├── package.json
└── tsconfig.json

下方為 local-auth.service.ts 的程式碼:

import { LocalAuthRepository } from '../../../repositories/local-auth.repository';

import { HttpStatus } from '../../../types/response.type';

export class LocalAuthService {

  private readonly localAuthRepo = new LocalAuthRepository();

  public async addUser(username: string, password: string, email: string) {
    const isUsed = await this.localAuthRepo.getUser({ username, email });
    if ( isUsed ) {
      const error = new Error('使用者名稱或電子信箱已被使用');
      (error as any).status = HttpStatus.CONFLICT;
      throw error;
    }
    const user = await this.localAuthRepo.addUser(username, password, email);
    return user;
  }

}

下方 local-auth.controller.ts 的程式碼:

import { Request } from 'express';

import { ControllerBase } from '../../../bases/controller.base';

import { LocalAuthService } from './local-auth.service';

import { ResponseObject } from '../../../common/response/response.object';

import { HttpStatus } from '../../../types/response.type';


export class LocalAuthController extends ControllerBase {

  protected readonly localAuthSvc = new LocalAuthService();

  public async signup(req: Request): Promise<ResponseObject> {
    const { username, password, email } = req.body;
    const user = await this.localAuthSvc.addUser(username, password, email);
    return this.formatResponse(user, HttpStatus.CREATED);
  }

}

下方為 local-auth.routing.ts 的程式碼:

import express from 'express';

import { RouteBase } from '../../../bases/route.base';
import { LocalAuthController } from './local-auth.controller';

export class LocalAuthRoute extends RouteBase {

  protected controller!: LocalAuthController;

  protected initial(): void {
    this.controller = new LocalAuthController();
    super.initial();
  }

  protected registerRoute(): void {
    this.router.post('/signup',
      express.json(),
      this.responseHandler(this.controller.signup)
    );
  }

}

查看結果

使用 Postman 進行帳號註冊:
https://ithelp.ithome.com.tw/upload/images/20200907/20119338RyNYtXjwNl.png

可以看到 User 的資料表示成功!

小結

本篇實作了註冊帳戶的部分,下篇會開始實作登入與驗證機制,並實現 token 的機制,敬請期待!


上一篇
[今晚我想來點 Express 佐 MVC 分層架構] DAY 20 - Repository Pattern
下一篇
[今晚我想來點 Express 佐 MVC 分層架構] DAY 22 - 實作帳戶機制 (下)
系列文
今晚我想來點 Express 佐 MVC 分層架構30

尚未有邦友留言

立即登入留言