비밀번호 초기화 페이지

This commit is contained in:
현성필
2024-05-27 11:15:45 +09:00
parent 9b53b9272c
commit c79b4c5535
13 changed files with 772 additions and 153 deletions
@@ -0,0 +1,108 @@
package com.eactive.testmaster.login.controller;
import com.eactive.testmaster.common.exception.UserNotFoundException;
import com.eactive.testmaster.login.dto.PasswordResetDTO;
import com.eactive.testmaster.login.entity.Token;
import com.eactive.testmaster.login.service.TokenService;
import com.eactive.testmaster.user.service.UserService;
import java.time.LocalDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Validator;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* Created by Sungpil Hyun
*/
@Controller
@ConditionalOnProperty(name = "password.reset.enabled", havingValue = "true")
public class PasswordResetController {
public static final String RESET_PASSWORD = "page/resetPassword";
public static final String FORGOT_PASSWORD = "page/forgotPassword";
@Autowired
private UserService userService;
@Autowired
private TokenService tokenService;
@Autowired
private Validator validator;
@GetMapping(value = "/forgot_password.do")
public String forgotPasswordView() {
return FORGOT_PASSWORD;
}
/**
* 비밀번호 초기화 링크를 생성한다.
*
* @param email
* @param model
* @return
*/
@PostMapping(value = "/forgot_password.do")
public String requestPasswordReset(@RequestParam(value = "email", defaultValue = "0") String email,
Model model) {
try {
userService.requestPasswordReset(email);
} catch (UserNotFoundException e) {
model.addAttribute("error", "존재하지 않는 이메일입니다.");
return FORGOT_PASSWORD;
} catch (Exception e) {
model.addAttribute("error", "비밀번호 초기화 요청에 실패하였습니다.");
return FORGOT_PASSWORD;
}
return "redirect:/forgot_password.do?success";
}
@GetMapping("/reset_password.do")
public String resetPasswordView(@RequestParam(value = "token", defaultValue = "0") String tokenValue,
Model model) {
Token token = tokenService.findByToken(tokenValue);
if (token == null || token.getExpiresAt().isBefore(LocalDateTime.now())) {
return "redirect:/forgot_password.do?expired";
}
model.addAttribute("tokenValue", tokenValue);
model.addAttribute("passwordReset", new PasswordResetDTO());
return RESET_PASSWORD;
}
@PostMapping("/reset_password.do")
@Transactional
public String resetPassword(@ModelAttribute("passwordResetRequest") PasswordResetDTO passwordReset,
BindingResult bindingResult,
Model model) {
Token token = tokenService.findByToken(passwordReset.getToken());
if (token == null || token.getExpiresAt().isBefore(LocalDateTime.now())) {
return "redirect:/forgot_password.do?expired";
}
validator.validate(passwordReset, bindingResult);
if (bindingResult.hasErrors()) {
model.addAttribute("tokenValue", passwordReset.getToken());
model.addAttribute("passwordReset", passwordReset);
return RESET_PASSWORD;
}
userService.resetPassword(token.getEmail(), passwordReset.getPassword());
tokenService.deleteToken(token);
return "redirect:/login";
}
}
@@ -0,0 +1,36 @@
package com.eactive.testmaster.login.dto;
import com.eactive.testmaster.common.validator.PasswordMatch;
import java.io.Serializable;
@PasswordMatch(input = "password", confirm = "password2")
public class PasswordResetDTO implements Serializable {
private String token;
private String password;
private String password2;
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getPassword2() {
return password2;
}
public void setPassword2(String password2) {
this.password2 = password2;
}
}
@@ -0,0 +1,54 @@
package com.eactive.testmaster.login.entity;
import java.io.Serializable;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name = "PT_TOKEN")
public class Token implements Serializable {
public Token(String tokenValue, String email, LocalDateTime expiresAt) {
this.tokenValue = tokenValue;
this.email = email;
this.expiresAt = expiresAt;
}
public Token() {
}
@Id
@Column(name = "TOKEN")
private String tokenValue;
private String email;
private LocalDateTime expiresAt;
public String getTokenValue() {
return tokenValue;
}
public void setTokenValue(String tokenValue) {
this.tokenValue = tokenValue;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
}
@@ -0,0 +1,7 @@
package com.eactive.testmaster.login.repository;
import com.eactive.testmaster.login.entity.Token;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TokenRepository extends JpaRepository<Token, String> {
}
@@ -0,0 +1,32 @@
package com.eactive.testmaster.login.service;
import com.eactive.testmaster.login.entity.Token;
import com.eactive.testmaster.login.repository.TokenRepository;
import java.time.LocalDateTime;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class TokenService {
@Autowired
private TokenRepository tokenRepository;
public Token createToken(String email) {
String token = UUID.randomUUID().toString();
LocalDateTime expiresAt = LocalDateTime.now().plusHours(24);
Token tokenObj = new Token(token, email, expiresAt);
return tokenRepository.save(tokenObj);
}
public Token findByToken(String token) {
return tokenRepository.findById(token).orElse(null);
}
public void deleteToken(Token token) {
tokenRepository.delete(token);
}
}
@@ -1,6 +1,7 @@
package com.eactive.testmaster.user.controller;
import com.eactive.testmaster.common.util.SecurityUtil;
import com.eactive.testmaster.user.dto.StaffPasswordChangeRequestDTO;
import com.eactive.testmaster.user.dto.UserRegisterDTO;
import com.eactive.testmaster.user.dto.UserSearch;
import com.eactive.testmaster.user.dto.UserUpdateDTO;
@@ -25,8 +26,9 @@ import org.springframework.web.bind.annotation.RequestParam;
@RequestMapping("/mgmt/users")
public class UserMgmtController {
public static final String STAFF_USER_EDIT = "page/users/staffUserEdit";
public static final String USERS_LIST_VIEW = "redirect:/mgmt/users/list_view.do";
private static final String STAFF_USER_EDIT = "page/users/staffUserEdit";
private static final String USERS_LIST_VIEW = "redirect:/mgmt/users/list_view.do";
private static final String STAFF_PASSWORD_RESET_VIEW = "page/users/staffPasswordReset";
private final StaffUserService staffUserService;
@@ -129,4 +131,23 @@ public class UserMgmtController {
return USERS_LIST_VIEW;
}
@PostMapping("/staff_password_reset_view.do")
public String staffPasswordReset(@ModelAttribute("passwordChangeRequest") StaffPasswordChangeRequestDTO passwordChangeRequestDTO) {
StaffUser found = staffUserService.findById(passwordChangeRequestDTO.getId());
passwordChangeRequestDTO.setUsername(found.getUsername());
return STAFF_PASSWORD_RESET_VIEW;
}
@PostMapping("/update_staff_password.do")
public String updateStaffPassword(@ModelAttribute("passwordChangeRequest") StaffPasswordChangeRequestDTO passwordChangeRequestDTO,
BindingResult bindingResult) {
validator.validate(passwordChangeRequestDTO, bindingResult);
if (bindingResult.hasErrors()) {
return STAFF_PASSWORD_RESET_VIEW;
}
staffUserService.updatePassword(passwordChangeRequestDTO.getId(), passwordChangeRequestDTO.getNewPassword());
return USERS_LIST_VIEW;
}
}
@@ -0,0 +1,51 @@
package com.eactive.testmaster.user.dto;
import com.eactive.testmaster.common.validator.PasswordMatch;
import javax.validation.constraints.NotEmpty;
@PasswordMatch(input = "newPassword", confirm = "newPassword2")
public class StaffPasswordChangeRequestDTO {
@NotEmpty(message = "{field.required}")
private String id;
private String username;
@NotEmpty(message = "{field.required}")
private String newPassword;
@NotEmpty(message = "{field.required}")
private String newPassword2;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getNewPassword() {
return newPassword;
}
public void setNewPassword(String newPassword) {
this.newPassword = newPassword;
}
public String getNewPassword2() {
return newPassword2;
}
public void setNewPassword2(String newPassword2) {
this.newPassword2 = newPassword2;
}
}
@@ -62,7 +62,15 @@ public class StaffUserService {
staffUserRepository.save(user);
}
public void updatePassword(String id, String newPassword) {
StaffUser user = staffUserRepository.findById(id).orElseThrow(() -> new UserNotFoundException(USER_NOT_FOUND_MESSAGE));
user.setUserSecurity(newPassword);
user.setLockAt(EnabledStatus.N);
user.setLockCnt(0);
user.setLastLockDate(null);
staffUserRepository.save(user);
}
}
@@ -3,10 +3,17 @@ package com.eactive.testmaster.user.service;
import com.eactive.testmaster.common.entity.EnabledStatus;
import com.eactive.testmaster.common.exception.UserNotFoundException;
import com.eactive.testmaster.common.util.EncryptionUtil;
import com.eactive.testmaster.login.entity.Token;
import com.eactive.testmaster.login.service.TokenService;
import com.eactive.testmaster.user.entity.StaffUser;
import com.eactive.testmaster.user.entity.User;
import com.eactive.testmaster.user.repository.StaffUserRepository;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -17,14 +24,23 @@ import org.springframework.stereotype.Service;
//@Transactional(propagation = Propagation.NESTED)
public class UserService implements UserDetailsService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
public static final String USER_NOT_FOUND_MESSAGE = "사용자 정보가 존재하지 않습니다.";
@Autowired
StaffUserRepository staffUserRepository;
private final StaffUserRepository staffUserRepository;
@Autowired
private EncryptionUtil encryptionUtil;
private final TokenService tokenService;
public UserService(StaffUserRepository staffUserRepository, EncryptionUtil encryptionUtil, TokenService tokenService) {
this.staffUserRepository = staffUserRepository;
this.encryptionUtil = encryptionUtil;
this.tokenService = tokenService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
@@ -65,6 +81,25 @@ public class UserService implements UserDetailsService {
staffUserRepository.save((StaffUser) user);
}
public void requestPasswordReset(String email) {
Token token = tokenService.createToken(email);
Map<String, Object> params = new HashMap<>();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedDateTime = token.getExpiresAt().format(formatter);
params.put("userId", email);
params.put("token", token.getTokenValue());
params.put("expiresAt", formattedDateTime);
logger.info("requestPasswordReset::params= {}", params);
}
public void resetPassword(String email, String newPassword) {
User user = findByUserId(email);
updatePassword(user, newPassword);
}
public void updateUser(User user) {
staffUserRepository.save((StaffUser) user);
}
@@ -0,0 +1,86 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/base_layout}">
<body>
<section layout:fragment="contentFragment">
<div class="container">
<div class="mt-2">
<h3 class="text-center">비밀번호 초기화</h3>
</div>
<div class="container-md container-sm d-flex flex-column flex-wrap align-items-center justify-content-center">
<div class="card col-lg-4 col-md-8">
<div class="card-body">
<form name="passwordResetRequest" id="passwordResetRequest" role="form" th:action="@{/forgot_password.do}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="form-floating">
<input id="email" type="text" class="form-control mb-3" name="email" maxlength="30" required/>
<label class="form-label" for="email">User ID</label>
</div>
</form>
<div th:if="${param.expired != null}">
<p>비밀번호 초기화 링크가 만료되었습니다.</p>
</div>
<div th:if="${#strings.isEmpty(error) == false}">
<p th:text="${error}"></p>
</div>
</div>
<div class="card-footer">
<div class="d-flex flex-wrap align-items-center justify-content-center">
<button type="button" class="btn btn-primary btn_reset">초기화 요청</button>
</div>
</div>
</div>
<form th:if="${param.success != null}" class="col-lg-4 col-md-8" name="resetPassword" role="form" th:action="@{/reset_password.do}" method="get">
<div class="card mt-2 ">
<div class="card-body">
<div class="form-floating">
<input id="resetToken" type="text" class="form-control mb-3" name="token" placeholder="Reset Token" required/>
<label class="form-label">Token</label>
</div>
</div>
<div class="card-footer">
<div class="d-flex flex-wrap align-items-center justify-content-center">
<button type="submit" class="btn btn-primary">초기화 페이지 열기</button>
</div>
</div>
</div>
</form>
</div>
<div class="mt-2 text-center">
<span class="text-muted"><a th:href="@{/login}">로그인</a></span>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script>
$(function() {
document.getElementById('passwordResetRequest').email.focus();
let btnReset = document.querySelector('.btn_reset');
btnReset.addEventListener('click', function(event) {
let form = document.getElementById('passwordResetRequest');
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
} else {
form.submit();
}
form.classList.add('was-validated');
});
});
</script>
</th:block>
</html>
@@ -0,0 +1,80 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/base_layout}">
<body>
<section layout:fragment="contentFragment">
<div class="container">
<div class="mt-2">
<h3 class="text-center">비밀번호 초기화</h3>
</div>
<div class="container-md container-sm d-flex flex-wrap align-items-center justify-content-center">
<div class="card col-lg-4 col-md-8">
<div class="card-body">
<form name="passwordResetRequest" id="passwordResetRequest" role="form" th:action="@{/reset_password.do}" method="post" th:object="${passwordReset}">
<input type="hidden" name="token" th:value="${tokenValue}">
<div class="form-floating">
<input class="form-control mb-3" th:classappend="${#fields.hasErrors('password')}? 'is-invalid'" name="password" id="password" th:field="*{password}" type="password"
size="20" value="" maxlength="100" required>
<label for="password" class="must" th:text="#{comUssUmt.userManagePasswordUpdt.pass}"></label>
<th:block th:each="err : ${#fields.errors('password')}">
<div th:text="${err}" class="invalid-feedback"></div>
</th:block>
</div>
<!-- 비밀번호확인 -->
<div class="form-floating">
<input class="form-control mb-3" th:classappend="${#fields.hasErrors('password2')}? 'is-invalid'" name="password2" id="password2" th:field="*{password2}"
type="password" size="20" value="" maxlength="100" required>
<label for="password2" class="must" th:text="#{comUssUmt.userManagePasswordUpdt.passConfirm}"></label>
<th:block th:each="err : ${#fields.errors('password2')}">
<div th:text="${err}" class="invalid-feedback"></div>
</th:block>
</div>
</form>
<div th:if="${param.success != null}">
<p>비밀번호 초기화 되었습니다.</p>
</div>
</div>
<div class="card-footer">
<div class="d-flex flex-wrap align-items-center justify-content-center">
<button type="button" class="btn btn-primary btn_reset">저장</button>
</div>
</div>
</div>
</div>
<div class="mt-2 text-center">
<span class="text-muted"><a th:href="@{/login}">로그인</a></span>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script>
$(function() {
document.getElementById('passwordResetRequest').password.focus();
let btnReset = document.querySelector('.btn_reset');
btnReset.addEventListener('click', function(event) {
let form = document.getElementById('passwordResetRequest');
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
} else {
form.submit();
}
form.classList.add('was-validated');
});
});
</script>
</th:block>
</html>
@@ -0,0 +1,90 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/default_layout}">
<body>
<section layout:fragment="contentFragment">
<div class="container-fluid">
<div class="card mt-2">
<div class="card-header">
<h3>사용자 비밀 번호 변경</h3>
</div>
<div class="card-body">
<form name="passwordChangeRequest" id="passwordChangeRequest" class="form-horizontal form-horizontal_l" method="post" th:action="@{/mgmt/users/update_staff_password.do}"
th:object="${passwordChangeRequest}">
<!-- 사용자 이름 -->
<input type="hidden" th:field="*{id}">
<div class="form-floating">
<input class="form-control mb-3" name="username" id="username" th:field="*{username}" type="text" size="20" value="" maxlength="100" readonly>
<label for="username" class="must">이름</label>
</div>
<!-- 비밀번호 -->
<div class="form-floating">
<input class="form-control mb-3" th:classappend="${#fields.hasErrors('newPassword')}? 'is-invalid'" name="newPassword" id="newPassword" th:field="*{newPassword}" type="password"
size="20" value="" maxlength="100" required>
<label for="newPassword" class="must" th:text="#{comUssUmt.userManagePasswordUpdt.pass}"></label>
<th:block th:each="err : ${#fields.errors('newPassword')}">
<div th:text="${err}" class="invalid-feedback"></div>
</th:block>
</div>
<!-- 비밀번호확인 -->
<div class="form-floating">
<input class="form-control mb-3" th:classappend="${#fields.hasErrors('newPassword2')}? 'is-invalid'" name="newPassword2" id="newPassword2" th:field="*{newPassword2}"
type="password" size="20" value="" maxlength="100" required>
<label for="newPassword2" class="must" th:text="#{comUssUmt.userManagePasswordUpdt.passConfirm}"></label>
<th:block th:each="err : ${#fields.errors('newPassword2')}">
<div th:text="${err}" class="invalid-feedback"></div>
</th:block>
</div>
</form>
</div>
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="position: absolute; top: 0; right: 0;">
<div class="toast-header">
<strong class="mr-auto">알림</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
</div>
</div>
<div class="card-footer">
<button type="button" class="btn btn-primary btn_update"><i class="fa-sharp fa-solid fa-floppy-disk"></i> 저장</button>
<a th:href="@{/mgmt/users/list_view.do}" class="btn btn-primary" th:title="#{button.cancel}"><i class="fa-sharp fa-solid fa-cancel"></i> [[#{button.cancel}]]</a>
</div>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script>
$(function () {
document.getElementById("passwordChangeRequest").newPassword.focus();
let btnUpdate = document.querySelector(".btn_update");
btnUpdate.addEventListener("click", function (event) {
let form = document.getElementById("passwordChangeRequest");
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
} else {
form.submit();
}
form.classList.add('was-validated');
});
})
</script>
<script th:if="${resultMsg != null}">
$(function () {
var resultMsg = '[[#{${resultMsg}}]]';
if (resultMsg !== "") {
$('.toast-body').text(resultMsg);
$('.toast').toast('show');
}
})
</script>
</th:block>
</html>
@@ -9,7 +9,7 @@
<div class="container-fluid">
<div class="card mt-2">
<div class="card-header">
<h3>사용자 목록</h1>
<h3>사용자 목록</h3>
</div>
<div class="card-body">
<div class="d-flex justify-content-between">
@@ -90,6 +90,9 @@
<button type="button" class="btn btn-primary btn-sm me-2" th:onclick="fnSelectItem([[${row.esntlId}]])" th:value="#{button.update}"><i class="fa-sharp fa-solid fa-edit"></i>
[[#{button.update}]]
</button>
<button type="submit" class="btn btn-primary btn-sm me-2" th:onclick="fnUpdatePassword([[${row.esntlId}]])" ><i class="fa-sharp fa-solid fa-edit"></i>
비밀번호 변경
</button>
</div>
</td>
</tr>
@@ -119,9 +122,17 @@
document.searchForm.submit();
}
function fnUpdatePassword(id) {
document.listForm.method = 'POST';
document.listForm.action = '[[@{/mgmt/users/staff_password_reset_view.do}]]';
document.listForm.id.value = id;
document.listForm.submit();
}
function fnSelectItem(id) {
location.href = "[[@{/mgmt/users/update_view.do}]]" + "?userId=" + id;
}
$(function () {
let fnSearch = document.querySelector(".btn_search");
let fnDelete = document.querySelector(".btn_delete");