Spring Projcect/학습 관리 시스템 & 백오피스 구축

Chapter 08. 스프링 부트 프로젝트 - 비밀번호 찾기(초기화)

계란💕 2022. 8. 18. 16:46

  Ex) 메인 페이지에 공통적으로 계속 사용할 코드를 어떻게 관리할까?

 

     - ThymeLeaf fragment

     - layout 파일을 이렇게 구성하면 

<hide/>
<!DOCTYPE html>
<html lang="ko"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>fastlms</title>
</head>
<body>

    <div th:fragment="fragment-body-menu">
        <div>
            <a href="/member/register">회원 가입</a>
            |
            <a href="/member/info">회원 정보</a>
            |
            <a href="/member/login">로그인</a>
            |
            <a href="/member/logout">로그아웃</a>
        </div>
        <hr/>
    </div>
</body>
</html>

 

     - index 파일을 간단히 작성 가능

<hide/>
<!doctype html>
<html lang="ko"  xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>메인 페이지</title>
    </head>
    <body>
        <h1>메인 페이지</h1>
        <div th:replace="/fragments/layout.html :: fragment-body-menu" >
        </div>
        <hr/>
    </body>
</html>

 

    - 다른 info,  파일에도 모두 적용한다.

<div th:replace="/fragments/layout.html :: fragment-body-menu" ></div>

 

     - 가입 결과 페이지는 true / false에 맞게 각각 넣어줘야한다.

 

  Note) 실행 결과

 

 

 ==================================오류 =====================================

  - 로그인을 해야만 메인페이지가 안 뜬다.

  - 그리고 메인 기본 인덱스 페이지가 안 나오고 로그인 페이지가 첫 번째로 나온다.

  - 원인: SecurityConfig클래스에서 메서드 이름을 잘못 썼다.

 

 

  Ex) 비밀 번호 찾기

 

    - 로그인 페이지

    - member/find/password는 @PostMapping() 안의 매개변수 - URL 주소

<hide/>
<!DOCTYPE html>
<html lang="ko"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원 로그인</title>
</head>
<body>
  <h1>회원 로그인</h1>

  <div th:replace="/fragments/layout.html :: fragment-body-menu" ></div>
  <div th:text="${errorMessage}">
    </div>

    <form method="post">
        <div>
            <input type="text" name="username" placeholder="아이디(이메일)을 입력해주세요"/>
        </div>
        <div>
            <input type="password" name="password" placeholder="비밀번호 입력"/>
        </div>
        <div>
            <button type="submit" >로그인</button>
        </div>

        <div>
            <a href="/member/find/password">비밀번호 찾기</a>
        </div>

    </form>
</body>
</html>

 

    - 멤버 컨트롤러

<hide/>
@GetMapping("/member/find/password")
public String findPassword(){
    return "member/find_password";
}

 

    - find_password 파일 만든다.

       -> 데이터베이스의 변수명을 보고 만든다

<hide/>
<!DOCTYPE html>
<html lang="ko"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원 비밀번호 찾기</title>
</head>
<body>
  <h1>회원 비밀번호 찾기</h1>

  <div th:replace="/fragments/layout.html :: fragment-body-menu" ></div>
    <form method="post">
        <div>
            <input type="text" name="userId" placeholder="아이디(이메일)을 입력"/>
        </div>
        <div>
            <input type="text" name="userName" placeholder="이름 입력"/>
        </div>
        <div>
            <button type="submit" >비밀번호 초기화 요청</button>
        </div>

    </form>
</body>
</html>

 

 

    - http://localhost:8080/member/find_password  입력하면  http://localhost:8080/member/login페이지로 이동한다.

      -> SecurityConfig 클래스 설정 때문이다.

      -> SecurityConfig 에 내용 추가

      (find_password 수정

<hide/>
   http.authorizeRequests()
                .antMatchers(
                        "/"
                        , "/member/register"
                        , "/member/email-auth"
                        , "/member/find/password"
                )
                .permitAll();

 

  Note) 실행 결과

    - ResetPasswordInput

<hide/>
package com.zerobase.fastlms.member.model;
import lombok.Data;
import lombok.ToString;

@Data
@ToString
public class ResetPasswordInput {
    private String userId;
    private String userName;
}

 

    - 비밀번호 찾기 메서드

      -> 새로운 비밀번호를 파라미터로 받는다.

      -> 주소는 그대로 뷰만 바꾸고 메인 페이지로 넘기려면? => return "index" => 

      -> 주소도 같이 바꾸고 메인 페이지로 넘기려면? =>  return "redirect:/"

      -> 인터페이스 model에 속성을 추가한다.

<hide/>
 @PostMapping("/member/find/password")
    public String findPasswordSubmit(
            Model model,
            ResetPasswordInput parameter){

//       boolean result = memberService.sendResetPassword(parameter);    // 초기화 메일 보낸 결과
//       model.addAttribute("result", result);
       return "member/find_password_result";
    }

 

    - 맞는지 확인하기

 

    - find_password_result.html : 비밀번호 찾기 결과 페이지

<hide/>
<!DOCTYPE html>
<html lang="ko"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원 비밀번호 찾기 요청 결과</title>
</head>
<body>
  <h1>회원 비밀번호 찾기 요청 결과</h1>
<!--  <div th:replace="/fragments/layout.html :: fragment-body-menu" ></div>-->
    <p>입력하신 이메일로 비밀번호 초기화 정보를 보내드렸습니다.</p>
</body>
</html>

  Note) 실행 결과

    - result 만들어줘야한다. (이메일과 이름 맞는지)

    - 이메일 보내준다.

    - 멤버서비스

<hide/>
	 /**
     * 입력한 이메일로 비밀번호 초기화 정보를 전송
     */
    boolean sendResetPassword(ResetPasswordInput parameter);

 

    - 멤버리포지토리 - 메서드 findByUserIdAndUserName() 추가

<hide/>
package com.zerobase.fastlms.member;
import com.zerobase.fastlms.member.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, String> {
    Optional<Member> findByEmailAuthKey(String emailAuthKey);
    Optional<Member> findByUserIdAndUserName(String userId, String userName);
}

 

    - 멤버서비스임플 - sendResetPassword()

      -> 사용자한테 메일을 보내고 나서 사용자가 링크를 타고 와서 아이디 패스워드를 입력 => 입력한 데이터로 계정 초기화

      -> 사용자만 아는 유일한 값을 보낸다.

 

    - Member 

      -> 키가 일치할 때만 비밀번호 초기화 가능하도록 한다.

private String resetPasswordKey;

 

      -> 시간 초과하면 더이상 초기화할 수 없다.

private LocalDateTime resetPasswordLimitDt;

 

     - 멤버서비스임플

       -> id는 url에 나오는 특정한 값이다.

<hide/>
    @Override
    public boolean sendResetPassword(ResetPasswordInput parameter) {
        Optional<Member> optionalMember = memberRepository.findByUserIdAndUserName(parameter.getUserId(), parameter.getUserName());

        // 정보가 없으면
        if(!optionalMember.isPresent()){
            throw new UsernameNotFoundException("회원 정보가 존재하지 않습니다.");
        }

        Member member = optionalMember.get();
        String uuid = UUID.randomUUID().toString();

        member.setResetPasswordKey(uuid);
        member.setResetPasswordLimitDt(LocalDateTime.now().plusDays(1));    // 하루 안에 초기화 해야한다.
        memberRepository.save(member);

        // 있으면 메일을 보낸다.
        String email = parameter.getUserId();
        String subject = "[fastlms] 비밀번호 초기화 메일입니다.";
        String text = "<p>fastlms 비밀번호 초기화 메일입니다.</p>" +
                       "<p>비밀번호를 초기화해주세요.</p>" +
                       "<div><a target='_blank'   href='http://localhost:8080/member/reset/password?id=" +
                        uuid +
                        "'> 비밀번호 초기화 링크 </a></div>";

        mailComponents.sendMail(email, subject, text);
        return false;
    }

  Note) 실행 결과

 

 

  Ex)  회원 정보 입력해서 비밀번호 찾기

<hide/>
    @Override
    public boolean sendResetPassword(ResetPasswordInput parameter) {

        Optional<Member> optionalMember = memberRepository.findByUserIdAndUserName(parameter.getUserId(), parameter.getUserName());

        // 정보가 없으면

        if (!optionalMember.isPresent()) {
            throw new UsernameNotFoundException("회원 정보가 존재하지 않습니다.");
        }

            Member member = optionalMember.get();
            String uuid = UUID.randomUUID().toString();

            member.setResetPasswordKey(uuid);
            member.setResetPasswordLimitDt(LocalDateTime.now().plusDays(1));    // 하루 안에 초기화 해야한다.
            memberRepository.save(member);

            // 있으면 메일을 보낸다.
            String email = parameter.getUserId();
            String subject = "[fastlms] 비밀번호 초기화 메일입니다.";
            String text = "<p>fastlms 비밀번호 초기화 메일입니다.</p>" +
                    "<p>비밀번호를 초기화해주세요.</p>" +
                    "<div><a target='_blank'   href='http://localhost:8080/member/reset/password?id=" +
                    uuid +
                    "'> 비밀번호 초기화 링크 </a></div>";

            mailComponents.sendMail(email, subject, text);
            return true;
    }

 

    - ServiceImpl에 내용을 추가한다.

<hide/>
  http.authorizeRequests()
                .antMatchers(
                        "/"
                        , "/member/register"
                        , "/member/email-auth"
                        , "/member/find-password"
                        , "/member/reset/password"
                )
                .permitAll();

 

    - 멤버컨트롤러

<hide/>
 @GetMapping("/member/reset/password")
    public String resetPassword(){
        return "member/reset_password";
    }

 

    - 확인 비밀번호는 서버로 데이터를 보낼 필요 없이 클라이언트에서 맞는지 체크하면 된다. jQuery

 

  Note) 비밀 번호 찾기 => 회원 정보 일치

    - 데이터베이스에 데이터가 들어온다. 인증 날짜, 인증 키

 

    - 이메일 링크 클릭 => 비번 확인은 나중에 구현한다. jQuery

 

 

 

  Note) 비밀 번호 찾기 => 틀린 정보 입력

 

 

  Ex)  jQuery 다운로드

    - 접속 https://releases.jquery.com/

    - 압축되지 않은 minified 선택

 

 

    - copy 버튼을 누른다.

 

    - reset_password 파일에 추가

      -> 

<hide/>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"  integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <script>
        $(document).ready(function (){
            alert('1');
        });
    </script>

 

     - 비밀번호 초기화 화면에서 새로고침 누르면?

      -> 아래와 같은 알림창이 뜬다.

   

 

   

 

  Ex) 비밀번호 재설정 버튼 누르고 난 다음, 페이지 만든다.

 

    - reset_password 파일에서 버튼은 하나밖에 없다. 

    - password를 찾아서 값이 같은지 확인한다.

    - this: 현재 폼, input의 이름이 password인 값을 찾아서 저장한다.

var password = $(this).find('input[name=password]').val();

 

    -  비밀번호를 다르게 입력하고 비밀번호 재설정 버튼을 누르면?

<hide/>
    <script>
        $(document).ready(function (){
            $('form').on('submit', function () {

                const password = $(this).find('input[name=password]').val();
                const rePassword = $(this).find('input[name=rePassword]').val();

                if(password != rePassword){
                    alert("비밀번호가 일치하지 않습니다.");
                    return false;
                }
            });
            // return true;   없어야 서버로 데이터 전송된다.
        });
    </script>

 

  Note) 실행 화면

 

    - 비밀번호 초기화 메서드

      -> 비밀번호 재설정 창에서 넘어온 값들을 받아서 비밀번호를 새로 지정한다.

    - ResetPasswordInput()

<hide/>
package com.zerobase.fastlms.member.model;
import lombok.Data;
import lombok.ToString;
@Data
@ToString
public class ResetPasswordInput {

    private String userId;
    private String userName;
    private String password;
}

 

    - 비밀번호 재설정 화면

 

    - 재설정 버튼 누른 화면

      -> 화면에 나온 값을 가지고 아이디를 초기화할 계획이다.

 

    - 멤버컨트롤러

<hide/>
    @PostMapping("/member/reset/password")
    public String resetPasswordSubmit(Model model, ResetPasswordInput parameter){

        model.addAttribute("parameter", parameter);
        boolean result = memberService.resetPassword(parameter.getUserId(), parameter.getPassword());
        return "member/reset_password";
    }

 

    - 멤버서비스 메서드 추가한다.

<hide/>
	/**
     * 입력받은 uuid에 대해서 password로 초기화한다
     * */
    boolean resetPassword(String userId, String password);

 

    - MemberServiceImpl에 메서드 추가

<hide/>
 @Override
    public boolean resetPassword(String uuid, String password) {

        Optional<Member> optionalMember = memberRepository.findById(uuid);
        if(!optionalMember.isPresent()){
            throw new UsernameNotFoundException("회원 정보가 존재하지 않습니다.");
        }
        return false;
    }

 

    - 멤버리포짓에 추가

Optional<Member> findByResetPasswordKey(String resetPasswordKey);

 

    - resetPassword() 구현

      -> 패스워드 초기화 요청할 때, 날짜가 맞아야만 초기화 가능하다.

<hide/>
@Override
public boolean resetPassword(String uuid, String password) {

    Optional<Member> optionalMember = memberRepository.findByResetPasswordKey(uuid);
    if(!optionalMember.isPresent()){
        throw new UsernameNotFoundException("회원 정보가 존재하지 않습니다.");
    }

    //회원 정보가 맞다면?
    Member member = optionalMember.get();

    // 초기화 날짜가 유효한지 체크한다.
    if(member.getResetPasswordLimitDt() == null){
        throw new RuntimeException("유효한 날짜가 아닙니다.");
    }
    if(member.getResetPasswordLimitDt().isBefore(LocalDateTime.now())){
        throw new RuntimeException("유효한 날짜가 아닙니다.");
    }

    String encPassword = BCrypt.hashpw(password, BCrypt.gensalt()); // 암호화된 패스워드로 설정
    member.setPassword(encPassword);
    member.setResetPasswordKey("");
    member.setResetPasswordLimitDt(null);
    memberRepository.save(member);
    return true;
}

 

    - 멤버 컨트롤러 - resetPasswordSubmit()

<hide/>
@PostMapping("/member/reset/password")
public String resetPasswordSubmit(Model model, ResetPasswordInput parameter){

    model.addAttribute("parameter", parameter);
    boolean result = false;

    try{
        result = memberService.resetPassword(parameter.getUserId(), parameter.getPassword());

    }catch (Exception e){
        e.printStackTrace();
    }
    model.addAttribute("result", result);
    return "member/reset_password_result";
}

 

    - 비밀번호 초기화한 다음, 결과 나타낼 파일 reset_password_result 을 만든다. 

       -> 비밀번호 초기화 결과는 위에 컨트롤러의 resetPasswordSummit의 결과 "result"를 보고 판단한다.

<hide/>
<!DOCTYPE html>
<html lang="ko"  xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원 비밀번호 초기화 결과</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"  integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>


    <script>
        $(document).ready(function (){
            $('form').on('submit', function () {

                const password = $(this).find('input[name=password]').val();
                const rePassword = $(this).find('input[name=rePassword]').val();

                if(password != rePassword){
                    alert("비밀번호가 일치하지 않습니다.");
                    return false;
                }
            });
            // return true;   없어야 서버로 데이터 전송된다.
        });
    </script>

</head>
<body>
    <h1>회원 비밀번호 초기화 결과</h1>

    <div th:if="${result eq true}">
        <p>비밀번호 초기화 되었습니다. </p>
        <div>
            <a href="member/login">로그인</a>
        </div>
    </div>

    <div th:if="${result eq false}">
        <p>비밀번호 초기화에 실패했습니다.</p>

        <div>
            <a href="member/find-password">비밀번호 다시 찾기</a>
        </div>
    </div>
</body>
</html>

 

    - 비밀번호 초기화 결과

 

     - 초기화가 정상적으로 이뤄지면 데이터가 지워진다.

      -> 그런데 문제는 여전히 이메일에서 링크를 타고 들어올 수 있다는 것이다.

      -> 이 문제를 방지하려면?

      -> 컨트롤러의 resetPassword() 에서 uuid가 유효한지 확인해야한다.

 

      - 컨트롤러를 내용 추가

<hide/>
@GetMapping("/member/reset/password")
public String resetPassword(Model model, HttpServletRequest request){
    String uuid = request.getParameter("id");
    model.addAttribute("uuid", uuid);
    boolean result = memberService.checkResetPassword(uuid);

    return "member/reset_password";
}

 

    - 멤버서비스

      ->uuid를 매개변수로 넣었을 때, 적잘한 값인지 체크하기 위한 메서드 "checkResetPassword()"

/**
 * 입력받은 uuid 유효성 검사
 */
boolean checkResetPassword(String uuid);

 

    - 멤버서비스 임플 위 메서드 구현

      -> result를 그대로  컨트롤러의 model에 저장한다.

<hide/>
@Override
public boolean checkResetPassword(String uuid) {
    Optional<Member> optionalMember = memberRepository.findByResetPasswordKey(uuid);
    if(!optionalMember.isPresent()){
        return false;
    }

    //회원 정보가 맞다면?
    Member member = optionalMember.get();

    // 초기화 날짜가 유효한지 체크한다.
    if(member.getResetPasswordLimitDt() == null){
        throw new RuntimeException("유효한 날짜가 아닙니다.");
    }
    if(member.getResetPasswordLimitDt().isBefore(LocalDateTime.now())){
        throw new RuntimeException("유효한 날짜가 아닙니다.");
    }
    return true;
}

 

<hide/>
@GetMapping("/member/reset/password")
public String resetPassword(Model model, HttpServletRequest request){
    String uuid = request.getParameter("id");
    boolean result = memberService.checkResetPassword(uuid);
    model.addAttribute("result", result);
    return "member/reset_password";
}

 

    - 그러고나서 비밀번호 초기화 창에서 위 변수를 이용 가능하다.

 

    - 위 데이터베이스에서 reset_password_limit_dt 값이 null 인 경우, 아래와 같은 창을 띄울 수 있다.