인증 코드를 전수 점검했더니, 흩어져 있던 이슈들이 사실 같은 뿌리에서 나왔다. 권한 상태는 로그인 시점에만 확인되는데, 토큰은 그 시점보다 오래 산다. 그리고 인증 토큰을 비밀번호만큼 조심해서 다루지 않았다. 둘 다 평소엔 안 보이다가, 계정을 정지시키거나 DB가 새는 순간 드러난다.
상태 검사는 로그인이 아니라 토큰이 발급되는 모든 곳에
계정을 SUSPENDED로 바꿔도 그 사용자가 계속 들어왔다. 로그인은 막혔는데 어떻게.
상태 검사가 login() 한 곳에만 있었기 때문이다. 그런데 토큰을 만들어 주는 길은 로그인 말고도 있다.
jwt.strategy.validate(): 매 요청에서 JWT를 검증하는 자리. 여기서 사용자 상태를 안 보면, 이미 발급된 토큰을 든 정지 계정이 그대로 통과한다.refreshToken(): 리프레시 토큰이 유효하면 새 access token을 내준다. 여기서 상태를 안 보면, 정지시킨 직후에도 갱신으로 토큰을 계속 받는다.
로그인은 “이 사람이 들어와도 되나”를 한 번 묻는다. 하지만 그 답은 토큰에 박제돼 토큰 수명 내내 따라다닌다. 계정 정지가 즉시 반영되려면, 토큰이 발급되거나 검증되는 모든 지점에서 상태를 다시 봐야 한다.
async validate(payload: JwtTokenPayload) { const user = await this.userService.findOneByEmail(payload.email); if (!user) throw new UnauthorizedException(); // 로그인 때 통과했어도, 매 요청에서 다시 본다 if (user.status === UserStatus.SUSPENDED || user.status === UserStatus.WITHDRAWN) { throw new UnauthorizedException('접근이 제한된 계정입니다.'); } // ...}회사를 지웠는데 직원이 API를 통과한 그 구멍과 같은 종류다. “입구에서 한 번 확인했다”가 “모든 입구에서 확인한다”를 보장하지 않는다.
검증 토큰은 비밀번호처럼 다룬다
이메일 인증 토큰이 평문 UUID 그대로 DB에 저장돼 있었다. 비밀번호는 해시해 두면서, 이메일 인증·초대 토큰은 원문을 그대로 둔 것이다.
이게 왜 문제냐면, 그 토큰은 사실상 “이 계정을 활성화할 수 있는 비밀”이다. DB가 한 번 새면, 공격자는 그 평문 토큰으로 계정을 바로 활성화하거나 비밀번호를 설정할 수 있다. 그래서 비밀번호 재설정 토큰과 똑같이 다뤘다. DB에는 SHA-256 해시를 저장하고, 원문은 이메일 URL에만 담는다. 검증할 땐 들어온 토큰을 해시해서 비교한다.
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');if (user.emailVerifyToken !== tokenHash) throw new BadRequestException('유효하지 않은 토큰입니다.');한 가지 순서를 더 챙겼다. 만료 검사를 토큰 해시 비교보다 먼저 둔다. 만료된 토큰은 해시를 맞춰볼 것도 없이 떨어내, 비교 연산에 시간을 쓰지 않는다. DB에 남는 건 해시뿐이라, DB만으로는 인증 링크를 되살릴 수 없다.
보안 작업은 원자적이어야 한다
비밀번호 재설정이 두 단계로 나뉘어 있었다. 비밀번호를 새로 쓰고, 그 다음 재설정 토큰을 지운다.
await this.userService.updatePassword(userId, hashedPassword); // 1await this.userService.clearPasswordResetToken(userId); // 21과 2 사이에서 죽으면, 비밀번호는 바뀌었는데 재설정 토큰은 살아 있다. 그 토큰으로 비밀번호를 또 바꿀 수 있다. 그래서 둘을 단일 UPDATE로 묶어 한 번에 처리했다. 비밀번호 변경과 토큰 무효화는 “둘 다” 또는 “둘 다 아님”이어야 한다.
await this.userRepo.update(userId, { password: hashedPassword, passwordResetToken: null, passwordResetExpiresAt: null,});검증과 차감을 같은 트랜잭션에 묶어야 했던 것과 같은 이야기다. 보안에 걸린 두 변경이 따로 커밋되면, 그 틈이 곧 구멍이다.
그래서
인증 점검에서 반복해 나온 교훈은 단순하다. 한 번 확인한 권한은 시간이 지나면 거짓이 되고, 토큰은 그 거짓을 수명 내내 들고 다닌다. 그러니 상태는 토큰이 오가는 모든 지점에서 다시 보고, 계정을 여는 토큰은 비밀번호만큼 감추고, 보안에 걸린 변경은 한 트랜잭션으로 묶는다. 셋 다 “한 곳에서 한 번”이 “모든 곳에서 매번”을 보장하지 않는다는 같은 함정을 가리킨다.