接續著昨天的 UserModule 部份
今天目標會繼續規劃關於 AuthModule 用來做登入驗證與 Jwt 驗證的部份。
今天這個部份主要是關於授權與驗證。 JWT 是一個用來做授權與驗證的規範,不同於以往的 Cookie Session 機制。需要在透過瀏覽器 cookies 帶來的 session-id 來查訊當下 request 的使用者資訊。 JWT 是以授權的方式,讓使用者在通過身份驗證後,讓伺服器核發一個授權的 token 。每次針對 header 傳入的 token 做授權驗證。
JWT token 組成主要分為三大部份:
使用 JWT token ,為了讓客戶端透過 Mobile 來做登入驗證。不像 cookie session 需要,瀏覽器的 cookie 來做驗證資訊識別子存放。
這邊會採用 passportjs 這個多人使用的驗證框架來實作。因為 passportjs 具有完整的驗證架構,且方便更換驗證方式。
為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫
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);
})
});
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);
})
});
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');
}
}
}
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);
}
}
import { AuthGuard } from '@nestjs/passport';
export class LocalAuthGuard extends AuthGuard('local') {}
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)
}
}
import { AuthGuard } from '@nestjs/passport';
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
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)
}
}
import { AuthGuard } from '@nestjs/passport';
export class JwtAuthGuard extends AuthGuard('jwt') {}
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 })
}
}
import { Reflector } from '@nestjs/core';
import { Role } from '../users/schema/role.type';
export const Roles = Reflector.createDecorator<Role[]>();
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)
}
}
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 {}
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 的部份則挪到明天的文章。
login
refresh
https://documenter.getpostman.com/view/2441389/2sAXjGducf
今天實作登入與驗證的部份,主要透過 passport 框架,用來把不同的驗證帶入不同的 strategy 。 JWT 的部份透過 nestjs/jwt 套件做到 JWT 驗證機制。
其中最重要的細節,在於把每個服務根據職責切份到每個 Module 。透過 Provider 把服務提供給需要的模組。
可以注意到有一個模組是透過全域的注入方式,也就是 Config 這種每個服務都會用到且俱備唯一性的變量。因此,這類的模組通常會以 isGlobal 設定為 true 來做全域分享,這樣做,就不需要逐個引用到需要模組內了。
今天的存儲狀態都是透過 in memory 的方式做處理,因此只要伺服器重啟,狀態就會消失不見。所以為了能夠持有化保持狀態,明天將會使用 Postgresql 這個關聯式資料庫作為儲存工具。