요약
이 글은 로그인 기능의 보안을 강화하기 위한 인증과 인가에 대한 개선 방법을 다루고 있다. 기존의 단일 토큰 방식에서의 보안 취약점을 보완하기 위해 두 개의 토큰, 즉 accessToken과 refreshToken을 사용하는 방법을 소개하고 있다. 이를 통해 토큰 탈취 시의 리스크를 줄이기 위해 유효기간을 짧게 설정하고, 사용자 편의성을 유지하기 위해 슬라이딩 세션을 도입하는 방법을 설명하였다.
또한, refreshToken을 서버의 DB 대신에 redis 캐싱을 이용하여 저장함으로써 성능을 향상시키는 방법에 대해서도 언급하고 있다. 마지막으로, 보안성을 강화하기 위해 클라이언트 정보와 함께 refreshToken을 이용하여 새로운 accessToken을 발급하는 방법을 소개하고 있다. 전반적으로 보안과 사용자 경험 간의 균형을 맞추기 위해 다양한 기술적인 접근 방법을 고려하여 진행한 프로젝트의 내용이다.
인증/인가?
지금까지 로그인 기능을 구현하면서 항상 고민이었던 부분인 인증/인가에 대해서 개선해 본 방법을 기록한다.
인증(authentication)이란 사용자 혹은 장치의 신원을 확인하는 절차이다. 인증요소는 두 개 혹은 여러 개 일 수도 있다. AWS 또는 Github 그 외의 많은 사이트가 two-factor 인증 과정을 적용하여 보안을 강화하는 모습을 볼 수 있다.
인가는 인증과는 다른 개념이다. 인증이 된 해당 사용자가 어떠한 리소스에 접근하고 동작 할 수 있는지 검증하는 것이 인가이다. 쉽게 말해 로그인하는 것을 인증, 로그인 후 특정 서비스의 접근 권한을 검증하는 것은 인가라고 생각하자.
이전의 프로젝트에서는 하나의 토큰만을 발급하여 구현 하였었다. 이때의 문제점은 JWT의 서버리스 특성 때문에 도중에 토큰을 탈취당한 경우 토큰을 즉시 무효화시키지 못하는 리스크에 대한 대비책이 없었다. 이번 프로젝트에서는 이것에 대한 보완책으로 두 개의 토큰, accessToken과 refreshToken을 사용하였다.
두 개의 토큰?
JWT는 DB에 저장하지 않는 서버리스 특성을 가졌다. 공격자가 도중에 토큰을 탈취한 경우 서버는 탈취당한 사실을 알 수도 없고, 탈취당한 토큰을 무효화시키지도 못한다. 즉, 무방비로 공격자에게 모든 사용자 신분과 권한을 제공하게 된다.
이를 대비하기 위한 방법으로는, 토큰의 유효기간을 매우 짧게 설정하여 계속해서 새로운 토큰으로 인증하여, 탈취시 무방비한 상태의 시간을 줄이는 것이다.
하지만, 이렇게 기능을 구현 했을 때 매우 큰 문제가 있다. 사용자는 계속해서 로그인을 해야 한다는 것이다. 쇼핑하려고 로그인 후 제품을 보다가 맘에 드는 것을 발견하고 장바구니에 넣으려고 했더니, 로그인을 해야 한다 하고, 로그인하고 결제하려 하니, 로그인하라고 하고...
아무도 이러한 사이트는 이용하지 않을 것이다.
사용자의 편의성을 유지하면서 토큰의 보안 취약점을 방지하기 위해, 슬라이딩 세션을 도입 할 수 있다.
슬라이딩 세션은 사용자가 직접 로그인 정보를 입력하지 않고 새로운 토큰을 발급하는 방식을 말하는데, 결국 로그인 과정을 대신해 줄 무언가가 필요하다. 이때 refreshToken을 사용하여 해결할 수 있다. 아래의 그림으로 동작 과정을 살펴보자.
- 처음 사용자가 로그인할 때, accessToken과 refreshToken을 함께 발급한다. 사용자의 사용 패턴에 맞춰 유효기간을 설정한다. 예를 들어, accessToken의 만료기간은 1 시간에서 하루 정도로 짧게, refreshToken은 7일에서 한 달 정도로 비교적 길게 설정한다.
- 클라이언트는 발급 받은 refreshToken을 탈취당하지 않도록 안전한 공간에 저장하고, accessToken으로 유효기간 동안 자유롭게 요청을 한다. accessToken 만료 에러를 전달받으면, 저장해 둔 refreshToken을 이용하여 새로운 토큰 발급 요청을 서버에 보낸다.
- 서버는 전달받은 refreshToken과 저장되어있는 토큰을 비교하여, 새로운 accessToken을 발급하여 전달한다.
실제로 refreshToken을 서버의 DB에 저장해두고 비교 요청 시, storage에 I/O작업이 발생하기 때문에, 비상태 저장 방식의 장점이 약화되기는 한다. 어떤 기술이든 마찬가지로 완벽한 기술은 없듯이, 사용자의 편의성, 보안성 등을 효율적인 부분에서 타협이 필요한 것 같다.
세션 방식과 refreshToken의 I/O작업의 동작수를 생각해 봤을 때, 인증요청을 할 때마다 I/O작업이 일어나는 세션에 비해, refreshToken을 조회할 때만 I/O작업을 하기 때문에 세션의 단점을 대폭 보안했다고 생각한다. 여기서 조금 더 나아가, 이번 프로젝트에서, redis 캐싱을 이용하여 redis storage에 refreshToken을 저장하여, 서버의 I/O 작업의 부담을 한번 더 감소시키는 방법으로 구현해 보았다.
코드 구현
// 컨트롤러
@Controller('auth')
export class AuthController {
constructor(
@Inject(IAUTH_SERVICE)
private readonly authService: IAuthService,
@Inject(IHANDLE_DATE_TIME)
private readonly handleDateTime: IHandleDateTime,
) {}
@Post('login')
@HttpCode(HttpStatus.CREATED)
@UseGuards(LocalAuthGuard)
async login(@Req() req: Request, @Res() res: Response): Promise<void> {
const { browser, platform, os, version } = useragent.parse(
req.headers['user-agent'],
)
const { accessToken, refreshToken } = await this.authService.login({
id: req.user.id,
ip: req.ip,
device: { browser, platform, os, version },
})
//쿠키에 세팅
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: this.handleDateTime.getFewHoursLater(
jwtExpiration.accessTokenExpirationHours,
),
})
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: this.handleDateTime.getFewDaysLater(
jwtExpiration.refreshTokenExpirationDays,
),
})
res.send()
}
@Post('refresh')
@HttpCode(HttpStatus.CREATED)
async refresh(@Req() req: Request, @Res() res: Response): Promise<void> {
try {
const { refreshToken } = req.cookies
const { browser, platform, os, version } = useragent.parse(
req.headers['user-agent'],
)
const { accessToken } = await this.authService.refresh({
refreshToken,
ip: req.ip,
device: { browser, platform, os, version },
})
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: this.handleDateTime.getFewDaysLater(
jwtExpiration.refreshTokenExpirationDays,
),
})
res.send()
} catch (error) {
res.clearCookie('accessToken')
res.clearCookie('refreshToken')
res.send()
}
}
//서비스
async login(req: ReqLoginAppDto): Promise<ResLoginAppDto> {
const accessToken = this.tokenService.generateAccessToken(req)
const refreshToken = this.tokenService.generateRefreshToken(req)
const { ip, device } = req
await this.cacheService.setCache(
`refreshToken:${req.id}`,
{ refreshToken, ip, device },
jwtExpiration.refreshTokenExpirationSeconds,
)
this.logger.log('info', `${LOGIN_SUCCESS_MESSAGE}-유저 ID:${req.id}`)
return plainToClass(ResLoginAppDto, { accessToken, refreshToken })
}
async refresh(req: ReqRefreshAppDto): Promise<ResRefreshAppDto> {
const decoded = this.tokenService.decodeToken(req.refreshToken)
if (!decoded || !decoded.id) {
throw new UnauthorizedException(AUTH_INVALID_TOKEN)
}
const redisRefreshInfo: RefreshInfo = await this.cacheService.getFromCache(
`refreshToken:${decoded.id}`,
)
if (redisRefreshInfo === null) {
this.logger.error(AUTH_EXPIRED_REFRESH_TOKEN)
throw new UnauthorizedException(AUTH_EXPIRED_REFRESH_TOKEN)
}
if (req.refreshToken !== redisRefreshInfo.refreshToken) {
this.logger.error(AUTH_INVALID_TOKEN)
throw new UnauthorizedException(AUTH_INVALID_TOKEN)
}
if (req.ip !== redisRefreshInfo.ip) {
this.logger.error(OTHER_IP)
throw new UnauthorizedException(OTHER_IP)
}
const { device: reqDevice } = req
const { device: redisDevice } = redisRefreshInfo
if (
reqDevice.browser !== redisDevice.browser ||
reqDevice.os !== redisDevice.os ||
reqDevice.platform !== redisDevice.platform ||
reqDevice.version !== redisDevice.version
) {
this.logger.error(OTHER_DEVICE)
throw new UnauthorizedException(OTHER_DEVICE)
}
const user = await this.userRepository.findById(decoded.id)
const accessToken = this.tokenService.generateAccessToken(user)
this.logger.log('info', `리프레시 토큰 검증 완료-유저 ID:${user.id}`)
return { accessToken }
}
}
로그인 API 응답 accessToken, refreshToken 발급
로그인 컨트롤러에서 @useGuards()를 통해서, passport의 LocalStrategy를 사용한 인증 절차를 먼저 거친다.
useragent를 사용하여 클라이언트 정보를 가져오고, header부분을 파싱 하여 클라이언트의 ip, device정보를 가져온다.
accessToken이 만료되었을 때, 서버는 전달받은 refreshToken과 클라이언트 정보들이 redis storage에 저장되어 있는 정보와 일치하는지 검증 후 새로운 accessToken을 발급해서 넘겨준다. 재발급 시 refreshToken만 비교하는 것이 아닌 기존의 IP와 기기정보를 함께 비교하여 검증하는 방식으로 보안성을 조금 더 강화시킨다.
보안을 위해 어떠한 기능을 JWT에 적용시킬 수도 있지만, 그럴수록 세션과의 차이점이 없어진다고 생각이 든다. 보안성과 유저의 편의성을 잘 분석하여 최대한 합리적인 방법을 적용시키는 것이 중요하다.