10/29/2023

토큰과 쿠키

발단

처음에 리팩토링을 진행할 때 위코드에서 배운 것처럼 똑같은 방법으로 인증을 구현했다. 즉, 로그인 성공 시 JWT 접근 토큰을 발급한다! 이 방식으로 발급된 JWT 접근 토큰은 Bearer 토큰이다. Bearer 토큰은 인증된 사용자가 인증이 필요한 자원에 접근할 시 사용되는 토큰으로 HTTP 요청 메시지의 Authorizaiton 헤더의 Bearer라는 이름으로 포함되어 서버로 전송된다. 접근 토큰은 Bearer 토큰으로 식별 정보가 아니라 인증이 필요한 자원에 접근할 수 있는 자격 증명 정보로 작용한다. 즉, 접근 토큰을 가지는 사용자는 누구라도 인증이 필요한 자원에 접근할 수 있다. 이에 따라 악의적인 의도를 가진 사용자가 접근 토큰을 훔칠 경우 자원에 접근할 수 있어 큰 문제가 발생한다. 이 때문에 새로고침 토큰을 사용해서 문제를 완화한다. 지금까지 방법은 접근 토큰을 브라우저의 로컬 스토리지에 저장하는데 자세하게 알아보니 쿠키에 접근 토큰을 저장하는 방식이 로컬 스토리지에 저장하는 것보다 여러 장점을 가지는 것을 배웠다. 또한 새로고침 토큰으로 앞서 언급한 문제를 어떻게 완화하는지에 대해 알아보자~


로컬 스토리지

로컬 스토리지는 웹 스토리지 중 하나로 가장 흔하게 JWT 토큰을 저장하는 방식인데 웹 페이지를 새로고침해도 JWT 토큰이 살아있다는 장점을 가지지만 XSS 공격에 취약하다. 다른 웹 스토리지인 세션 스토리지도 기본적으로 로컬 스토리지와 같다. 차이점은 세션 스토리지는 브라우저가 종료되었을 때 사라지지만 로컬 스토리지는 유지된다.


쿠키

일단 XSS(Cross-Site Scripting) 공격을 완화할 수 있다. XSS 공격이란 웹 사이트에 악성 스크립트를 실행하는 공격으로 쿠키의 HttpOnly 옵션으로 완화한다. HttpOnly 옵션은 JavaScript에서 Document.cookie 속성을 통해 쿠키에 접근하는 것을 금지한다. 그러나 이 옵션은 중간자 공격 같은 HTTP 공격에 대응할 수 없다. 중간자 공격(Man-in-the-Middle, MitM)은 두 개의 시스템 간 통신을 가로채는 공격이다. Secure 옵션은 쿠키가 HTTPS를 통해 요청할 경우만 쿠키를 서버(로컬호스트 제외)로 전송해서 중간자 공격과 같은 HTTP 공격을 완화한다. 또한 CSRF(Cross Site Request Forgery) 공격을 완화할 수 있다. CSRF 공격은 신뢰할 수 있는 사용자를 흉내내고 웹 사이트에 요청을 위조해서 원치 않는 명령을 보내는 공격이다. SameSite 옵션은 쿠키가 사이트 간 요청과 함께 전송되는지 여부를 제어하는 옵션으로 3가지 값을 가질 수 있다.
  • Strict: 브라우저가 동일한 사이트 요청에만 쿠키를 전송한다. 즉, 쿠키를 설정한 동일한 사이트의 요청에만 쿠키를 전송한다. 다른 도메인이나 프로토콜(동일한 도메인 포함)에서 시작된 요청에는 SameSite=Strict 속성을 가진 쿠키가 전송되지 않는다.
  • Lax: 쿠키는 교차 사이트 요청(e.g.,이미지 또는 프레임 로드 요청)에는 보내지지 않지만 사용자가 외부 사이트에서 원본 사이트로 이동할 때(e.g., 링크를 클릭하는 경우)는 보내진다. SameSite 속성이 지정되지 않은 경우의 기본 동작이다.
  • None: 브라우저는 쿠키를 교차 사이트 및 동일한 사이트 요청 모두에 전송한다. 이 값을 사용하면 Secure 속성도 설정해야 한다. 예를 들어, SameSite=None; Secure와 같이 설정해야 한다. Secure가 누락되면 오류가 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 인증은 Passport 패키지를 사용했다! 자세한 사항은 NestJS 공식 문서를 참조하기를~ 
@HttpCode(HttpStatus.OK)
@UseGuards(LocalAuthGuard)
@Post('/signin')
async signIn(
  @Req() request: RequestWithUser,
  @Res({ passthrough: true }) response: Response,
) {
  const { user } = request;
  const { token: accessToken } = await this.authService.createToken(
    user.id,
    Token.ACCESS_TOKEN,
  );
 
  const accessTokenOptions = createCookieOptions(
    this.configService.get<string>('JWT_ACCESS_TOKEN_EXPIRATION'as string,
  );
 
  const { email, phoneNumber } = user;
  const userDto = new UserDto(email, phoneNumber);
  response
    .cookie(`access_token`, accessToken, accessTokenOptions)
    .send(userDto);
}
 
// JWT 토큰을 생성한다.
async createToken(id: number, type: Token) {
  try {
    const payload: TokenPayload = { id };
 
    const tokenSecret =
      type === Token.ACCESS_TOKEN
        ? 'JWT_ACCESS_TOKEN_SECRET'
        : 'JWT_REFRESH_TOKEN_SECRET';
 
    const tokenExpiresIn =
      type === Token.ACCESS_TOKEN
        ? 'JWT_ACCESS_TOKEN_EXPIRATION'
        : 'JWT_REFRESH_TOKEN_EXPIRATION';
 
    const token = await this.jwtService.signAsync(payload, {
      secret: this.configService.get<string>(tokenSecret),
      expiresIn: this.configService.get<string>(tokenExpiresIn),
    });
 
    return { token };
  } catch (error) {
    throw new InternalServerErrorException(
      `${
        type === Token.ACCESS_TOKEN ? '접근 토큰' : '새로고침 토큰'
      } 생성 중 오류 발생.`,
    );
  }
}
 
// 쿠키의 옵션을 반환하는 함수.
export function createCookieOptions(expiration: string): CookieOptions {
  return {
    httpOnly: true// JavaScript에서 쿠키 접근을 비활성화 한다.
    secure: false// HTTPS를 설정하지 않아서 false로... 자체 서명 인증서와 개인 키로 HTTPS를 설정할 수 있지만 실제로 하려면 벤더에서 구매해야 한다.
    sameSite: 'strict'// Serve Static 패키지를 사용해서 strict로 설정했지만 CORS 사용 시 lax로 설정해야 한다.
    expires: new Date(Date.now() + parseInt(expiration)), // JWT와 쿠키의 만료 기간을 동일하게 설정한다.
  };
}
cs

쿠키에 접근 토큰이 생성된 걸 볼 수 있다.


새로고침 토큰

쿠키에 접근 토근을 저장하는 방식은 언급한 공격을 완화한다. 하지만 단순히 접근 토큰만 사용하는 방식은 문제가 있다. 쿠키가 제거되면 브라우저는 이후의 요청과 함께 해당 토큰을 서버로 보내지 않는다. 서버의 관점에서 보면 해당 토큰은 더 이상 해당 클라이언트 세션과 연결되어 있지 않으며 인증이나 권한 부여에 사용되지 않지만 JWT 접근 토큰은 상태가 없으며 만료 기간을 가진다. 즉, 토큰이 지속되는 시간은 로그인이 유지되는 시간이다. 따라서 브라우저에서 쿠키 혹은 접근 토큰을 제거한다고 접근 토큰 자체가 사라지거나 무효화되지 않는다. 이 때문에 아무리 만료 기간이 짧더라도 만약 접근 토큰이 탈취당할 경우 탈취자가 사용자처럼 행동할 수 있다. 또한, 만료 기간이 지나면 사용자는 사용자 이름과 비밀번호 다시 로그인을 해야하는데 사용자 입장에서 너무 불편하다. 그렇다고 만료 기간을 늘리면 앞의 탈취 문제에 보안이 더 취약해진다. 이 문제를 해결하는 방법이 바로 새로고침 토큰이다. 로그인 성공 시 두 개의 별도의 JWT 토큰을 생성하는 것으로 하나는 30분 이내의 짧은 만료 기간을 가지는 접근 토큰이고 다른 하나는 1주일 혹은 2주일 정도 유효한 새로고침 토큰이다.

새로고침 토큰을 사용하는 방법은 다음과 같다. 사용자는 두 토큰을 쿠키에 저장하지만 요청 시 인증에는 접근 토큰만 사용한다. 만약 접근 토큰이 만료되면 API에서 새로고침 토큰을 쿠키에서 가져온 다음 새로운 접근 토큰을 발급한다. 중요한 점은 새로고침 토큰이 유효할 경우만 API가 작동해야 한다. 이로써 사용자는 귀찮게 다시 로그인을 할 필요가 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@HttpCode(HttpStatus.OK)
@UseGuards(LocalAuthGuard)
@Post('/signin')
async signIn(
  @Req() request: RequestWithUser,
  @Res({ passthrough: true }) response: Response,
) {
  const { user } = request;
  const { token: accessToken } = await this.authService.createToken(
    user.id,
    Token.ACCESS_TOKEN,
  );
  const { token: refreshToken } = await this.authService.createToken(
    user.id,
    Token.REFRESH_TOKEN,
  );
  
  const accessTokenOptions = createCookieOptions(
    this.configService.get<string>('JWT_ACCESS_TOKEN_EXPIRATION'as string,
  );
  const refreshTokenOptions = createCookieOptions(
    this.configService.get<string>('JWT_REFRESH_TOKEN_EXPIRATION'as string,
  );
 
  const { email, phoneNumber } = user;
  const userDto = new UserDto(email, phoneNumber);
  response
    .cookie(`access_token`, accessToken, accessTokenOptions)
    .cookie(`refresh_token`, refreshToken, refreshTokenOptions)
    .send(userDto);
}
 
// expiresIn의 기본 단위는 밀리세컨드(ms)
JWT_ACCESS_TOKEN_EXPIRATION=1800000 // 30분
JWT_REFRESH_TOKEN_EXPIRATION=86400000 // 24시간 
cs

한계

새로고침 토큰을 적용하는 방법도 아쉽게도 분명한 한계점이 있다. 바로 토큰이기에 접근 토큰처럼 탈취될 수 있다. 가장 간단한 방법은 탈취 시 JWT 새로고침 토큰 비밀 키를 변경하는 것이다. 이렇게 하면 모든 새로고침 토큰이 무효화되어 사용할 수 없다. 하지만 이 방법을 적용하면 모든 사용자를 애플리케이션에서 로그아웃 시켜야 한다. 다른 방법은 블랙리스트이다. 누군가가 새로고침 토큰을 사용할 때마다 블랙리스트에 있는지 확인하는 것이다. 모든 새로고침 토큰을 확인하고 최신 상태를 유지해야 하기에 구현이 복잡할 수 있다. 마지막 방법은 로그인할 때 현재 새로고침 토큰을 데이터베이스에 저장하는 것이다. 새로고침 수행 시, 데이터베이스에 저장된 토큰이 제공된 토큰과 일치하는지 확인하고 그렇지 않은 경우 요청을 거부한다. 이렇게 하면 특정 사용자의 토큰을 데이터베이스에서 제거함으로써 토큰을 쉽게 무효화시킬 수 있다.

데이터베이스에 새로고침 토큰을 저장하면 일단 여러 장치에서 중복 로그인 문제를 방지할 수 있다. 중복 로그인이 발생하면 데이터베이스에 저장된 새로고침 토큰이 덮어씌어지기 때문에 이전 새로고침 토큰을 가진 사용자는 더 이상 사용할 수 없다. 데이터베이스 저장 시 비밀번호처럼 평문이 아니라 해시를 적용해야 하는데 데이터베이스가 만약 유출되면 비밀번호처럼 민감한 새로고침 토큰이 그대로 노출되며 이를 악용할 수 있다. 마지막으로 로그아웃 시 데이터베이스에서 새로고침 토큰을 제거해서 접근 토큰만 사용할 시 발생하는 문제 즉, 만료 기간 전에 토큰 자체를 무효화시킬 수 없다는 문제를 해결할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@HttpCode(HttpStatus.OK)
@UseGuards(LocalAuthGuard)
@Post('/signin')
async signIn(
  @Req() request: RequestWithUser,
  @Res({ passthrough: true }) response: Response,
) {
  // 로그인 시 새로고침 쿠키를 데이터베이스에 저장한다.
  await this.usersService.setRefreshToken(refreshToken, user.id);
  // ...
}
 
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@Post('/signout')
async signOut(
  @Req() request: RequestWithUser,
  @Res({ passthrough: true }) response: Response,
) {
  // 로그아웃 시 새로고침 토큰을 데이터베이스에서 삭제한다.
  await this.usersService.removeRefreshToken(request.user.id);
  // 접근 토큰과 새로고침 토큰 둘 다 브라우저에서 제거한다.
  response.clearCookie('access_token').clearCookie('refresh_token');
}
 
async setRefreshToken(refreshToken: string, id: number) {
  try {
    const salt = await bcrypt.genSalt();
    // 비밀번호처럼 새로고침 토큰도 민감한 정보이기 때문에 단방향 해시를 적용한다.
    const hashedRefreshToken = await bcrypt.hash(refreshToken, salt);
 
    await this.usersRepository.update(id, {
      refreshToken: hashedRefreshToken,
    });
  } catch (error) {
    throw new InternalServerErrorException('새로고침 토큰 설정 중 오류 발생.');
  }
}
 
async removeRefreshToken(id: number) {
  try {
    await this.usersRepository.update(id, { refreshToken: null });
  } catch (error) {
    throw an InternalServerErrorException('새로고침 토큰 삭제 중 오류 발생.');
  }
}
cs


작업 스케줄링

로그아웃 시 브라우저에서 접근 토큰 쿠키와 새로고침 토큰 쿠키를 제거하고 데이터베이스에서 새로고침 토큰을 null로 갱신한다. 하지만 로그아웃 없이 새로고침 토큰이 그냥 만료될 시 데이터베이스에서 제거되어야 하는데 이 역시 자동적으로 수행되어야 한다. 위의 코드의 경우 새로고침 토큰이 만료되어도 로그아웃 하지 않으면 데이터베이스 내부에 새로고침 토큰 값이 그대로 남아있다.

update: 2024.01.21

댓글 없음:

댓글 쓰기