개발일지
[NestJS] Redis로 JWT Blacklist 적용하기 본문
개요
지난 번 사용자 인증/인가 방식으로 JWT를 적용했습니다. JWT는 토큰 그 자체만으로도 검증이 가능하기 때문에 별도의 저장공간을 필요로 하지않는다는 장점이 있습니다. 그러나 단점 역시 존재합니다. 설정한 기간이 만료되기 전까지 토큰은 유효하기 때문에 탈취될 경우 유효기간이 만료되기 전까지는 손을 쓸 수 없게 됩니다. 이러한 점을 방지하기위해 두가지 방식을 도입했습니다.
1) Refresh 토큰 도입; Refresh 토큰은 오직 Access 토큰 재발급 용도로 사용되며 Access 토큰의 유효 기간을 짧게 설정할 수 있어 비교적 안전합니다.
2) JWT blacklist 도입; Refresh 토큰 발급 시 DB에 저장합니다. 재발급 요청 시에 토큰이 DB에 존재하는지 확인하는 과정을 거쳐 존재하지 않을 경우 검증에 실패합니다.
Blacklist를 적용한 이유?
앞서 말했던 것처럼 JWT는 유효기간이 존재합니다. 따라서 사용자가 로그아웃을 한다 하더라도 토큰이 유효하다면 재사용이 가능하기 때문에 악용될 가능성이 생깁니다. 이러한 취약점을 막기위해 유효기간과 상관없이 토큰을 사용하지 못하도록 하는 방법이 필요하고 이를 "Blacklist"라고 합니다.
비교적 유효기간이 긴 Refresh 토큰에만 블랙리스트를 적용했습니다. 처음에는 Refresh 토큰을 RDBMS(PostgreSQL)에 저장하고 로그인 시 발급, 로그아웃 시에 삭제되도록 구현했습니다. 그러나 이는 1. 별도의 저장공간을 필요로 하지 않는다는 JWT의 장점을 살리지 못하는 것 이며, 2. 로그인/로그아웃될 때마다 DB에 접근해야하기 때문에 효율적이지 못한 방법이라고 생각했습니다. 따라서 Refresh 토큰 저장소로 RDBMS가 아닌 Redis cache를 도입했습니다.
Redis를 사용한 이유?
Redis(Remote Dictionary Server) 란 "키-값" 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템입니다. 인메모리(In-Memory) 방식으로 속도가 빠르며 string, list, hash, set, stream 등 다양한 데이터 구조를 제공합니다. 캐싱, 세션 관리, pub/sub 등에 사용됩니다.
Redis는 인메모리로 데이터를 디스크가 아닌 RAM에서 관리합니다. 따라서 I/O 액세스 속도가 빠르고 로그인/로그아웃과 같이 빈번히 발생하는 I/O에 대한 병목을 줄일 수 있습니다. 또한 ttl을 지정해줄 수 있어 토큰 삭제 api를 호출하지 않아도 자동으로 삭제됩니다. 이는 자동 로그인 해제, 탈취 토큰 대응 등을 손쉽게 구현할 수 있도록 합니다.
NestJS에 Redis cache 적용하기
NestJS에서는 캐싱을 손쉽기 구현하기 위한 모듈을 제공합니다. 공식 문서에 따라 진행했으며 cache-manager, cache-manager-redis-store
패키지를 사용했습니다.
먼저 캐싱을 사용하기 위해 CacheModule
를 등록하고 register()
메소드를 호출합니다.
import type { ClientOpts } from 'redis';
@Module({
imports: [
CacheModule.register<ClientOpts>(redisConfig()),
]
})
// config/configuration.ts
export const redisConfig = (): ClientOpts => ({
store: redisStore,
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
auth_pass: process.env.REDIS_AUTH_PASSWORD,
isGlobal: true,
});
캐시 매니저를 사용하려면 CACHE_MANAGER
토큰을 사용하여 클래스에 주입하면 됩니다.
// auth.service.ts
import { Cache } from 'cache-manager';
@Injectable()
export class TokenService implements TokenServiceInterface {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) { }
}
Refresh 토큰이 생성될 때 캐싱에 저장하고 로그아웃 될 때 삭제됩니다.
// auth.service.ts
async createRefreshToken(payload: JwtPayload): Promise<string> {
//...refresh token 생성 및 암호화 (encryptRefreshToken)
// redis 저장
const ttl = +process.env.REFRESH_TOKEN_EXPIRATION_TIME;
await this.cacheManager.set(`${payload.sub}`, cryptRefreshToken, ttl);
return encryptRefreshToken;
}
async deleteRefreshToken(userId: number) {
await this.cacheManager.del(`${userId}`);
}
Access 토큰 재발급 시 verifyRefreshToken()
메소드가 실행됩니다. JWT 검증 메소드인 jwtService.verify()
가 실행되기 전에 해당 토큰이 캐싱에 존재하는지 확인하고 존재하지 않을 경우 검증 실패로 간주합니다.
async verifyRefreshToken(userId: number, encryptRefreshToken: string) {
const token = await this.cacheManager.get(`${userId}`);
if (!token) {
throw new CustomException(UsersException.REFRESH_TOKEN_NOT_EXISTS);
}
//...jwtService.verify()
}
Docker로 Redis 사용하기
저는 Redis를 컴퓨터에 직접 설치하지 않고 Docker를 사용했습니다.
컨테이너 생성할 때 패스워드를 설정해주지 않으면 redis-cli 접근 권한 에러가 발생합니다.
# redis 이미지 다운로드
$ docker pull redis
# 도커 컨테이너 생성 및 실행
$ docker run -d -p 6379:6379 --name <NAME> redis --requirepass <PASSWORD>
# 생성한 redis 컨테이너와 연결
$ docker exec -it "NAME" redis-cli -a <PASSWORD>
발생했던 에러
NestJS에 캐싱을 적용하고나니 다음과 같은 에러가 발생했습니다.
Cannot read properties of undefined (reading 'bind')
검색해보니 버전이 호환되지 않아 발생하는 문제였고 cache-manager@^4.1.0
, cache-manager-redis-store@^2.0
버전으로 다운그레이드해서 해결했습니다. NestJS v9로 업그레이드해도 해결된다고 합니다.
🔗https://stackoverflow.com/questions/73213405/in-nest-js-i-added-redis-as-a-cache-manager-and-cant-find-any-added-data-in-r
🔗https://github.com/node-cache-manager/node-cache-manager/issues/210
참고한 글
'NestJS, Node.js > #01 Project - 투표 커뮤니티' 카테고리의 다른 글
[NestJS] DI, DIP에 대해 알아보고 구현해보기 (0) | 2023.03.16 |
---|---|
[NestJS] TypeORM 적용하기 (0) | 2022.12.22 |
[NestJS] S3로 이미지 파일 관리하기 (0) | 2022.12.09 |
[NestJS] ValidationPipe 커스텀하기 (0) | 2022.11.30 |
[NestJS] Prisma 환경 변수 문제 해결하기 (0) | 2022.11.16 |