내가 이메일 인증 기능에 넣은 보안 요소


Nestjs로 이메일 인증코드로 회원가입 기능을 구현한 적이 있다.
우선 제가 생각한 고려해야 할 요소는
1. 인증코드 발급시 고려해야 하는 요소
- 제한시간 (ex 5분)
- 중복 인증 방지
- 인증시 즉시 expired시키기
이 정도였는데, 생각해보니 이런 악성 시나리오가 있겠더라구요 물론 실제로 할 가능성은 희박하긴 한데 가능성이 있다면 막아야겠죠 ^-^
2. 공격 시나리오
(1) A(피해자), M(공격자)라고 가정
(2) M이 사전에 이메일 인증번호를 요청하여 인증번호 입력 대기창에 진입
(3) 그 직후 (5분 제한 시간 이내) A가 이메일 인증번호 요청
(4) M이 인증번호 탈취 후 인증
(5) M은 인증 성공. A는 인증번호가 만료됐으니 실패.
(6) M은 이제 A의 정보로 살아갈 수 있습니다.
그림으로 표현하자면 다음과 같다.

위 시나리오를 어떻게 방지할 것인가..?
3. 나의 접근 방법
요청한 기기를 기준으로 고유 식별자를 따로 발급한 후 이메일 인증코드와 같이 보낸다.
이러면 해결이 되는 문제기 때문에 처음엔 요청 IP를 식별자로 사용을 할까? 했습니다. 그러나..... 이동 중에 IP가 바뀌면? 갑자기 와이파이가 연결되면? 사용자는 화가 나겠죠......
그래서 고유 토큰을 발급하는 것으로 결정했습니다.
4. 코드
sendEmail 함수
typescriptasync sendVerifyEmail(email: string) { const userExists = await this.prisma.user.findUnique({ where: { email }, }); if (userExists) { throw new BadRequestException('이미 가입된 이메일입니다.'); } const code = this.generateRandomCode(); const userToken = {유저_고유_토큰} const cachedData: Payload = await this.cacheManager.get( this.codeByEmail(email), ); if (!!cachedData?.isVerified) { throw new BadRequestException('이미 인증된 이메일입니다.'); }// ...
verifyEmail 함수
typescriptasync verifyEmail(body: { email: string; code: string; userToken: string }) { const { email, code, userToken } = body; const cacheData: Payload = await this.cacheManager.get( this.codeByEmail(email), ); if (!cacheData) { throw new BadRequestException('유효하지 않은 정보입니다.'); } if (userToken !== cacheData.userToken) { throw new BadRequestException('다른 브라우저에서 인증을 시도하였습니다.'); } if (cacheData.code !== code) { throw new BadRequestException('인증번호가 일치하지 않습니다.'); } if (cacheData.isVerified) { throw new BadRequestException('이미 인증된 이메일입니다.'); }// ...
5. 결론
이메일로 인증 버튼을 보내는 것도 생각해봤는데 그런 서비스들은 평소에 괜히 창 하나 더 켜지는 게 불편했어서 이런 방법을 써보았답니다
캡차도 추가해야겠다.