사용자가 자신의 닉네임, 비밀번호를 바꾸거나 댓글필터를 적용할 감정을 바꿔야 할 수 있어야 하기에 해당 부분을 구현해본다.
댓글감정필터란 ?
짧게 요약하면 긍정,중립,부정의 댓글 중 자신이 보고싶은 범위의 댓글만 볼 수 있게 해주는 서비스
https://anythingis.tistory.com/89
User Entity
@Entity
@Getter
@NoArgsConstructor
public class User extends TimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private String email;
@Column(nullable = false, length = 100)
private String password;
@Enumerated(EnumType.STRING)
private Roles role;
@Enumerated(EnumType.STRING)
private Emotion emotion;
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
@OneToMany(mappedBy = "user")
private List<Comment> comments = new ArrayList<>();
@Builder
public User(String username, String nickname, String email, String password, Roles role, Emotion emotion) {
this.username = username;
this.nickname = nickname;
this.email = email;
this.password = password;
this.role = role;
this.emotion = emotion;
}
public void edit(String nickname, String password){ // 사용자 정보 수정
this.nickname = nickname;
this.password = password;
}
public void edit(Emotion emotion){ // 사용자 감정 수정
this.emotion = emotion;
}
public void addComment(Comment comment) {
comment.setUser(this);
comments.add(comment);
}
}
메소드 오버로딩을 통해 사용자 정보를 수정할 때는 닉네임과 비밀번호를 받고, 감정을 수정할 땐 열거형 클래스를 받아 필드를 바꿔준다.
UserEditDto, EmotionEditDto
@Data
public class UserEdit {
@NotBlank(message = "아이디는 필수 입력값입니다.")
private String username;
@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$", message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.")
private String nickname;
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
private String password;
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
private String email;
@Builder
public UserEdit(String username, String nickname, String email) {
this.username = username;
this.nickname = nickname;
this.email = email;
}
}
@Data
public class EmotionEdit {
private Emotion emotion;
@NotBlank(message = "비밀번호 필수 입력값입니다.")
private String password;
private String username;
}
사용자 정보 수정 시엔 회원가입 때와 같이 유효성 검사를 해주고 해당 비밀번호 필드로 업데이트 시켜준다.
사용자 감정 수정 시엔 현재 비밀번호와 일치여부 확인 후에 해당 감정 필드만 업데이트 시켜준다.
감정 수정 시에 비밀번호를 입력받지 않게하려고도 생각해봤었는데 그러면 이후에 실행되는 세션 재등록 부분에서 PasswordEncoding 되지 않은 비밀번호를 입력받지 않으면 세션을 재등록 할 수 가 없어 감정에 접근하는 모든 부분을 DB에 접근해서 찾아와야해서 그냥 패스워드도 입력 받기로 했다. 세션 재등록은 Controller에서 ...
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void edit(UserEdit userEdit) { // 사용자 정보 변경
User user = userRepository.findByUsername(userEdit.getUsername()).orElseThrow(UserNotFound::new);
user.edit(userEdit.getNickname(), passwordEncoder.encode(userEdit.getPassword()));
}
@Transactional
public void emotionEdit(EmotionEdit emotionEdit){ // 사용자 감정 변경
User user = userRepository.findByUsername(emotionEdit.getUsername()).orElseThrow(UserNotFound::new);
user.edit(emotionEdit.getEmotion());
}
private boolean checkDuplicateNickname(UserCheck user){
return userRepository.existsByNickname(user.getNickname());
}
public void checkDuplicateEdit(UserCheck user, BindingResult result){
if(checkDuplicateNickname(user)){
result.addError(new FieldError(user.getObjectName(), "nickname", "이미 존재하는 별명입니다"));
}
}
}
JPA를 사용할 때 Update는 변경감지 기능을 이용한다.
1. findByUsername : JPA Repository에서 해당 객체를 찾아와 영속성 컨텍스트에 영속시켜준다.
2. user.edit : 객체의 데이터 값이 변화하면 영속성 컨텍스트의 쓰기 지연 SQL 저장소에 SQL이 쌓이다가 @Transactional을 통해 메소드가 끝날 때 트랜잭션이 끝나면 DB로 SQL을 날려서 값을 바꿔준다.
사용자 정보 변경 시엔 비밀번호를 수정하고, 감정 변경 시엔 비밀번호를 수정하지 않는다.
위의 변경 사항이 DB에 저장된다.
Controller
@RequiredArgsConstructor
@Controller
@RequestMapping("/account")
public class AccountController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
@GetMapping("/edit") // 사용자 정보 변경
public String emotionForm(Model model, @AuthenticationPrincipal CustomUserDetails userDetails) {
existsSession(model, userDetails);
model.addAttribute("userEdit", UserEdit.builder().username(userDetails.getUsername())
.email(userDetails.getEmail())
.nickname(userDetails.getNickname()).build());
return "account/userEdit";
}
@PostMapping("/edit")
public String userEdit(@Valid UserEdit userEdit, BindingResult result, Model model) {
userService.checkDuplicateEdit(new UserCheck(userEdit), result);
if(result.hasErrors()){
model.addAttribute("userEdit", userEdit);
return "account/userEdit";
}
userService.edit(userEdit);
updateAuthToken(userEdit.getUsername(), userEdit.getPassword());
return "redirect:/posts";
}
@GetMapping("/emotion") // 사용자 정보 변경
public String emotionFrom(@AuthenticationPrincipal CustomUserDetails userDetails, Model model){
existsSession(model, userDetails);
return "account/emotionEdit";
}
@PostMapping("/emotion")
public String emotionEdit(EmotionEdit emotionEdit, @AuthenticationPrincipal CustomUserDetails userDetails){
userService.emotionEdit(emotionEdit);
updateAuthToken(userDetails.getUsername(), emotionEdit.getPassword());
return "redirect:/posts";
}
private void updateAuthToken(String username, String password) {
Authentication authenticate = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.getContext().setAuthentication(authenticate);
}
}
컨트롤러에서 서비스의 메소드를 이용하면 DB의 값은 바뀌지만 로그인할 때 받은 UsernamePasswordAuthenticationToken은 바뀌지 않는다.
재 로그인을 하지 않고 세션을 재등록 하기위해 Controller에서 AuthenticationManger를 주입받아 새로운UsernamePasswordToken을 만들고 시큐리티의 세션 저장소인 SecurityContextHolder에 접근해 재등록해준다.
AuthenticationManger를 주입받아 사용하려면 Security Config에서 빈 등록을 해줘야한다.
Security Config
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
@Bean
public SecurityFilterChain
securityFilterChain(HttpSecurity http) throws Exception {
// ...
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
스프링 시큐리티 최신 버전부터 WebSecurityConfigurerAdapter가 Deprecated 된 이후엔 해당 코드와 같이 빈으로 등록해준다.
UncheckedExceptionController
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
/**
* The plaintext password used to perform PasswordEncoder#matches(CharSequence,
* String)} on when the user is not found to avoid SEC-2056.
*/
private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
private PasswordEncoder passwordEncoder;
/**
* The password used to perform {@link PasswordEncoder#matches(CharSequence, String)}
* on when the user is not found to avoid SEC-2056. This is necessary, because some
* {@link PasswordEncoder} implementations will short circuit if the password is not
* in a valid format.
*/
private volatile String userNotFoundEncodedPassword;
private UserDetailsService userDetailsService;
private UserDetailsPasswordService userDetailsPasswordService;
public DaoAuthenticationProvider() {
setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { // 비밀번호 다를 때
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
사용자 감정을 수정하고 세션 재등록 할때 세션의 인코딩 비밀번호와 컨트롤러에서 넘어온 비밀번호를 인코딩 한 것과 일치하지 않으면 DaoAuthenticationProvider에서 BadCredentialsException이 발생하고 HandlerAdapter -> Dispatcher Servlet -> Security (Filter)을 거쳐 로그아웃하게 된다.
@ControllerAdvice
public class UnCheckedExceptionController {
@ExceptionHandler(EmotionException.class)
public String exception(EmotionException exception, Model model){
model.addAttribute("code", exception.getStatusCode());
model.addAttribute("message", exception.getMessage());
return "error/exception";
}
@ExceptionHandler(BadCredentialsException.class)
public String redirect(Model model){
MessageCreate message = new MessageCreate("비밀번호가 다릅니다.", "/account/emotion", RequestMethod.GET, null);
model.addAttribute("params", message);
return "fragments/messageRedirect";
}
}
AccountController 내에서 터지는 BadCredentialsException을 로그아웃 필터가 잡는게 아니라 그전에 예외 처리를 위해 UncheckedExceptionController에 ExceptionHandler를 추가해준다.
해당 핸들러는 Alert 창을 띄워주고 감정 수정 폼으로 Redirect해준다.
Thymeleaf Edit Form, Alert View
UserEdit Form
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:justify-content="http://www.w3.org/1999/xhtml">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link th:href="@{/css/bootstrap.min.css}"
href="../../static/css/bootstrap.min.css"
rel="stylesheet">
<title>Emotion Community</title>
<link th:href="@{/css/signin.css}"
href="signin.css" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.5.1.js"></script>
</head>
<style>
p{
font-size: 2px;
font-weight: bold;
color: #b02a37;
}
.fieldError{
border-color: #bd2130;
}
.dis{
background-color: #c6c7c8;
}
</style>
<body class="text-center">
<div th:replace="fragments/common :: menu">
</div>
<form class="form-signin" method="post" th:action="@{/account/edit}" th:object="${userEdit}">
<img class="mb-4" src="https://cdn-icons-png.flaticon.com/128/817/817787.png" alt="" width="72" height="72">
<h1 class="h3 mb-3 font-weight-normal">사용자 정보 수정</h1>
<input type="text" th:field="*{username}" class="form-control dis" placeholder="username" readonly>
<p th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></p>
<label th:for="email" class="sr-only">이메일</label>
<input type="email" th:field="*{email}" class="form-control dis" placeholder="email" readonly>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
<label th:for="nickname" class="sr-only">닉네임</label>
<input type="text" th:field="*{nickname}" class="form-control" placeholder="nickname"
th:class="${#fields.hasErrors('nickname')} ? 'form-control fieldError' : 'form-control'" >
<p th:if="${#fields.hasErrors('nickname')}" th:errors="*{nickname}" class="fieldError"></p>
<label th:for="password" class="sr-only">비밀번호</label>
<input type="password" th:field="*{password}" class="form-control" placeholder="Password"
th:class="${#fields.hasErrors('password')} ? 'form-control fieldError' : 'form-control'" required>
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
<button class="btn btn-lg btn-primary btn-block" type="submit" id="btn-modify-user" >Edit</button>
<p class="mt-5 mb-3 text-muted">© 2017-2022</p>
</form>
</body>
</html>
EmotionEdit Form
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:justify-content="http://www.w3.org/1999/xhtml">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link th:href="@{/css/bootstrap.min.css}"
href="../../static/css/bootstrap.min.css"
rel="stylesheet">
<title>Emotion Community</title>
<link th:href="@{/css/signin.css}"
href="signin.css" rel="stylesheet">
</head>
<body class="text-center">
<div th:replace="fragments/common :: menu">
</div>
<form class="form-signin" method="post" th:action="@{/account/emotion}">
<img class="mb-4" src="https://cdn-icons-png.flaticon.com/128/817/817787.png" alt="" width="72" height="72">
<h1 class="h3 mb-3 font-weight-normal">Emotion Filter Change</h1>
<div th:if="${param.error}" class="alert alert-danger" role="alert">
Invalid username and password.
</div>
<select name="emotion" id="emotion">
<option th:each="emotion : ${T(com.jackcomunity.emotionCommunity.util.Emotion).values()}"
th:value="${emotion}" th:text="${emotion}"></option>
</select>
<input type="password" id="inputPassword" name="password" class="form-control" placeholder="Password" required>
<input type="hidden" name="username" th:value="${#authentication.name}"></input>
<!-- <div class="checkbox mb-3">-->
<!-- <label>-->
<!-- <input type="checkbox" value="remember-me"> Remember me-->
<!-- </label>-->
<!-- </div>-->
<button class="btn btn-lg btn-primary btn-block" type="submit" id="btn-modify-user" >변경</button>
<p class="mt-5 mb-3 text-muted">© 2017-2022</p>
</form>
</body>
</html>
Thyemleaf에서 Enum 클래스를 select + option을 이용해 Form내에 드롭박스 형식으로 넣어줄 수 있다.
Alert View
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<link th:href="@{/css/bootstrap.min.css}"
href="../../static/css/bootstrap.min.css"
rel="stylesheet">
<title>Emotion Community</title>
<link th:href="@{/css/sticky-footer-navbar.css}"
href="../../static/css/sticky-footer-navbar.css" rel="stylesheet">
<body>
<div th:replace="fragments/common :: menu"></div>
<form th:if="${not #maps.isEmpty( params.data )}" id="redirectForm" th:action="${params.redirectUri}" th:method="${params.method}" style="display: none;">
<input th:each="key, status : ${params.data.keySet()}" type="hidden" th:name="${key}" th:value="${params.data.get(key)}" />
</form>
<th:block layout:fragment="script">
<script th:inline="javascript">
/* <![CDATA[ */
window.onload = () => {
const message = [[ ${params.message} ]]; // message view
if (message) {
alert(message);
}
const form = document.getElementById('redirectForm');
if (form) {
form.submit();
return false;
}
const redirectUri = [[ ${params.redirectUri} ]];
location.href = redirectUri; // redirect
}
/* ]]> */
</script>
</th:block>
</body>
</html>
결과
사용자 정보 수정
사용자 정보 수정 DB (닉네임, 비밀번호)
사용자 감정수정
사용자 감정 수정 DB
참고
https://congsong.tistory.com/22
https://dev-coco.tistory.com/127