본문 바로가기
공부 및 정리/점프 투 스프링부트

점프 투 스프링부트 추가 기능 구현 - 비밀번호 변경 및 찾기

by 스파이펭귄 2024. 1. 19.
728x90

https://wikidocs.net/162814의 추가 기능 구현 - 비밀번호 변경 및 찾기

 

3-14 SBB 추가 기능 구현하기

이 책에서 구현할 SBB의 기능은 아쉽지만 여기까지이다. 함께 더 많은 기능을 추가하고 싶지만 이 책은 SBB의 완성이 아니라 SBB를 성장시키는 경험을 전달하는 데 목표를 두고…

wikidocs.net

이 글은 점프 투 스프링 부트의 3-13까지 구현과 이 글 이전의 구현을 마친 것을 전제로 한다.


이번엔 비밀번호 변경 및 찾기를 구현해보자. 이전에 프로필 페이지를 구현했으니 이 페이지에서 비밀번호를 변경하고, 회원가입 페이지에서 비밀번호 찾기를 추가해주면 될 것 같다.

비밀번호 변경 만들기

프로필 페이지에 비밀번호를 변경할 수 있도록 폼을 만들어주자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <h2 class="border-bottom py-2" >내 정보</h2>
    <div class="card my-3">
        <div class="card-body">
            <form th:action="@{/user/profile}" th:object="${userUpdateForm}" method="post">
                <div th:replace="~{form_errors :: formErrorsFragment}"></div>
                <div class="mb-3">
                    <label for="username" class="form-label">사용자 ID</label>
                    <input type="text" th:value="${user_name}" class="form-control" readonly>
                </div>
                <div class="mb-3">
                    <label for="email" class="form-label">이메일</label>
                    <input type="email" th:value="${user_email}" class="form-control" readonly>
                </div>
                <div class="mb-3">
                    <label for="origin_password" class="form-label">기존 비밀번호</label>
                    <input type="password" class="form-control">
                </div>
                <div class="mb-3">
                    <label for="password_1" class="form-label">새 비밀번호</label>
                    <input type="password" class="form-control">
                </div>
                <div class="mb-3">
                    <label for="password_2" class="form-label">새 비밀번호 확인</label>
                    <input type="password" class="form-control">
                </div>
                <button type="submit" class="btn btn-primary">정보 저장</button>
            </form>
        </div>
    </div>

    <h2 class="border-bottom py-2" >내 질문</h2>
        (...생략...)

현재는 form으로 userUpdateForm을 넘겨주기 때문에 profile.html에 들어가면 오류가 발생한다. user 패키지에 UserUpdateForm 클래스를 생성해 주고 이를 controller의 매핑 함수에 추가해주어야 오류가 발생하지 않는다.

package com.example.main.user;

import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserUpdateForm {
    @NotEmpty(message = "기존 비밀번호는 필수항목입니다.")
    private String originPassword;

    @NotEmpty(message = "새 비밀번호는 필수항목입니다.")
    private String password1;

    @NotEmpty(message = "새 비밀번호 확인은 필수항목입니다.")
    private String password2;
}

필요한 정보는 기존 비밀번호, 새 비밀번호, 새 비밀번호 확인이다. 이제 UserController의 profile 함수에 UserUpdateForm을 파라미터로 추가해줘야한다.

@Controller
@RequestMapping("/user")
public class UserController {
        (...생략...)
        @PreAuthorize("isAuthenticated()")
    @GetMapping("/profile")
    public String profile(UserUpdateForm userUpdateForm, Model model, Principal principal,
                          @RequestParam(value="question-page", defaultValue="0") int questionPage,
                          @RequestParam(value="ans-page", defaultValue="0") int ansPage,
                          @RequestParam(value="question-vote-page", defaultValue="0") int questionVoterPage,
                          @RequestParam(value="ans-vote-page", defaultValue="0") int ansVoterPage) {

        (...생략...)

잘 구성된것을 확인할 수 있다.

이제 비밀번호 변경 기능을 추가해보자.

 

비밀번호 변경 기능 추가

다음과 같이 UserController에 update 함수를 만들어준다.

@Controller
@RequestMapping("/user")
public class UserController {
        (...생략...)

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/profile")
    public String update(@Valid UserUpdateForm userUpdateForm, BindingResult bindingResult,
                         Model model, Principal principal) {
        SiteUser siteUser = this.userService.getUser(principal.getName());
        Page<Question> wroteQuestions = this.questionService.getListByAuthor(0, siteUser);
        Page<Answer> wroteAnswers = this.answerService.getListByAuthor(0, siteUser);
        Page<Question> votedQuestions = this.questionService.getListByVoter(0, siteUser);
        Page<Answer> votedAnswers = this.answerService.getListByVoter(0, siteUser);

        model.addAttribute("wrote_question_paging", wroteQuestions);
        model.addAttribute("wrote_answer_paging", wroteAnswers);
        model.addAttribute("voted_question_paging", votedQuestions);
        model.addAttribute("voted_answer_paging", votedAnswers);
        model.addAttribute("username", siteUser.getUsername());
        model.addAttribute("userEmail", siteUser.getEmail());
        if (bindingResult.hasErrors()) {
            return "profile";
        }

        if(!this.userService.isMatch(userUpdateForm.getOriginPassword(), siteUser.getPassword())) {
            bindingResult.rejectValue("originPassword", "passwordInCorrect",
                    "기존 패스워드가 일치하지 않습니다.");
            return "profile";
        }
        if(!userUpdateForm.getPassword1().equals(userUpdateForm.getPassword2())) {
            bindingResult.rejectValue("password2", "passwordInCorrect",
                    "확인 패스워드가 일치하지 않습니다.");
            return "profile";
        }

        try {
            userService.update(siteUser, userUpdateForm.getPassword1());
        } catch(Exception e) {
            e.printStackTrace();
            bindingResult.reject("updateFailed", e.getMessage());
        }
        return "profile";
    }
}

위 코드를 살펴보면, 비밀번호 변경시 /user/profile에 post로 접근하기에 postMapping을 사용하였고, 오류가 발생하든 성공하든 profile로 보내고 싶기 때문에 기존 profile 함수에서 하던 일들을 동일하게 처음에 해주었다.

파라미터로 userUpdateForm과 bindingResult를 추가해 form으로부터 오는 정보를 받을 수 있게 하였고, 기존 패스워드와의 동일함을 확인하기 위해 UserService에 isMatch 함수를 추가하였다. (조금 있다가 구현 해보자.)

그 후 새로운 패스워드와 확인용 패스워드의 일치를 확인하고 UserService에 update라는 함수를 추가해 변경사항을 업데이트 후 다시 profile로 보내는 함수이다.

이젠 UserService에 추가한 함수를 구현해보자.

@Service
public class UserService {
        (...생략...)

        public SiteUser update(SiteUser user, String newPassword) {
        user.setPassword(this.passwordEncoder.encode(newPassword));
        this.userRepository.save(user);
        return user;
    }

    public boolean isMatch(String rawPassword, String encodedPassword) {
        return this.passwordEncoder.matches(rawPassword, encodedPassword);
    }
}

먼저 update부터 살펴보자. update 함수는 새로운 패스워드를 BCryptPasswordEncoder로 encode하여 유저 객체의 password에 set 후 save하여 업데이트하는 함수로 구현하였다.

isMatch 함수는 BCryptPasswordEncoder의 matches라는 함수를 이용했다. BCryptPasswordEncoder는 salt라는 무작위 문자열을 추가하는 기능 때문에 같은 문자열이라도 매번 다르게 encoding이 되기 때문에 이를 그냥 문자열로 확인하기는 어렵다. 그러므로 matches(rawString, encodedString)을 사용해 두 문자열이 같은 문자열인지 확인하는 함수를 사용해 기존 password와의 동일함을 검증하였다.

기존 패스워드가 틀린 경우
확인 패스워드가 틀린 경우

서버를 재 시작 후 비밀번호를 변경해보면 기존 패스워드가 틀린 경우, 확인 패스워드가 틀린 경우 모두 잘 오류 메시지가 뜨는 것을 확인할 수 있다.

 

 

비밀번호 찾기 기능

이번엔 비밀번호 찾기 기능을 추가해보자. 비밀번호는 보통 가입시 사용한 메일을 사용해 찾게 된다. 즉, 우리 서버에서 가입자의 이메일로 메일을 보내주어야 한다.

스프링 부트에서 이메일을 보내기 위해서 준비사항이 있다. (블로그를 참조하였음.)

 

스프링부트+jsp로 배달사이트 만들기-38 이메일보내기(아이디 찾기,비번찾기)

이메일을 보낼 준비를 합니다 구글 계정관리 클릭 보안 > 2단계인증 후, 앱 비밀번호 생성 pom.xml에 라이브러리를 추가합니다 org.springframework.boot spring-boot-starter-mail application.properties에 설정을 추

sumin2.tistory.com

build.gradle에 아래 dependency를 추가해주자.

implementation 'org.springframework.boot:spring-boot-starter-mail'

그 후 application.properties에 다음 내용을 추가하자.

# Email
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=지메일 주소
spring.mail.password=앱 비밀번호
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.auth=true

💡 만약 github 공개 레포지토리 같은 곳에 이 프로젝트를 올릴 경우 위 정보가 누출되지 않도록 주의해주자.

그 후 지메일 계정을 변경해야한다.

크롬 새 탭 우상단의 그림을 클릭 후 Google 계정 관리를 누른다.

검색 창에 “앱 비밀번호”를 검색 후 앱 비밀번호를 만들어준 후 위의 application.properties에 넣어준다. (따옴표 필요 X)

준비는 끝났다! 이제 

비밀번호 찾기 링크 생성

(...생략...)
                <button type="submit" class="btn btn-primary">로그인</button>
        <a href="/user/find-account">비밀번호가 기억나지 않아요.</a>
    </form>
</div>
</html>

간단히 링크 형식으로 만들었다. 이제 스프링에서 내 gmail 계정을 통해 다른이들에게 메일을 보낼 수 있게 되었다.

 

이메일로 계정 찾는 함수 생성 및 비밀번호 찾기 페이지 매핑하기

주어지는 정보는 이메일 뿐이므로 UserRepository와 UserService에 이메일로 계정을 찾을 수 있는 함수를 만들어주자.

public interface UserRepository extends JpaRepository<SiteUser, Long> {
    (...생략...)

    Optional<SiteUser> findByEmail(String email);
}
@Service
public class UserService {
        (...생략...)

        public SiteUser getUserByEmail(String email) {
        Optional<SiteUser> siteUser = this.userRepository.findByEmail(email);
        if (siteUser.isPresent()) {
            return siteUser.get();
        } else {
            throw new DataNotFoundException("Email not found!!");
        }
    }
}

이제 /user/find-account로 get을 통해 들어오면 작동할 controller를 만들어주자.

@Controller
@RequestMapping("/user")
public class UserController {
        (...생략...)

    @GetMapping("/find-account")
    public String findAccount(Model model) {
        model.addAttribute("sendConfirm", false);
        model.addAttribute("error", false);
        return "find_account";
    }
}

이메일을 보냈는지, 오류가 발생한 것에 대한 정보를 model에 넘긴 후 find_account 뷰로 리다이렉션한다.

 

비밀번호 찾기 HTML 코드 작성하기

find_account.html을 작성해주자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <h2 class="border-bottom py-2">비밀번호 찾기</h2>
    <div class="card my-3">
        <div class="card-body"
            th:if="${!sendConfirm}">
            <div class="text-center">
                <h4>가입시 입력한 이메일을 입력해주세요.</h4>
                <form action="/user/find-account" method="post">
                    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
                    <div th:if="${error}">
                        <div class="alert alert-danger">
                            확인된 이메일이 없습니다.
                        </div>
                    </div>
                    <input type="email" name="email" id="email" class="form-control" required/>
                    <button type="submit" class="btn btn-primary">비밀번호 찾기</button>
                </form>
            </div>
        </div>
        <div class="card-body"
             th:if="${sendConfirm}">
            <div class="text-center">
                <h4 th:text="|${userEmail}로 계정 정보 메일을 보냈습니다.|">
                </h4>
                <a href="/user/login" class="delete btn btn-sm btn-primary">로그인 페이지로 이동하기</a>
            </div>
        </div>
    </div>
</div>

위 코드를 간단히 설명해보면 sendConfirm을 통해 보냈는지에 대해서 판단하며, 보내기 전인 경우 이메일 입력 창을 띄우고, 보낸 경우 보냈다는 메시지 창을 띄운다. 결과 그림은 아래와 같다.

적절히 이메일을 보내어 sendConfirm=true인 경우 아래와 같이 보이게 된다.

그리고 오류가 발생한 경우는 서버 데이터 베이스에 해당 이메일로 가입한 계정이 없는 경우이므로 아래와 같이 오류 메시지를 띄워준다.

form 태그를 통해 이메일 정보를 전달한다.

💡 이때 스프링 시큐리티 때문에 post 요청시 csrf 토큰를 넘겨주어야 하므로 ``가 반드시 form 태그 안에 들어 있어야 오류가 나지 않는다. 아니라면 권한이 없음 403 오류가 발생한다. ([블로그 참조](https://m.blog.naver.com/hallongjj/222487009502))

input으로 넘겨준 email 정보는 RequestParam을 통해 가져오도록 할 수 있다.

이제 UserController에서 이 Post 요청을 처리해보자.

 

Post 요청 처리하기

메일을 보내야 하므로 JavaMailSender를 UserController의 멤버로 추가해야한다.

@Controller
@RequestMapping("/user")
public class UserController {
        (...생략...)

    private final JavaMailSender mailSender;
    @Autowired
    public UserController(UserService userService, QuestionService questionService,
                          AnswerService answerService, JavaMailSender mailSender) {
        this.userService = userService;
        this.questionService = questionService;
        this.answerService = answerService;
        this.mailSender = mailSender;
    }
        (...생략...)

}

(난 @RequiredArgsConstructor 어노테이션을 안써서 Autowired로 생성자를 통해 넣어주었다.)

나는 기존 비밀번호를 그대로 메일로 보내는 대신에 새로운 랜덤 임시 비밀번호로 초기화해 비밀번호를 재설정 하도록 하고싶기에 새 비밀번호 생성기를 멤버 static class로 생성하였다.

@Controller
@RequestMapping("/user")
public class UserController {
        (...생략...)

    public static class PasswordGenerator {
        private static final String CHAR_LOWER = "abcdefghijklmnopqrstuvwxyz";
        private static final String CHAR_UPPER = CHAR_LOWER.toUpperCase();
        private static final String NUMBER = "0123456789";
        private static final String OTHER_CHAR = "!@#$%&*()_+-=[]?";

        private static final String PASSWORD_ALLOW_BASE = CHAR_LOWER + CHAR_UPPER + NUMBER + OTHER_CHAR;
        private static final int PASSWORD_LENGTH = 12;

        public static String generateRandomPassword() {
            if (PASSWORD_LENGTH < 1) throw new IllegalArgumentException("Password length must be at least 1");

            StringBuilder sb = new StringBuilder(PASSWORD_LENGTH);
            Random random = new SecureRandom();
            for (int i = 0; i < PASSWORD_LENGTH; i++) {
                int rndCharAt = random.nextInt(PASSWORD_ALLOW_BASE.length());
                char rndChar = PASSWORD_ALLOW_BASE.charAt(rndCharAt);
                sb.append(rndChar);
            }

            return sb.toString();
        }
    }
}

위 클래스는 그냥 무작위 문자열을 생성하는 코드로 설명은 생략한다.

이제 이걸 이용해서 findAccount 메서드를 다시 작성해보자.

@Controller
@RequestMapping("/user")
public class UserController {
        (...생략...)

    @PostMapping("/find-account")
    public String findAccount(Model model, @RequestParam(value="email") String email) {
        try {
            SiteUser siteUser = this.userService.getUserByEmail(email);
            model.addAttribute("sendConfirm", true);
            model.addAttribute("userEmail", email);
            model.addAttribute("error", false);
            SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
            simpleMailMessage.setTo(email);
            simpleMailMessage.setSubject("계정 정보입니다.");
            StringBuffer sb = new StringBuffer();

            String newPassword = PasswordGenerator.generateRandomPassword();
            sb.append(siteUser.getUsername()).append("계정의 비밀번호를 새롭게 초기화 했습니다..\n").append("새 비밀번호는 ")
                    .append(newPassword).append("입니다.\n")
                    .append("로그인 후 내 정보에서 새로 비밀번호를 지정해주세요.");
            simpleMailMessage.setText(sb.toString());
            this.userService.update(siteUser, newPassword);
            new Thread(new Runnable() {
                @Override
                public void run() {
                    mailSender.send(simpleMailMessage);
                }
            }).start();
        } catch(DataNotFoundException e) {
            model.addAttribute("sendConfirm", false);
            model.addAttribute("error", true);
        }
        return "find_account";
    }

위 코드는 아래와 같은 순서로 작동한다.

  1. sendConfirm을 true, userEmail을 주어진 이메일, error를 false로 만든다
  2. 이메일로 보낼 메시지를 작성한다. 계정 name, 새로운 비밀번호를 넣어주었다.
  3. 계정의 비밀번호를 새 비밀번호로 업데이트한다.
  4. 메일을 보내는데 오래 걸리므로 새 쓰레드를 생성하여 보내준다.
  5. 만약 오류가 발생한다면 sendConfirm을 false로 변경하고, error를 true로 변경해 다시 이메일을 입력하게 하고 오류 메시지를 띄워준다.
  6. find_account 뷰로 리다이렉션한다.

 

메일이 잘 온것을 확인 할 수 있다.

전체 구현은 깃허브 참조.

Reference

728x90