lottie
Seungjun's blog
blog
refresh token, access token

계기

기존에 내가 인증, 인가 기능을 구현했던 방식이 공부를 점차 하다보니 문제가 많다고 느껴졌다.

 그래서 현재방식으로 리팩토링 하였다. 기존에 구현했던 방식이 어떤 점이 문제였는지 그리고 현재 어떻게 리팩토링 했는지 차이점을 살펴보자.

기존 방식 : bearer token

기존엔 nest.js, next.js 에 대한 이해도가 낮아 블로그를 만들어 가는데 어려운 부분이 많았다 . 그래서 일단 나의 블로그 메이킹 전략 그리고 개발자로서 내가 사랑하는 방법론 '애자일'을 통해 일단 되는데로 구현하고 문제점을 해결하기로했다.

  그래서 나의 머릿속에서 나온것은 jwt 베어러 토큰 방식이었다. 대략적인 코드 로직은 아래와 같다.

  1. signin : 클라이언트측에서 아이디와 비밀번호를 담아 서버측에 인증 요청을 (authentication request) 보낸다.

  2. token generate : 유저를 확인한 후 유저 데이터를 바탕으로 jwt 토큰을 만들고 이를 쿠키에 담아 클라이언트측으로 보낸다.

  3. state store : 받아온 토큰이 있다면 클라이언트의 상태를 인증 상태로 바꿔 주고 이를 클라이언트 측 sessionStorage에 저장하고 로그인 상태를 유지시켜준다.

  4. authorization : 인가가 필요한 경우 세션에 저장된 상태에 따라 이를 클라이언트에서 확인하고 서버측으로 요청을 보낸다. 서버측은 쿠키에 담긴 토큰을 확인하고 있다면 해당 기능을 수행한다.


급하게 만들며 내가 했던 잘못들

  • httpOnly 옵션을 사용해 자바스크립트를 통한 토큰 탈취를 방지했지만 세션에 인증 상태와 토큰을 저장했기에 이를 개발자 도구를 통해 쉽게 값을 변경하고 탈취할 수 있었다.

  • 서버에서도 기능별로 레이어를 나누어 토큰 체크하고 토큰의 유효성 검사를 진행한 것이 아니라 그냥 토큰의 유무만을 확인해 인가(authorization)를 해주었기 때문에 보안에도 취약했다.

  • 세션에 상태 값을 저장해두어 해당 창을 닫는다면 상태 값이 초기화되고 반복해서 로그인해야하는 번거러움이 있었다.


개선 방식 : refresh token, access token

우선 이번에 리팩토링은 기존의 passport와 jwt를 그대로 사용하고 상태를 localStorage에 저장하기로 했다. 그리고 로컬에 저장하는 토큰의 보안을 위해 bearer token 하나만을 사용하는 방식에서 refresh token, access token 두개의 토큰을 사용하는 방법으로 개선했다. 두가지 토큰들에 관해 간단히 설명하자면 아래와 같다.

  • refresh token : 만료 기간이 길고 access token이 만료되었을 경우 재발급하는 근거가 된다.

  • access token : 만료 기간이 짧고 인가(authorization)이 필요한 경우 사용되는 토큰이다.

대략적인 코드 로직은 아래와 같다.

  1. signin : 클라이언트측에서 아이디와 비밀번호를 담아 서버측에 인증 요청을 (authentication request) 보낸다.

  2. token generate : 유저를 확인한 후 유저 데이터를 바탕으로 refresh token, access token을 만들고 쿠키에 담아 클라이언트측으로 보낸다.

  3. token store : 서버에선 생성된 refresh token을 데이터 베이스에 저장한다.

  4. state store : 클라이언트측은 서버로부터 받은 데이터를 localStorage에 저장한다.( 로컬 스토리지는 세션과 달리 창을 닫아도 계속해서 데이터가 브라우저에 저장되어 있기 때문에 민감한 정보는 저장하면 안된다.)

  5. authorization : 인가가 필요한 경우 로컬스토리지에 저장된 상태에 따라 이를 클라이언트에서 확인하고 서버측으로 요청을 보낸다.( 이때 쿠키는 헤더에 담겨져 있고 만료되지 않았다면 자동으로 서버로 보내지게 된다 )

  6. validate : 서버는 헤더에 담긴 쿠키를 확인한다. access Token이 만료되었다면 refresh token의 유효성 검사를 진행해 시크릿 키를 확인하고 새로운 access token을 발급해준다. access token이 있다면 마찬가지로 유효성 검사를 진행해 서명 확인 후 클라이언트 측의 요청을 수행한다.


리팩토링 후 로직의 장점

  • 창을 닫을 때마다 매번 로그인을 다시 해야하는 번거로움이 사라졌다.

  • 토큰을 헤더에 저장하고 최소한의 데이터만 로컬에 저장해 보안이 더 강화되었다.

  • passport, guard 등을 이용하여 인가가 필요한 기능이라면 간편히 유효성 검사를 진행하도록 만들었다. 이로 인해 인가 코드를 작성하기 매우 수월해졌다.


리팩토링하면서 힘들었던 부분!

  로컬에선 쿠키 전송 및 작동이 잘되는데 왜 프로덕트에선 쿠키가 안오는거야!

 이 문제로 인해 몇시간은 걸린것 같다. 내가 겪었던 문제의 원인 몇가지를 정리해보겠다.

  1. 클라이언트와 서버의 도메인이 다름. 과거 크롬에선 도메인이 달라도 쿠키가 전송된거 같지만 현재는 안되는것 같다. 결국 서버 도메인을 클라이언트의 서브 도메인으로 옮겨 이를 해결했다.

  2. 쿠키에 domain 속성을 입력해주지 않음. 만약 도메인 속성을 입력해주지 않았다면 쿠키는 헤더에 설정되어 있으나 브라우저 탭에서 확인이 안될것이다.


  authgaurd 는 왜 작동하지 않을까...

 passport와 garud의 사용방법에 대해 잘모르고 로직이 어떻게 작동하는지 처음에 잘 이해하지 못하고 코드를 짜둔터라 이부분을 이해하며 시간이 좀 소모되었다. 작동 순서는 아래와 같다

// auth.controller.ts

// 1번 컨트롤러
@Post('/logout')
// 여기서 가드를 통해 유효성 검증이 시작된다 
	@UseGuards(AuthGuard)
	async logout(@Body() data: number, @Res() res: Response)
	: Promise<Response | UnauthorizedException> {
		return await this.authService.logout( data, res);
	}


// auth.gaurd.ts

// 2번 가드에서 jwt passport를 활성화 시킨다
@Injectable()
export class AuthGuard extends NestAuthGuard('jwt') {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return super.canActivate(context);
  }
}


// passport.jwt.strategy.ts

// 3번 패쓰포트에서 토큰을 가져오고 유효성 검증에 필요한 서비스를 작동시킨다.

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
    // 엑세스 토큰을 가져온다. main.ts에서 cookie-parser를 사용하고 있기 객체 형태 접근 가능
      jwtFromRequest: (req) => req.cookies['accessToken'],
      ignoreExpiration: false,
      secretOrKey: process.env.SECRET_KEY,
    });
  }
  
// 유효성 검증하는 서비스 실행 
  async validate(
    payload: any ,
    done: VerifiedCallback,
  ): Promise<any> {
  // 4번 authService의 서비스 실행(여기가 진짜 유효성 검증 부분)
    const user = await this.authService.tokenValidateUser(payload);
		
    if (!user) {
      return done(
        new UnauthorizedException({ message: 'user data cannot found' }),
        false,
      );
    }
    return done(null, user);
  }
}