Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

승띵이의 개발일지

[Spring Boot] 비밀번호 재설정 본문

언어/Spring Boot

[Spring Boot] 비밀번호 재설정

승띵이 2022. 11. 10. 21:16
비밀번호 재설정


누구나 비밀번호를 잊어먹는 일이 발생할 수 있다.
보닌의 경우 기억력이 매우매우 X 138 나쁘다.
맨날 뭐 놔두고 오고 한 말 까먹고 주머니에 넣어놓고 물건 찾고 뭐 그런다.. 뭐 그렇다고..

TMI는 접어두고 오늘은 웹사이트에서 비밀번호를 재설정하는 기능을 구현해보려고 한다.

비밀번호를 재설정하기 위하여 다음과 같은 절차로 진행한다.

1. 이메일 주소 입력
2. 해당 이메일 주소로 비밀번호 재설정 링크 메일 발송
3. 해당 주소 접속시 비밀번호 재설정 가능한 폼 등장
4. 비밀번호 변경
5. 변경 완료 시 로그인 창으로 이동

결과


이젠 익숙해질만한 냅다 결과 보여주기

보세요

초기화면

이메일 입력 후 인증하기를 클릭하면 해당 로딩화면 등장

계정확인이 완료되면 숨어있던 이메일 발송 메세지가 나타난다.

그리고 인증 링크를 담은 메일 도착

메일의 인증하기 링크를 클릭하면 해당 alert 메세지가 팝업된다

그리고 비밀번호 재설정으로 돌아오면 갑자기 새로운 비밀번호 폼 두두등장

비밀번호 재설정 후 비밀번호 변경하기 버튼 클릭하면 비번 변경 완료!

원래 이렇던 비번이(암호화 된 상태)

이렇게 변경 완


어떻게 만들었는지 궁금하죠

알려는 드릴게

recoverPasswordAuthEmail

<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
<div style="max-width: 600px;">
  <div style="background-color: rgb(33, 47, 61); border-bottom: 2px solid rgb(86, 101, 115);  color: rgb(234, 236, 238); font-size: 28px; font-weight:bold; padding: 20px 30px;">
    스터디
  </div>
  <div style="line-height: 133%; padding: 30px;">
    <span>안녕하세요!&nbsp;<b>스터디</b>입니다.</span>
    <br>
    <br>
    <span>해당 이메일 주소로 비밀번호 재설정을 요청하여 인증할 수 있는 링크를 발송하였습니다. 본인이 요청한 것이 맞다면 아래 링크를 클릭하여 이메일 인증을 완료한 뒤 비밀번호를 재설정해 주시기 바랍니다.</span>
    <br>
    <br>
    <a href="#" style="background-color: rgb(33, 47, 61); border-radius: 0.375rem; color: rgb(234, 236, 238);display: inline-block; padding: 0.75rem 1rem; text-decoration: none; user-select: none" target="_blank"  th:href="@{http://localhost:8080/member/recoverPasswordEmail (email=${email}, code=${code}, salt=${salt})}">인증하기</a>
    <br>
    <br>
    <span>만약, 이메일 서비스 제공자의 보안 정책에 따라 위 링크가 클릭되지 않는다면 아래의 링크를 복사하여 주소창에 붙여넣어 사용할 수도 있습니다.</span>
    <br>
    <br>
    <code style="background-color: rgb(234, 236, 238);  color: rgb(86, 101, 115);display: inline-block; font-size: 13px; padding: 15px;"
          th:text="${'http://localhost:8080/member/recoverPasswordEmail?email=' + email + '&code=' + code + '&salt=' + salt}">http://localhost8080:/member/recoverPasswordEmail&머시기저시기</code>
    <br>
    <br>
    <span>해당 링크를 타인에게 알려줄 경우 개인정보 도용의 위험이 있습니다. 절대 타인에게 알려주시 마시오.</span>
    <br>
    <br>
    <span>혹시 본인이 요청한 적 없다면 이 이메일을 폐기해 주시기 바랍니다.</span>
    <br>
    <br>
    <span>감사합니다.</span>
  </div>
</div>
</body>
</html>


해당 HTML 파일은 비밀번호 변경을 위한 계정 인증 이메일 파일이다.
비밀번호 인증을 하면 해당 형식의 이메일이 발송된다.

해당 인증 링크는 아래와 같이 email, code, salt 값을 합친 형태를 가진다.

http://localhost:8080/member/recoverPasswordEmail (email=${email}, code=${code}, salt=${salt})

recoverPassword.js

const form = window.document.getElementById('form');

const Warning = {
    getElementById: () => form.querySelector('[rel="warningRow"]'),
    show: (text) => {
        const warningRow = Warning.getElementById();
        warningRow.querySelector('.text').innerText = text;
        warningRow.classList.add('visible');

    },
    hide: () => Warning.getElementById().classList.remove('visible')
};

//SUCCESS가 반환될때 까지 1초마다 FAILURE 반환
let emailAuthIndex = null;
setInterval(() => {
    if (emailAuthIndex === null) {
        return;
    }
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('index', emailAuthIndex);
    xhr.open('POST', './recoverPasswordEmail');
    xhr.onreadystatechange = () => {
        if (xhr.readyState === XMLHttpRequest.DONE) {
            if (xhr.status >= 200 && xhr.status < 300) {
                const responseObject = JSON.parse(xhr.responseText);
                console.log(xhr.responseText);
                switch (responseObject['result']) {
                    case'success':
                        form['code'].value = responseObject['code'];
                        form['salt'].value = responseObject['salt'];
                        form.querySelector('[rel="messageRow"]').classList.remove('visible');
                        form.querySelector('[rel="passwordRow"]').classList.add('visible');
                        form['password'].focus();
                        emailAuthIndex = null; //SUCCESS 되면 초기화
                        break;
                    default:
                }
            }
        }
    };
    xhr.send(formData);
}, 1000);

form['emailSend'].addEventListener('click', () => {
    Warning.hide();
    if (form['email'].value === '') {
        Warning.show('이메일을 입력해주세요.');
        form['email'].focus();
        return;
    }
    Cover.show('계정확인을 하고 있습니다., \n\n 잠시만 기다려 주세요.')
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('email', form['email'].value);
    xhr.open('POST', './recoverPassword');
    xhr.onreadystatechange = () => {
        if (xhr.readyState === XMLHttpRequest.DONE) {
            Cover.hide();
            if (xhr.status >= 200 && xhr.status < 300) {
                const responseObject = JSON.parse(xhr.responseText);
                switch (responseObject['result']) {
                    case'success':
                        emailAuthIndex = responseObject['index'];
                        form['email'].setAttribute('disabled', 'disabled');
                        form['emailSend'].setAttribute('disabled', 'disabled');
                        form.querySelector('[rel="messageRow"]').classList.add('visible');
                        break;
                    default:
                        Warning.show('해당 이메일을 사용하는 계정을 찾을 수 없습니다.');
                        form['email'].focus();
                        form['email'].select();
                }
            } else {
                Warning.show('서버와 통신하지 못하였습니다. 잠시 후 다시 시도해 주세요.')
            }
        }
    };
    xhr.send(formData);
});

//비밀번호 변경
form['passwordSend'].addEventListener('click', () => {
    Warning.hide();
    if (form['password'].value !== form['passwordCheck'].value) {
        Warning.show('비밀번호가 서로 일치하지 않습니다람쥐.');
        form['passwordCheck'].focus();
        form['passwordCheck'].select();
        return;
    }
    if (form['password'].value === '') {
        Warning.show('새로운 비밀번호를 입력해 주세요.');
        form['password'].focus();
        return;
    }
    Cover.show('비밀번호 변경을 하고 있습니다.\n\n 잠시만 기다려 주세요.')
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('password', form['password'].value);
    formData.append('email', form['email'].value);
    formData.append('code', form['code'].value);
    formData.append('salt', form['salt'].value);
    xhr.open('PATCH', './recoverPassword');
    xhr.onreadystatechange = () => {
        if (xhr.readyState === XMLHttpRequest.DONE) {
            Cover.hide();
            if (xhr.status >= 200 && xhr.status < 300) {
                const responseObject = JSON.parse(xhr.responseText);
                switch (responseObject['result']) {
                    case'success':
                        alert('비밀번호를 성공적으로 재설정하였습니다.\n\n확인을 누르시면 로그인 페이지로 이동합니다.')
                        window.location.href = 'login';
                        break;

                    default:
                }
            } else {
                Warning.show('비밀번호를 재설정하지 못하였습니다. 세션이 만료되었을 수도 있습니다.')
            }
        }
    };
    xhr.send(formData);
});

MemberMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.smchoi.studymemberbbs.mappers.IMemberMapper">
    <!--    @Param이 있으면 절대로 parameterType을 쓰지 않는다-->
    <select id="selectEmailAuthByEmailCodeSalt" resultType="com.smchoi.studymemberbbs.entities.member.EmailAuthEntity">
        SELECT `index`        AS `index`,
               `email`        AS `email`,
               `code`         AS `code`,
               `salt`         AS `salt`,
               `created_on`   AS `createdOn`,
               `expires_on`   AS `expiresOn`,
               `expired_flag` AS `isExpired`
        FROM `study_member`.`email_auths`
        WHERE BINARY `email` = #{email}
          AND BINARY `code` = #{code}
          AND BINARY `salt` = #{salt}
        LIMIT 1
    </select>
    <update id="updateEmailAuth"
            parameterType="com.smchoi.studymemberbbs.entities.member.EmailAuthEntity">
        UPDATE `study_member`.`email_auths`
        SET `email`       = #{email},
            `code`        = #{code},
            `salt`        = #{salt},
            `created_on`  = #{createdOn},
            `expires_on`  = #{expiresOn},
            `expired_flag`= #{isExpired}
        WHERE BINARY `index` = #{index}
        LIMIT 1
    </update>
    <insert id="insertUser"
            useGeneratedKeys="true"
            keyProperty="email"
            keyColumn="email"
            parameterType="com.smchoi.studymemberbbs.entities.member.UserEntity">
        INSERT INTO `study_member`.`users`(`email`, `password`, `nickname`, `name`, `contact`, `address_postal`,
                                           `address_primary`, `address_secondary`)
        VALUES (#{email}, #{password}, #{nickname}, #{name}, #{contact}, #{addressPostal}, #{addressPrimary},
                #{addressSecondary})
    </insert>
    <select id="selectUserByEmail" resultType="com.smchoi.studymemberbbs.entities.member.UserEntity">
        SELECT `email`             AS `email`,
               `password`          AS `password`,
               `nickname`          AS `nickname`,
               `name`              AS `name`,
               `contact`           AS `contact`,
               `address_postal`    AS `addressPostal`,
               `address_primary`   AS `addressPrimary`,
               `address_secondary` AS `addressSecondary`,
               `registered_on`     AS `registeredOn`
        FROM `study_member`.`users`
        WHERE BINARY `email` = #{email}
        LIMIT 1
    </select>
    <insert id="insertEmailAuth"
            useGeneratedKeys="true"
            keyProperty="index"
            keyColumn="index"
            parameterType="com.smchoi.studymemberbbs.entities.member.EmailAuthEntity">
        INSERT INTO `study_member`.`email_auths`(`email`, `code`, `salt`, `created_on`, `expires_on`, `expired_flag`)
        VALUES (#{email}, #{code}, #{salt}, #{createdOn}, #{expiresOn}, #{isExpired})
    </insert>

    <select id="selectEmailAuthByIndex"
            resultType="com.smchoi.studymemberbbs.entities.member.EmailAuthEntity">
        SELECT `index`        As `index`,
               `email`        AS `email`,
               `code`         AS `code`,
               `salt`         AS `salt`,
               `created_on`   AS `createdOn`,
               `expires_on`   AS `expiresOn`,
               `expired_flag` AS `isExpired`
        FROM `study_member`.email_auths
        WHERE BINARY `index` = #{index}
    </select>

    <update id="updateUser"
    parameterType="com.smchoi.studymemberbbs.entities.member.UserEntity">
        UPDATE `study_member`.`users`
        SET `password`          = #{password},
            `nickname`          = #{nickname},
            `name`              = #{name},
            `contact`           = #{contact},
            `address_postal`    = #{addressPostal},
            `address_primary`   = #{addressPrimary},
            `address_secondary` = #{addressSecondary},
            `registered_on`     = #{registeredOn}
        WHERE BINARY `email` = #{email}
        LIMIT 1
    </update>

    <select id="selectUserByEmailPassword" resultType="com.smchoi.studymemberbbs.entities.member.UserEntity">
        SELECT `email`             AS `email`,
               `password`          AS `password`,
               `nickname`          AS `nickname`,
               `name`              AS `name`,
               `contact`           AS `contact`,
               `address_postal`    AS `addressPostal`,
               `address_primary`   AS `addressPrimary`,
               `address_secondary` AS `addressSecondary`,
               `registered_on`     AS `registeredOn`
        FROM `study_member`.`users`
        WHERE BINARY `email` = #{email} and `password` = #{password}
        LIMIT 1
    </select>

</mapper>


IMemoMapper

package com.smchoi.studymemberbbs.mappers;

import com.smchoi.studymemberbbs.entities.member.EmailAuthEntity;
import com.smchoi.studymemberbbs.entities.member.UserEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface IMemberMapper {

    int insertEmailAuth(EmailAuthEntity emailAuth);

    int insertUser(UserEntity user);

    EmailAuthEntity selectEmailAuthByEmailCodeSalt(@Param(value = "email") String email,
                                                   @Param(value = "code") String code,
                                                   @Param(value = "salt") String salt);

    EmailAuthEntity selectEmailAuthByIndex(@Param(value = "index") int index);

    UserEntity selectUserByEmail(@Param(value = "email") String email);

    UserEntity selectUserByEmailPassword(String email,
                                         String password);
    int updateUser(UserEntity user);

    int updateEmailAuth(EmailAuthEntity emailAuth);

}

MemberService

@Transactional
public Enum<? extends IResult> recoverPasswordSend(EmailAuthEntity emailAuth) throws MessagingException {
    UserEntity recoverStudy = this.memberMapper.selectUserByEmail(emailAuth.getEmail());
    if (recoverStudy == null) {
        return CommonResult.FAILURE;
    }
    String authCode = RandomStringUtils.randomNumeric(6);
    String authSalt = String.format("%s%s%f%f",
            authCode,
            emailAuth.getEmail(),
            Math.random(),
            Math.random());
    authSalt = CryptoUtils.hashSha512(authSalt);
    Date createdOn = new Date();
    Date expiresOn = DateUtils.addMinutes(createdOn, 5);
    emailAuth.setCode(authCode);
    emailAuth.setSalt(authSalt);
    emailAuth.setCreatedOn(createdOn);
    emailAuth.setExpiresOn(expiresOn);
    emailAuth.setExpired(false);
    if (this.memberMapper.insertEmailAuth(emailAuth) == 0) {
        return CommonResult.FAILURE;
    }

    Context context = new Context();
    context.setVariable("email", emailAuth.getEmail());
    context.setVariable("code", emailAuth.getCode());
    context.setVariable("salt", emailAuth.getSalt());

    String text = this.templateEngine.process("member/recoverPasswordEmailAuth", context);
    MimeMessage mail = this.mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(mail, "UTF-8");
    helper.setFrom("choi434900@gmail.com");
    helper.setTo(emailAuth.getEmail());
    helper.setSubject("[스터디] 비밀번호 재설정 인증 링크");
    helper.setText(text, true);
    this.mailSender.send(mail);

    return CommonResult.SUCCESS;
}

public Enum<? extends IResult> recoverPasswordCheck(EmailAuthEntity emailAuth) {
    EmailAuthEntity existingEmailAuth = this.memberMapper.selectEmailAuthByIndex(emailAuth.getIndex());
    if (existingEmailAuth == null || !existingEmailAuth.getExpired()) {
        return CommonResult.FAILURE;
    }
    emailAuth.setCode(existingEmailAuth.getCode());
    emailAuth.setSalt(existingEmailAuth.getSalt());
    return CommonResult.SUCCESS;
}

public Enum<? extends IResult> recoverPasswordAuth(EmailAuthEntity emailAuth) {
    EmailAuthEntity existingEmailAuth = this.memberMapper.selectEmailAuthByEmailCodeSalt(
            emailAuth.getEmail(),
            emailAuth.getCode(),
            emailAuth.getSalt());

    if (existingEmailAuth == null) {
        return CommonResult.FAILURE;
    }
    if (new Date().compareTo(existingEmailAuth.getExpiresOn()) > 0) {
        return CommonResult.FAILURE;

    }
    existingEmailAuth.setExpired(true);
    if (this.memberMapper.updateEmailAuth(existingEmailAuth) == 0) {
        return CommonResult.FAILURE;
    }
    return CommonResult.SUCCESS;
}


public Enum<? extends IResult> recoverPassword(EmailAuthEntity emailAuth, UserEntity user) {
    EmailAuthEntity existingPasswordAuth = this.memberMapper.selectEmailAuthByEmailCodeSalt(
            emailAuth.getEmail(),
            emailAuth.getCode(),
            emailAuth.getSalt());

    if (existingPasswordAuth == null) {
        return CommonResult.FAILURE;
    }
    UserEntity userEmail = this.memberMapper.selectUserByEmail(existingPasswordAuth.getEmail());

    userEmail.setPassword(CryptoUtils.hashSha512(user.getPassword()));
    if (this.memberMapper.updateUser(userEmail) == 0) {
        return CommonResult.FAILURE;
    }
    return CommonResult.SUCCESS;
}


MemberController

@RequestMapping(value = "recoverPassword",
        method = RequestMethod.GET,
        produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView getRecoverPassword() {
    ModelAndView modelAndView = new ModelAndView("member/recoverPassword");
    return modelAndView;
}

@RequestMapping(value = "recoverPassword",
        method = RequestMethod.POST,
        produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String postRecoverPassword(EmailAuthEntity emailAuth) throws MessagingException {
    Enum<? extends IResult> result = this.memberService.recoverPasswordSend(emailAuth);
    JSONObject responseObject = new JSONObject();
    responseObject.put("result", result.name().toLowerCase());
    if(result == CommonResult.SUCCESS) {
        responseObject.put("index", emailAuth.getIndex());
    }
    return responseObject.toString();
}

@RequestMapping(value = "recoverPasswordEmail",
        method = RequestMethod.POST,
        produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String postRecoverPasswordEmail(EmailAuthEntity emailAuth) {
    Enum<?> result = this.memberService.recoverPasswordCheck(emailAuth);
    JSONObject responseObject = new JSONObject();
    responseObject.put("result", result.name().toLowerCase());
    if(result == CommonResult.SUCCESS) {
        responseObject.put("code", emailAuth.getCode());
        responseObject.put("salt", emailAuth.getSalt());
    }
    return responseObject.toString();
}

@RequestMapping(value = "recoverPasswordEmail",
        method = RequestMethod.GET,
        produces = MediaType.TEXT_HTML_VALUE)
@ResponseBody
public ModelAndView getRecoverPasswordEmail(EmailAuthEntity emailAuth) {
    Enum<?> result = this.memberService.recoverPasswordAuth(emailAuth);
    ModelAndView modelAndView = new ModelAndView("member/recoverPasswordEmail");
    modelAndView.addObject("result", result.name());

    return modelAndView;
}

@RequestMapping(value = "recoverPassword",
        method = RequestMethod.PATCH,
        produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String patchRecoverPassword(EmailAuthEntity emailAuth, UserEntity user) {
    Enum<?> result = this.memberService.recoverPassword(emailAuth, user);
    JSONObject responseObject = new JSONObject();
    responseObject.put("result", result.name().toLowerCase());

    return responseObject.toString();
}

정리


오늘 본 포스팅에서는 기존에 존재하던 비밀번호를 재설정하는 기능을 구현하였다.

세줄요약 하자면

1. 이메일 계정 인증 확인 시 인증링크가 포함된 메일이 해당 이메일로 발송된다.
2. 해당 링크로 접속 시 기존 비밀번호 재설정 페이지에 hide 되어있던 새 비밀번호 입력 폼과 비밀번호 변경하기 버튼이 나타난다.
3. 해당 폼에 적절한 값을 입력 후 비밀번호 변경 버튼을 클릭하면 비밀번호 변경 완료라는 팝업창과 함꼐 로그인 페이지로 이동한다.
4. <3> 의 실행과 동시에 본 서버의 DB에 password 값이 new password 값으로 변경된다.
5. 끝!

세줄 요약할랬지만 5줄 요약이 되어버렸네요

그렇다고요

마치며


이러한 로직을 한 번에 이해한다면 그 사람은 천재가 확실하다.
그리고 내가 천재가 아닌 것도 확실하다.

언젠가는 당연하듯이 이러한 로직을 짤 수 있는 사람이 되었으면..

기대한다 미래의 나


오늘도 끝! ^__^

Comments