SSAFY 1학기에 Share Your Trip에서 유저를 인증하기 위해 이메일 인증 방식을 사용했다. 하지만, 악의적인 사용자가 비밀번호 변경 API를 알아낸 뒤 다른 사용자의 비밀번호를 변경하면 취약점이 발생할 것이라 생각이 들었다. 이를 해결하기 위해, 유저 인증 처리에 대해서 학습해보려고 한다.
기존 이메일 인증 로직
클라이언트가 비밀번호 변경을 위해 이메일 인증 요청
서버는 인증번호를 생성하고 해당 클라이언트의 이메일로 메일 전송
메일 전송 완료 후, 서버는 생성된 인증번호를 클라이언트에게 전송
클라이언트는 자체 저장소(Redux, Recoil 등)에 저장하고 사용자가 입력한 값을 검증
일치한다면 패스워드 변경 진행
// 아이디, 패스워드 찾기용 이메일 인증 요청
@GetMapping("/find-email")
public ResponseEntity<Object> findMailSend(@RequestParam("email") String email) {
int auth_number = mailService.findSendMail(email);
return SuccessResponse.createSuccess(SuccessCode.EMAIL_SUCCESS, auth_number);
}
// 인증번호 생성
public static int createNumber(){
return (int)(Math.random() * (90000)) + 100000;
}
// 이메일 전송
public int sendEmail(String mail) {
int authNumber = createNumber();
MimeMessage message = javaMailSender.createMimeMessage();
try {
message.setFrom(senderEmail);
message.setRecipients(MimeMessage.RecipientType.TO, mail);
message.setSubject("이메일 인증");
String body = "";
body += "<h3>" + "요청하신 인증 번호입니다." + "</h3>";
body += "<h1>" + authNumber + "</h1>";
body += "<h3>" + "감사합니다." + "</h3>";
message.setText(body,"UTF-8", "html");
} catch (MessagingException e) {
e.printStackTrace();
}
javaMailSender.send(message);
return authNumber;
}
// 이메일 검증 및 이메일 전송 요청 함수 호출
public int findSendMail(String email){
repository.findByEmail(email).orElseThrow(
() -> new RestApiException(ErrorCode.USER_NOT_FOUND));
return sendEmail(email);
}
인증 로직의 문제점
악의적인 사용자가 다른 사용자의 이메일로 인증 이메일 전송 요청을 보내면 인증번호가 응답으로 전송받게 될 것이다.
이 과정에서 패킷 트레이서와 같은 스니핑 툴을 통해 인증번호를 획득하고 검증한다면 보안에 문제가 생길 것이다.
또한, 개발자 모드에서 Network 탭에 Payload를 통해서 인증번호를 획득할 수 있다는 문제점이 존재할 것이다.
해당 문제점은 서버에서 클라이언트에게 인증번호를 전송하지 않고 보관하고 있다가 클라이언트의 검증 요청을 처리하는 방법을 통해서 해결할 수 있을 것이다.
변경된 이메일 인증 로직
클라이언트가 비밀번호 변경을 위해 이메일 인증 요청
서버는 인증번호를 생성하고 해당 클라이언트의 이메일로 메일 전송
메일 전송 완료 후, 서버는 생성된 인증번호를 3분 Timeout을 걸어 Redis에 저장
클라이언트는 이메일을 통해 받은 인증번호를 작성한 후 검증 API 전송
서버는 Redis에서 클라이언트의 이메일을 이용해서 Redis에 저장된 인증번호 조회 및 검증 수행
클라이언트에 결과 전송
// 인증번호 생성
public static int createNumber(){
return (int)(Math.random() * (90000)) + 100000;
}
// 이메일 전송
public int sendEmail(String mail) {
int authNumber = createNumber();
MimeMessage message = javaMailSender.createMimeMessage();
try {
message.setFrom(senderEmail);
message.setRecipients(MimeMessage.RecipientType.TO, mail);
message.setSubject("이메일 인증");
String body = "";
body += "<h3>" + "요청하신 인증 번호입니다." + "</h3>";
body += "<h1>" + authNumber + "</h1>";
body += "<h3>" + "감사합니다." + "</h3>";
message.setText(body,"UTF-8", "html");
} catch (MessagingException e) {
e.printStackTrace();
}
javaMailSender.send(message);
// redis 인증번호 저장
redisTemplate.opsForValue().set(
mail,
String.valueOf(authNumber),
180,
TimeUnit.SECONDS
);
return authNumber;
}
// 이메일 검증 및 이메일 전송 요청 함수 호출
public int findSendMail(String email){
repository.findByEmail(email).orElseThrow(
() -> new RestApiException(ErrorCode.USER_NOT_FOUND));
return sendEmail(email);
}
// controller 코드
@PostMapping("/check-auth")
public ResponseEntity<Object> checkAuth(@RequestBody MailDto.authRequestDto dto) {
mailService.checkAuth(dto);
return SuccessResponse.createSuccess(SuccessCode.AUTH_SUCCESS);
}
// Service 코드
public boolean checkAuth(MailDto.authRequestDto dto) {
String number = redisTemplate.opsForValue().get(dto.getEmail());
if(StringUtils.hasText(number) && number.equals(dto.getNumber())) {
return true;
}
throw new RestApiException(ErrorCode.INVALID_NUMBER);
}