Skip to content

[준섭] 1121(화) 개발기록 ‐ 커스텀 AuthGuard 작성

송준섭 edited this page Nov 21, 2023 · 2 revisions

@UseGuards(AuthGuard())와 같은 경우, AuthGuard()는 기본적으로 클라이언트 요청의 Authorization헤더에 있는 토큰은 읽어서 내부적으로 등록된 Strategy를 활용하여 요청을 인증한다.

그러나 우리는 Authorization 헤더를 사용하지 않고, 브라우저 쿠키에 토큰을 저장하여 요청 시 쿠키 헤더에 토큰이 담겨 들어온다.

나는 AuthGuard를 커스텀하여 Authorization 헤더가 아닌 쿠키에서 토큰을 읽게 하고, Strategy 또한 커스텀하여 원하는대로 인증을 진행하기로 계획했다.

Strategy 커스텀?

위에서 말했듯 AuthGuardPassport에서 설정하여 내부적으로 등록된 Strategy를 읽어 인증을 진행한다.

PassportStrategy를 상속하면 커스텀 Strategy를 만들 수 있다.

// src/auth/jwt.strategy.ts
import ...

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(private userRepository: UserRepository) {
        super({
            secretOrKey: process.env.JWT_SECRET_KEY,
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
        });
    }

		// 생략 ..
}

이렇게 커스텀을 한다고 하면 생성자 super()안 jwtFromRequest 부분에 jwt를 넘겨줘야 한다.

위처럼 fromAuthHeaderAsBearerToken() 메서드로 Authorization 헤더의 Bearer 토큰을 넘겨줄 수도 있지만, 우리는 쿠키에 저장된 토큰을 넘겨야 한다.

jwtFromRequest: (req) => {
  let token = null;
  if (req && req.cookies) {
    token = req.cookies['accessToken']; // 쿠키에서 accessToken 가져오기
  }
  return token;
},

위와 같이 req에서 쿠키를 넘길 수도 있지만, 생각해보니 accessToken 뿐 아니라 refreshToken도 함께 넘겨야 accessToken이 유효하지 않은 경우 refreshToken을 검사할텐데 어떻게 하지? 라는 생각이 들었다.

그래서 일단 AuthGuard를 커스텀 해보자! 라고 생각하고 넘어갔다.

AuthGuard 커스텀

AuthGuard도 jwt를 사용하는 경우 AuthGuard('jwt')를 상속받아서 커스텀할 수 있다고 한다.

import {
	Injectable,
	ExecutionContext,
	UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class CookieAuthGuard extends AuthGuard('jwt') {
	constructor(private readonly jwtService: JwtService) {
		super();
	}

	async canActivate(context: ExecutionContext): Promise<boolean> {
		const request = context.switchToHttp().getRequest();

		try {
			const accessToken = request.cookies['accessToken'];
			const { username, id } = this.jwtService.verify(accessToken);
			request.user = { username, userId: id };
			return true;
		} catch (error) {
			throw new UnauthorizedException('로그인이 필요합니다.');
		}
	}
}

JwtService의 verify 메서드는 토큰을 검사하고 유효하지 않으면 에러를 발생시킨다.

일단 refreshToken은 생각하지 않고, 위처럼 accessToken만 검사하고 유효하지 않으면 UnauthorizedException을 발생시켰다.

그리고 유효하다면 요청에 user로 username과 userId를 넘겨주었다.

이렇게 하고 확인을 위해 테스트 api를 만들어서 테스트 해보았다.

// auth.controller.ts
@Get('test')
@UseGuards(CookieAuthGuard)
test(@Req() req) {
	return req.user;  // 응답 body에 CookieAuthGuard에서 추가해준 req.user 정보 반환
}

쿠키가 없는 경우 다음과 같이 Unauthorized 에러가 발생하였다.

Untitled

쿠키에 JWT 토큰을 넣은 채로 로그인을 하니 다음과 같이 user 정보가 잘 담겨서 출력되었다!

Untitled

AccessToken이 유효하지 않은 경우 처리

인가 과정은 다음과 같다.

  1. 쿠키가 없다면 로그인 하지 않았다고 판단, 인가 X → Unauthorized 에러 발생
  2. accessToken이 유효하다면 인가, request.user에 user정보를 담아 서비스 로직에 활용할 수 있도록 함
  3. accessToken이 유효하지 않다면 refreshToken을 검사
  4. refreshToken이 유효하지 않다면 브라우저의 쿠키를 지우고 인가 X → Unauthorized 에러 발생
  5. 유효하다면 Redis에 저장된 토큰과 비교
  6. 일치하지 않다면 브라우저의 쿠키를 지우고 인가 X → Unauthorized 에러 발생
  7. 일치하다면 인가 → 새로운 accesToken 발급
import ...

@Injectable()
export class CookieAuthGuard extends AuthGuard('jwt') {
	constructor(
		private readonly jwtService: JwtService,
		private readonly redisRepository: RedisRepository,
	) {
		super();
	}

	async canActivate(context: ExecutionContext): Promise<boolean> {
		const request = context.switchToHttp().getRequest();
		const response = context.switchToHttp().getResponse();

		if (!request.cookies) {  // 쿠키가 없다면 로그인을 하지 않았다고 판단
			throw new UnauthorizedException('로그인이 필요합니다.');
		}

		const accessToken = request.cookies['accessToken'];
		try {  // accessToken이 유효하다면 request.user에 user 정보를 담고 인가
			const { userId, username, nickname } =
				this.jwtService.verify(accessToken);

			request.user = { userId, username, nickname };
			return true;
		} catch (error) {}  // 유효하지 않다면 아래 로직으로

		const refreshToken = request.cookies['refreshToken'];
		try {  // refreshToken이 유효하다면 일단 request.user에 user 정보 담고 다음 로직으로
			const { userId, username, nickname } =
				this.jwtService.verify(refreshToken);
			request.user = { userId, username, nickname };
		} catch (error) {  // 유효하지 않다면 Unauthorized 에러 반환
			response.clearCookie(JwtEnum.ACCESS_TOKEN_COOKIE_NAME);
			response.clearCookie(JwtEnum.REFRESH_TOKEN_COOKIE_NAME);
			throw new UnauthorizedException('로그인이 필요합니다.');
		}

		if (  // Redis에 저장된 refreshToken과 일치하지 않는다면
			!(await this.redisRepository.checkRefreshToken(
				request.user.username,
				refreshToken,
			))
		) {  // 브라우저 쿠키를 지우고 Unauthorized 에러 반환
			response.clearCookie(JwtEnum.ACCESS_TOKEN_COOKIE_NAME);
			response.clearCookie(JwtEnum.REFRESH_TOKEN_COOKIE_NAME);
			throw new UnauthorizedException('로그인이 필요합니다.');
		}

		// refreshToken이 유효하면서 Redis에 저장된 토큰과도 같다면 새로운 accessToken 반환
		const newAccessToken = await createJwt(
			request.user,
			JwtEnum.ACCESS_TOKEN_TYPE,
			this.jwtService,
		);
		response.cookie(JwtEnum.ACCESS_TOKEN_COOKIE_NAME, newAccessToken, {
			path: '/',
			httpOnly: true,
		});
		return true;
	}
}

위처럼 로직이 굉장히 복잡하다..

코드를 짜면서 이게 맞나? 라는 생각이 들었다.

AuthGuard가 너무 무거워 지는 느낌?

다음에 함수를 분리해서 가독성을 높여봐야겠다.

어쨌든 동작은 잘 된다!

상황을 나눠서 확인해보겠다.

  • 쿠키가 없는 경우

image

당연히 Unauthorized 에러가 발생한다.

accessToken이 유효한 경우

image

인가가 잘 된 모습을 보인다.

accessToken이 유효하지 않은 경우

refreshToken이 유효하지 않은 경우

accessToken과 refreshToken의 payload에서 유효 기간을 만료한 상태로 쿠키에 저장하여 테스트 해보았다.

image

Unauthorized 에러가 발생한다.

image

쿠키도 사라진 모습을 보인다.

refreshToken이 유효한 경우

Redis에 저장된 토큰 정보와 일치한 경우

image

인가가 잘 된 모습을 보인다.

image

또한 accessToken이 현재 시간 기준으로 재발급 되었다!

Redis에 저장된 토큰 정보와 일치하지 않은 경우

로그인을 한 후 Redis의 해당 정보를 삭제하고 확인해보았다.

image

Unauthorized 에러가 발생한다.

image

역시 쿠키도 사라진 모습을 보인다.


후.. 이게 맞나 싶지만 일단 커스텀 AuthGuard를 만들었다.

가드가 필요한 곳에 @UseGuard(CookieAuthGuard)와 같은 식으로 사용하면 된다!

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally