비밀번호 찾기를 어떻게 구현할 것인가?
팀원들과 비밀번호 찾기에 대해서 열렬히 의견을 주고 받았었다. 여러 방법이 있겠지만 우리의 후보에 올랐던 방법들은 다음과 같다.
1. 단순하게 임시 비밀번호를 발급하는 방법
2. 비밀번호 재설정 링크를 생성하여 이메일로 보내는 방법
3. 이메일로 인증번호를 보낸 후 인증번호가 일치하면 비밀번호를 재설정하는 방법
1번은 보안상 좋지 않고, 2번은 관련 자료가 Node.js 뿐이었고 처음에는 잘 와닿지 않아서 최종적으로는 3번을 택하게 되었다. 이메일을 보내기 위한 프로젝트용 계정을 생성했고, Gmail을 이용했다. 그런데 방법을 정했다고 해서 일사천리로 해결되는 게 아니었다. 처음에 내가 제안한 방법은 아래와 같다.
1. 프론트->백 이메일 주소
2. 백-> 프론트 가입된 이메일주소 맞으면 인증번호를 포함한 이메일을 유저에게 전송 하고 인증번호(certificationNumber)를 반환 / 틀리면 error
3. 유저: 인증번호 6자리 입력
4. 프론트->백: 백으로 부터 받았던 인증번호, 유저가 입력한 인증번호
5. 백: 인증번호 일치할 시 ok, 일치하지 않을시 error
6. 프론트: ok면 비밀번호 재설정 페이지로 이동
7. 프론트 -> 백 변경된 비밀번호 전송 (이때, 유저의 이메일이 필요함)
8. 백: 해당 유저의 비밀번호 변경
위의 방법에 경우 3가지 API가 필요하다.
1에서 필요한 API: 인증번호 생성 및 이메일 전송 API
4에서 필요한 API: 인증번호 일치하는지 API
7에서 필요한 API: 비밀번호 재설정 API
4번의 경우 동일한 인증번호를 굳이 비교하는 점, 7번에 필요한 유저의 이메일을 1번에서 처음 넘겨 받은 후에 계속 백단에서 저장해야하는 점이 좋은 방법이 아니라는 결론이 내려졌다. 그래서 고민 후에 최종_진짜최종 의 결정으로 2번 재설정 링크 방식을 채택하게 되었다.
비밀번호 찾기(재설정) 과정
1. 회원은 가입했던 이메일을 입력 후 이메일 전송 버튼을 클릭한다.
2. 입력 받은 이메일로 가입한 회원이 있는지 체크한다.
3. 회원이 존재한다면 랜덤한 UUID를 붙인 비밀번호 재설정 URL을 생성한다.
4. 3번의 URL을 포함한 메일을 1번에서 입력받은 메일 주소로 전송한다.
5. 메일을 전송 후 3번에서 생성된 UUID와 회원의 이메일을 각각 key와 value로 Redis에 저장하고 유효시간을 24시간으로 설정한다.
6. 비밀번호 재설정 API는 UUID와 새 비밀번호를 넘겨받는다.
7. 먼저 Redis에 해당 UUID가 존재하는지 확인 후 없으면 오류를 발생 시킨다.
8. UUID를 통해 회원의 email을 Redis에서 찾아오고, 그 email로 Member를 조회한다.
9. 새로 입력받은 비밀번호로 해당 Member의 비밀번호를 재설정한다.
10. 비밀번호 재설정이 끝난 후 Redis에서 해당 uuid를 삭제한다.
개인적으로 로그아웃 기능 구현 당시 Redis를 사용한 경험이 있었기 때문에 UUID와 Email에 유효시간을 설정하여 저장하는 방식이 더 편리하게 느껴졌다.
메일을 보내기 위한 설정
build.gradle
//mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
application.yml
# SMTP
mail:
host: smtp.gmail.com
port: 587
username: {이메일 계정}
password: {이메일 계정 패스워드}
properties:
mail:
smtp:
starttls:
enable: true
auth: true
#properties
props:
reset-password-url: {프론트 배포 서버 url}
UUID 생성 및 이메일 전송 API
//UUID 생성 및 이메일 전송
@Operation(summary = "UUID 생성 및 이메일 전송")
@PostMapping("/send-reset-password")
public SendResetPasswordEmailRes sendResetPassword(
@Validated @RequestBody SendResetPasswordEmailReq resetPasswordEmailReq) {
memberService.checkMemberByEmail(resetPasswordEmailReq.getEmail());
String uuid = mailService.sendResetPasswordEmail(resetPasswordEmailReq.getEmail());
return SendResetPasswordEmailRes.builder()
.UUID(uuid)
.build();
}
메일 전송 서비스
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class SendMailService {
private final RedisService redisService;
@Value("${spring.mail.username}")
private String fromEmail;
@Value("${props.reset-password-url}")
private String resetPwUrl;
@Autowired
JavaMailSender mailSender;
public String makeUuid() {
return UUID.randomUUID().toString();
}
@Transactional
public String sendResetPasswordEmail(String email) {
String uuid = makeUuid();
String title = "요청하신 비밀번호 재설정 입니다."; // 이메일 제목
String content = "대동덕지도" //html 형식으로 작성
+ "<br><br>" + "아래 링크를 클릭하면 비밀번호 재설정 페이지로 이동합니다." + "<br>"
+ "<a href=\"" + resetPwUrl + "/" + uuid + "\">"
+ resetPwUrl + "/" + uuid + "</a>" + "<br><br>"
+ "해당 링크는 24시간 동안만 유효합니다." + "<br>"; //이메일 내용 삽입
mailSend(email, title, content);
saveUuidAndEmail(uuid, email);
return uuid;
}
//이메일 전송 메소드
public void mailSend(String toMail, String title, String content) {
MimeMessage message = mailSender.createMimeMessage();
// true 매개값을 전달하면 multipart 형식의 메세지 전달이 가능.문자 인코딩 설정도 가능하다.
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8");
helper.setFrom(new InternetAddress(fromEmail, "대동덕지도"));
helper.setTo(toMail);
helper.setSubject(title);
// true 전달 > html 형식으로 전송 , 작성하지 않으면 단순 텍스트로 전달.
helper.setText(content, true);
mailSender.send(message);
} catch (MessagingException e) {
e.printStackTrace();
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
// UUID와 Email을 Redis에 저장
@Transactional
public void saveUuidAndEmail(String uuid, String email) {
long uuidValidTime = 60 * 60 * 24 * 1000L; // 24시간
redisService.setValuesWithTimeout(uuid, // key
email, // value
uuidValidTime); // timeout(milliseconds)
}
}
비밀번호 재설정 서비스
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final Props props;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final RedisService redisService;
@Transactional
public void resetPassword(String uuid, String newPassword) {
//redis에 uuid가 있는지 확인, 없으면 error
String email = redisService.getValues(uuid);
if (email == null) {
throw new InvalidUuidException();
}
//redis에서 uuid로 email을 찾아온다.
Member member = memberRepository.findByEmail(email)
.orElseThrow(NonExistentMemberException::new);
//비밀번호 재설정
member.updatePassword(passwordEncoder.encode((newPassword)));
//비밀번호 업데이트 후 redis에서 uuid를 지운다.
redisService.deleteValues(uuid);
}
}
Redis 서비스
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, String> redisTemplate;
@Transactional
public void setValues(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
// 만료시간 설정 -> 자동 삭제
@Transactional
public void setValuesWithTimeout(String key, String value, long timeout) {
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MILLISECONDS);
}
public String getValues(String key) {
return redisTemplate.opsForValue().get(key);
}
@Transactional
public void deleteValues(String key) {
redisTemplate.delete(key);
}
}
구현 결과