iT邦幫忙

2024 iThome 鐵人賽

DAY 15
2
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 15

nestjs 系統設計 - 活動訂票管理系統 - User Module part2

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - User Module part2

目標

接續著昨天的 UserModule 部份

image

今天目標會繼續規劃關於 AuthModule 用來做登入驗證與 Jwt 驗證的部份。

概念

今天這個部份主要是關於授權與驗證。 JWT 是一個用來做授權與驗證的規範,不同於以往的 Cookie Session 機制。需要在透過瀏覽器 cookies 帶來的 session-id 來查訊當下 request 的使用者資訊。 JWT 是以授權的方式,讓使用者在通過身份驗證後,讓伺服器核發一個授權的 token 。每次針對 header 傳入的 token 做授權驗證。

JWT token 組成主要分為三大部份:

  1. Header: 用來存放 Token 的一些簽章規格,比如使用的演算法。
  2. Payload: 通常用來存放 Token 的授權內容以及驗證 id,比如授權身份,授權範圍, token 核發者, token 過期時間,使用者 id 。
  3. Signature: 把 Payload 的部份使用密鑰做簽章,用來驗證該 Payload 是否與簽章一致。

使用 JWT token ,為了讓客戶端透過 Mobile 來做登入驗證。不像 cookie session 需要,瀏覽器的 cookie 來做驗證資訊識別子存放。

設計規範

  1. 使用者登入系統之後,系統必須能夠讀取到登入資訊
  2. 當使用者無法做出不符合權限的操作。
  3. 當使用者必須要定時去更新過期的 token

設計

這邊會採用 passportjs 這個多人使用的驗證框架來實作。因為 passportjs 具有完整的驗證架構,且方便更換驗證方式。

驗證流程

image

先撰寫驗證

為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫

  1. authService
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UserEntity } from '../users/schema/user.entity';
import { isJWT } from 'class-validator';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';

describe('AuthService', () => {
  let service: AuthService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [AuthService, {
        provide: ConfigService,
        useValue: {
          get: (key: string): any => {
            switch (key) {
              case 'JWT_ACCESS_TOKEN_SECRET':
                return '123';
              case 'JWT_ACCESS_TOKEN_EXPIRATION_MS':
                return 1;
              case 'JWT_REFRESH_TOKEN_SECRET':
                return '246';
              case 'JWT_REFRESH_TOKEN_EXPIRATION_MS':
                return 1;
              default:
                throw new Error('not exist key');  
            }
          }
        },
      }, JwtService],
    }).compile();

    service = module.get<AuthService>(AuthService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
  // given login credential with correct user
  it('should be return access token and refresh token', async () => {
    const user = new UserEntity();
    user.email = 'test@gmail.com';
    user.id = crypto.randomUUID();
    const result = await service.login(user);
    expect(result).toHaveProperty('access_token');
    expect(result).toHaveProperty('refresh_token');
    expect(isJWT(result.access_token)).toBeTruthy();
    expect(isJWT(result.refresh_token)).toBeTruthy();
  });
  	
  // given with not existed user
  it('should be rejected with not found error ', async () => {
    const user = new UserEntity();
    user.email = 'test1@gmail.com';
    user.id = crypto.randomUUID();
    expect(service.login(user)).rejects.toThrow(NotFoundException);
  });
  // given a exist user
  it('should verifyUser with user entity', async () => {
    const email = 'test@gmail.com';
    const password = '@1#$aB%22^';
    await userService.createUser({
      email: email,
      password: password
    });
    const result = await service.verifyUser(email, password);
    expect(result).toHaveProperty('email', 'test@gmail.com');
  });
  // given a exist user, but wrong credential
  it('should verifyUser with Unauthorization Exception', async () => {
    const email = 'test@gmail.com';
    const password = '@1#$aB%22^';
    await userService.createUser({
      email: email,
      password: password
    });
    await expect(service.verifyUser(email, '1232')).rejects.toThrow(UnauthorizedException);
  });
  // given a exist refreshToken, and userId
  it('should refreshToken with origin user', async () => {
    const email = 'test@gmail.com';
    const password = '@1#$aB%22^';
    const object = await userService.createUser({
      email: email,
      password: password
    });
    const user = await userService.findUser({ id: object.id });
    const loginResult = await service.login(user);
    const result = await service.refreshToken(loginResult.refresh_token, object.id);
    expect(result.email).toEqual(email);
  })
  // given a exist refreshToken, and userId, but wrong refresh token
  it('should refreshToken reject with Uauthorization', async () => {
    const email = 'test@gmail.com';
    const password = '@1#$aB%22^';
    const object = await userService.createUser({
      email: email,
      password: password
    });
    const user = await userService.findUser({ id: object.id });
    const loginResult = await service.login(user);
    await expect(service.refreshToken(loginResult.refresh_token.replace('1','2'), user.id)).rejects.toThrow(UnauthorizedException);
  })
});
  1. authController
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { UsersService } from '../users/users.service';
import { UserEntity } from '../users/schema/user.entity';
import { AuthService } from './auth.service';

describe('AuthController', () => {
  let controller: AuthController;
  let service: UsersService;
  let authService: AuthService;
  const mockUserService = {
    createUser: jest.fn().mockResolvedValue({})
  }
  const mockAuthService = {
    login: jest.fn().mockResolvedValue({})
  }
  afterEach(() => {
	// clear each time timer
    jest.clearAllMocks();
  })
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuthController],
      providers: [{
        provide: UsersService,
        useValue: mockUserService,
      }, {
        provide: AuthService,
        useValue: mockAuthService,
      }]
    }).compile();

    controller = module.get<AuthController>(AuthController);
    service = module.get<UsersService>(UsersService);
    authService = module.get<AuthService>(AuthService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
   /**
   given a userInfo Object
  {
    "email": "bda605@hotmail.com",
    "password": "password" 
  }
   */
  it('service.createUser should be call once', async () => {
    const userInfo = {
      email: "bda605@hotmail.com",
      password: "password" 
    };
    await controller.register(userInfo);
    expect(service.createUser).toHaveBeenCalledTimes(1);
    expect(service.createUser).toHaveBeenCalledWith(userInfo);
  });
  /**
   * given a user instance
   */
  it('authService.login should be call once', async () => {
    const user = new UserEntity();
    await controller.login(user);
    expect(authService.login).toHaveBeenCalledTimes(1);
    expect(authService.login).toHaveBeenCalledWith(user);
  })
});

實作 AuthService

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../users/schema/user.entity';
import { LoginResponseDto } from './dto/login-response.dto';
import { ConfigService } from '@nestjs/config';
import { TokenPayload } from './token-payload.interface';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
  constructor(
    private readonly configService: ConfigService,
    private readonly jwtService: JwtService,
    private readonly userService: UsersService,
  ) {}
  async login(user: UserEntity): Promise<LoginResponseDto> {
    const expiresAccessToken = new Date();
    expiresAccessToken.setTime(
      expiresAccessToken.getTime() + this.configService.get<number>('JWT_ACCESS_TOKEN_EXPIRATION_MS')
    );
    const expiresRefreshToken = new Date();
    expiresRefreshToken.setTime(
      expiresRefreshToken.getTime() + this.configService.get<number>('JWT_REFRESH_TOKEN_EXPIRATION_MS')
    );
    const tokenPayload: TokenPayload = {
      userId: user.id,
      role: user.role,
    };
    const [accessToken, refreshToken]  = await Promise.all([this.jwtService.sign(tokenPayload, {
      secret: this.configService.get<string>('JWT_ACCESS_TOKEN_SECRET'),
      expiresIn: `${this.configService.get<number>('JWT_ACCESS_TOKEN_EXPIRATION_MS')}ms`,
    }), this.jwtService.sign(tokenPayload, {
      secret: this.configService.get<string>('JWT_REFRESH_TOKEN_SECRET'),
      expiresIn: `${this.configService.get<number>('JWT_REFRESH_TOKEN_EXPIRATION_MS')}ms`,
    })]);
    // update current user with new refresh Token
    await this.userService.updateUser({id: user.id}, {refreshToken: 
      await bcrypt.hash(refreshToken, 10),
    })
    return {
      refresh_token: refreshToken,
      access_token: accessToken,
    }
  }
  async verifyUser(email: string, password: string): Promise<UserEntity> {
    try {
      const user = await this.userService.findUser({
        email
      });
      
      const authenticated = await bcrypt.compare(password, user.password);
      if (!authenticated) {
        throw new UnauthorizedException();
      }
      return user;
    } catch(error) {
      throw new UnauthorizedException('Credentials are not valid.');
    }    
  }
  async refreshToken(refreshToken: string, userId: string): Promise<UserEntity> {
    try {
      const user = await this.userService.findUser({ id: userId});
      const authenticated = await bcrypt.compare(refreshToken, user.refreshToken);
      if (!authenticated) {
        throw new UnauthorizedException();
      }
      return user;
    } catch (error) {
      throw new UnauthorizedException('Refresh Token is not valid');
    }
  }
}

實作 AuthController 部份

import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { CreateUserDto } from '../users/dto/create-user.dto';
import { UsersService } from '../users/users.service';
import { UserEntity } from '../users/schema/user.entity';
import { AuthService } from './auth.service';
import { CurrentUser } from './current-user.decorator';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { JwtRefreshAuthGuard } from './guards/jwt-refresh.guard';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly usersService: UsersService,
    private readonly authService: AuthService,
  ) {}
  @Post('register')
  async register(@Body() userInfo: CreateUserDto) {
    return await this.usersService.createUser(userInfo);
  }
  @Post('login')
  @UseGuards(LocalAuthGuard)
  async login(@CurrentUser() user: UserEntity) {
    return await this.authService.login(user);
  }
  @Post('refresh')
  @UseGuards(JwtRefreshAuthGuard)
  async refresh(@CurrentUser() user: UserEntity) {
    return await this.authService.login(user);
  }
}

實作 LocalAuthGuard 與對應的 LocalStrategy

  1. LocalAuthGuard
import { AuthGuard } from '@nestjs/passport';

export class LocalAuthGuard extends AuthGuard('local') {}
  1. LocalStrategy
import { Injectable } 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({
      usernameField: 'email'
    });
  }

  async validate(email: string, password: string) {
    return this.authService.verifyUser(email, password)
  }
}

實作 JwtRefreshGuard 與 JwtRefreshStrategy

  1. JwtRefreshGuard
import { AuthGuard } from '@nestjs/passport';

export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
  1. JwtRefreshStrategy
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthService } from '../auth.service';
import { Request } from 'express';
import { TokenPayload } from '../token-payload.interface';
@Injectable()
export class JwtFreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request: Request) => {
          const token = request.headers?.authorization as string;
          console.log({token});
          return token;
        }
      ]),
      secretOrKey: configService.get<string>('JWT_REFRESH_TOKEN_SECRET'),
      passReqToCallback: true
    });
  }
  async validate(request: Request, tokenPayload: TokenPayload) {
    return this.authService.refreshToken(request.headers?.authorization as string, tokenPayload.userId)
  }
}

新增 JwtAuthGuard 與 JwtStrategy

  1. JwtAuthGuard
import { AuthGuard } from '@nestjs/passport';

export class JwtAuthGuard extends AuthGuard('jwt') {}
  1. JwtStrategy
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { UsersService } from '../../users/users.service';
import { TokenPayload } from '../token-payload.interface';

@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly configService: ConfigService,
    private readonly usersService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request: Request) => {
          const token = request.headers?.authorization as string;
          return token;
        }
      ]),
      secretOrKey: configService.get<string>('JWT_ACCESS_TOKEN_SECRET'),
    })
  }
  async validate(tokenPayload: TokenPayload) {
    return this.usersService.findUser({id: tokenPayload.userId })
  }
}

Roles Decorator 與 RoleGuard

  1. Roles Decorator
import { Reflector } from '@nestjs/core';
import { Role } from '../users/schema/role.type';

export const Roles = Reflector.createDecorator<Role[]>();
  1. RolesGuard
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { Roles } from '../roles.decorator';
import { UserEntity } from 'src/users/schema/user.entity';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const roles = this.reflector.get(Roles, context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user as UserEntity;
    return roles.includes(user.role)
  }
}

設定 AuthModule Provider

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtFreshStrategy as JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';

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

設定 AppModule

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import { validateSchema } from './validate.schema';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: validateSchema,
    }),
    UsersModule, AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

由於篇幅的緣故,這邊先處理到 login 與 refresh 的部份。而加入 postgresql db 的部份則挪到明天的文章。

驗證 with Postman

  1. login
    image

  2. refresh
    image

https://documenter.getpostman.com/view/2441389/2sAXjGducf

結論

今天實作登入與驗證的部份,主要透過 passport 框架,用來把不同的驗證帶入不同的 strategy 。 JWT 的部份透過 nestjs/jwt 套件做到 JWT 驗證機制。

其中最重要的細節,在於把每個服務根據職責切份到每個 Module 。透過 Provider 把服務提供給需要的模組。

可以注意到有一個模組是透過全域的注入方式,也就是 Config 這種每個服務都會用到且俱備唯一性的變量。因此,這類的模組通常會以 isGlobal 設定為 true 來做全域分享,這樣做,就不需要逐個引用到需要模組內了。

今天的存儲狀態都是透過 in memory 的方式做處理,因此只要伺服器重啟,狀態就會消失不見。所以為了能夠持有化保持狀態,明天將會使用 Postgresql 這個關聯式資料庫作為儲存工具。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - User Module part 1
下一篇
nestjs 系統設計 - 活動訂票管理系統 - User Module part 3
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加26
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言