Guard는 보호하는 역할을 합니다. API요청시, 요청 로직 처리 부분까지 갈 수 있는지 없는지를 확인해주는 것입니다.
그래서 Pipe
전에 동작을 합니다.
먼저 코드를 봅시다. 현재 authorization을 넣지 않고 포스트맨을 요청하면 다음과 같은 결과가 나옵니다. 즉 서버의 어딘가에서 에러가 터진것입니다.
@Post('login/email')
postLoginEmail(
@Headers('authorization') rawToken: string,
) {
const token = this.authService.extractTokenFromHeader(rawToken, false); // 서비스 이동
const credentials = this.authService.decodeBasicToken(token);
return this.authService.loginWithEmail(credentials);
}
{
"statusCode": 500,
"message": "Internal server error"
}
그리고 서버 콘솔을 확인하면 다음과 같은 에러가 나와있습니다. 토큰을 주지 않았기 때문에 split을 할 수 없다는 것입니다.
즉, 이 부분에서 에러를 받은 것입니다. 하지만 서비스단까지 오지 않고 내부 진입전에 막는 방법은 없을까요? 애초에 요청단에서 막는 것이 Guard입니다.
extractTokenFromHeader(header: string, isBearer: boolean) {
const splitToken = header.split(' '); // 에러발생
const prefix = isBearer ? 'Bearer' : 'Basic';
if (splitToken.length !== 2 || splitToken[0] !== prefix) throw new UnauthorizedException('잘못된 토큰입니다.');
const token = splitToken[1];
return token;
}
따라서 요청단에서 막기위해 auth/guard/basic-token.guard.ts를 만들고 로직만 작성합니다다.
/**
* 구현할 기능
*
* 1) 요청객체 (request)를 불러오고
* authorization header로부터 토큰을 가져온다.
* 2) authService.extractTokenFromHeader를 이용해서
* 사용 할 수 있는 형태의 토큰을 추출한다.
* 3) authService.decodeBasicToken을 실행해서
* email과 password를 추출한다.
* 4) email과 password를 이용해서 사용자를 가져온다.
* authService.authenticateWithEmailAndPassword
* 5) 찾아낸 사용자를 (1) 요청 객체에 붙여준다.
* req.user = user;
*/
Guard를 구현하는 것은 Pipe를 구현하는 것과 매우 비슷합니다. 따라서 제공을 받으려면 @Injectable()을 사용해서 등록을 하면됩니다.
import { CanActivate, ExecutionContext } from "@nestjs/common";
@Injectable() // Provider 이기 때문에 constructor 필요
export class BasicTokenGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { // boolean 이유: 통과 O X 이기 때문에
}
}
.
.
변경
.
.
@Injectable()
export class BasicTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService, // inject
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
}
}
이제 1개씩 추가를 해봅시다. 먼저 BasicTokenGuard를 특정 엔드포인트에 적용하면 해당 객체의 정보를 가져와야 합니다. 해당 정보를 가진 요청을 가지고 오는 방법은 스탠다드한 방법입니다.
@Injectable()
export class BasicTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// switchToHTTP(Http Protocol), switchToRpc(Rpc), switchToWs(웹소켓)
const req = context.switchToHttp().getRequest();
// {authorization: 'Basic asdf32sadc'} -> asdf32sadc
const rawToken = req.headers['authorization'];
if (!rawToken) throw new UnauthorizedException('토큰이 없습니다.');
const token = this.authService.extractTokenFromHeader(rawToken, false);
const {email, password} = this.authService.decodeBasicToken(token);
const user = await this.authService.authenticateWithEmailAndPassword({
email,
password
});
req.user = user; // req의 user라는 키에 가져온 user가 붙는다, 응답이 돌아갈때까지 존재
return true;
}
}
BasicTokenGuard를 만들었으니까 이제 사용을 합시다. auth.controller.ts로 이동하고 코드를 추가 후, 포스트맨으로 테스트를 합시다.
@Post('login/email')
@UseGuards(BasicTokenGuard) // 추가
postLoginEmail(
@Headers('authorization') rawToken: string,
) {
const token = this.authService.extractTokenFromHeader(rawToken, false);
const credentials = this.authService.decodeBasicToken(token);
return this.authService.loginWithEmail(credentials);
}
{
"message": "토큰이 없습니다.",
"error": "Unauthorized",
"statusCode": 401
}
그리고 확인용으로 요청 객체를 가지고 오는 방법을 보도록 하겠습니다. @Headers('authorization') rawToken: string,
바로 밑에 @Request() req,
를 추가합니다. req에 어떤 값들이 있는지 확인하겠습니다.
import { Body, Controller, Post, Headers, UseGuards, Request } from '@nestjs/common';
.
.
@Post('login/email')
@UseGuards(BasicTokenGuard)
postLoginEmail(
@Headers('authorization') rawToken: string,
@Request() req, // 추가
) {
.
.
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGNvZGVmYWN0b3J5LmFpIiwic3ViIjoxLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzA2Mjg2ODI2LCJleHAiOjE3MDYyODcxMjZ9.uLMb-ph3ockk1dsj-7ryuk04tI-qAefOd3Ag92B9xVo",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGNvZGVmYWN0b3J5LmFpIiwic3ViIjoxLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTcwNjI4NjgyNiwiZXhwIjoxNzA2MjkwNDI2fQ.JmgdS5o-Ab_QuZYJOhr53jmn65NVUC080FE6Ff0qqmY"
}
디버깅을 해보면 토큰에 우리가 입력한 정보들이 있는 것을 알 수 있습니다.
Request는 임시로 만든것이기 때문에 지워줍니다.
이제 token/access와 token/refresh에서 BearerToken을 검증해야 합니다. guard 폴더에 bearer-token.guard.ts를 만듭니다.
@Injectable()
export class BearerTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const rawToken = req.headers['authorization'];
if (!rawToken) throw new UnauthorizedException('토큰이 없습니다.');
const token = this.authService.extractTokenFromHeader(rawToken, true);
const result = await this.authService.verifyToken(token); // 검증 -> result에는 payload
/**
* request에 넣을 정보
*
* 1) 사용자 정보 - user
* 2) token - token
* 3) tokenType - access | refresh
*/
req.token = token;
req.tokenType = result.type;
}
}
사용자의 정보를 넣을 려면 inject를 받아야 합니다. userService를 추가합니다.
@Injectable()
export class BearerTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const rawToken = req.headers['authorization'];
if (!rawToken) throw new UnauthorizedException('토큰이 없습니다.');
const token = this.authService.extractTokenFromHeader(rawToken, true);
const result = await this.authService.verifyToken(token);
const user = await this.userService.getUserByEmail(result.email);
req.token = token;
req.tokenType = result.type;
req.user = user;
return true;
}
}
근데 Bearer 토큰은 access와 refresh 2개로 나뉘어지게 됩니다. 물론 로직은 동일합니다. 하지만 따로 구분해서 검증을 하고 싶은 경우가 있기 때문에 tokenType을 보고 분리하게 만듭니다.
@Injectable()
export class BearerTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly userService: UsersService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const rawToken = req.headers['authorization'];
if (!rawToken) throw new UnauthorizedException('토큰이 없습니다.');
const token = this.authService.extractTokenFromHeader(rawToken, true);
const result = await this.authService.verifyToken(token);
const user = await this.userService.getUserByEmail(result.email);
req.token = token;
req.tokenType = result.type;
req.user = user;
return true;
}
}
@Injectable()
export class AccessTokenGuard extends BearerTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context); // 기존의 Bearer토큰 검증 절차 + tokenType을 가져옴
const req = context.switchToHttp().getRequest();
if (req.tokenType !== 'access') throw new UnauthorizedException('Access Token이 아닙니다.');
return true;
}
}
@Injectable()
export class RefreshTokenGuard extends BearerTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const req = context.switchToHttp().getRequest();
if (req.tokenType !== 'refresh') throw new UnauthorizedException('Refresh Token이 아닙니다.');
return true;
}
}
이제 적용해봅시다. 사실상 BearerTokenGuard는 사용할 일이 없습니다.
@Post('token/access')
@UseGuards(RefreshTokenGuard)
.
.
@Post('token/refresh')
@UseGuards(RefreshTokenGuard)