이전 글에서 이어집니다.
Access Token의 한계
Access Token을 통해 웹사이트를 이용하다, 토큰이 만료되면 다시 재로그인을 해야만 하는 불편함을 겪게 된다.
또한 Access Token 만료 전 토큰을 탈취당하게 된다면 토큰이 만료될 때까지 속수무책이라는 보안상의 문제도 있다.
이러한 문제들 때문에, 보통 Acces Token의 유효 시간을 짧게 해두고, Refresh Token의 유효기간을 길게 설정하여
Refresh Token이 만료되기 전에는, Access Token을 갱신하여 사용할 수 있다.
정리
1. Access Token의 유효 기간을 짧게 설정, Refresh Token의 유효 기간은 길게 설정
2. 두 토큰 모두 서버에 전송하여 Access Token으로 인증하고, 만료 시 Refresh Token으로 재발급한다.
3. Access Token을 탈취당해도, 짧은 유효 기간이 지나면 사용할 수 없다.
하지만 만약 Refresh Token이 탈취당한다면..?
위 스택오버플로우의 글에서 제안하는 방법은 다음과 같다.
1. 데이터베이스에 Access Token, Refresh Token 쌍을 저장(1:1 매핑)
2. 공격자는 탈취한 Refresh Token으로 새 Access Token을 생성하여 서버에 전송하게 된다. 이 때, 서버에서 데이터베이스에 저장된 Access Token과 공격자에게 받은 Access Token이 다른 것을 확인하게 된다. 이 때, 저장된 토큰이 만료되지 않았을 경우 탈취당한 것으로 간주하고 두 토큰을 모두 만료시킨다.
3. 재로그인을 통해 인증을 다시 수행해야하지만, 공격자의 토큰 역시 만료됐기 때문에 공격자는 접근이 불가능해진다.
토큰은 클라이언트나 서버 전역에서 만료시킬 수 있는 개체가 아니기 때문에, 토큰의 유효 기간이 지나기 전까지는 데이터베이스에 저장하여 관리할 필요가 있다.
또한 Refresh Token도 Access Token과 같은 유효기간을 가지게 해서, 사용자가 한 번 Refresh Token으로 Access Token을 재발급을 받았을 때, Refresh Token도 재발급 할 수 있도록 권장하고 있다고 한다. (ietf문서 링크)
Refresh Token 이용하기(Refresh Strategy)
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { UserService } from 'src/user/user.service';
@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
constructor(
private readonly configService: ConfigService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
return request?.cookies?.refreshToken
}]),
ignoreExpiration: false,
passReqToCallback: true,
algorithms: ['HS256'],
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(request: Request, payload: any) {
const refreshToken = request.cookies['refreshToken'];
return this.userService.refreshTokenMatches(refreshToken, payload.no);
}
}
//JWT의 추출방법을 지정한다.
//fromAuthHeaderAsBearerToken()을 사용하여 헤더에서
//Bearer Token을 Authorization 헤더에서 추출한다.
jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
return request?.cookies?.refreshToken
}]),
//토큰 만료 기간을 무시할지? false는 만료된 토큰 자체를 거부한다.
ignoreExpiration: false,
//validate함수의 첫 번째 인자로 request 객체 전달 여부를 결정.
passReqToCallback: true,
//서명에 사용할 알고리즘을 지정한다.
algorithms: ['HS256'],
//서명에 사용되는 비밀키로 환경변수에서 가져왔다.
secretOrKey: configService.get('JWT_SECRET'),
validate의 refreshTokenMatches를 통해, 해당 user의 row에 있는 refresh token이 request의 refresh token과 일치한지 여부를 확인한다.
//JWT 관련 로직
async refreshTokenMatches(refreshToken: string, no: number): Promise<UserEntity> {
const user = await this.findByNo(no);
const isMatches = this.isMatch(refreshToken, user.refresh_token);
if (isMatches) return user;
}
검증에 성공하면, 다시 accessToken을 반환하여, 클라이언트에서 이를 보관처리하고 다시 인증에 유효하게 사용할 수 있을 것이다.
@Post('/refresh')
@UseGuards(RefreshAuthGuard)
@ApiOperation({ description: '토큰 재발급' })
async refreshToken(@Request() req) {
const accessToken = await this.authService.createAccessToken(req.user);
const user = new UserEntityBuilder()
.withNo(req.user.no)
.withId(req.user.id)
return { accessToken, user };
}
참조
https://docs.nestjs.com/security/authentication
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-00#section-8
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!