/user/login
url
로 지정defaultSuccessUrl
루트 url
로 지정 @Bean
SecurityFilterChain filterChain (HttpSecurity http) throws Exception {
http.authorizeHttpRequests().requestMatchers("/**").permitAll().and()
.csrf()
.ignoringRequestMatchers(new AntPathRequestMatcher("/**"))
.and()
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/")
post
전송 방식은 스프링 시큐리티가 대신 처리해주므로 작성 X @GetMapping("/login")
public String login() {
return "/user/login_form";
}
<!DOCTYPE html>
<html layout:decorate="~{layout}" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div layout:fragment="content" class="container my-3">
<form th:action="@{/user/login}" method="post">
<div th:if="${param.error}">
<div class="alert alert-danger">
사용자ID 또는 비밀번호를 확인해 주세요.
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" name="username" id="username" class="form-control">
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" name="password" id="password" class="form-control">
</div>
<button type="submit" class="btn btn-primary">로그인</button>
</form>
</div>
</html>
username
을 통해 db에서 SiteUser
를 찾고 Optional<SiteUser>
객체를 반환public interface UserRepository extends JpaRepository<SiteUser, Long> {
Optional<SiteUser> findByUsername(String username);
}
ADMIN
, USER
중 적절한 권한을 부여한다.@Getter
public enum UserRole {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
UserRole(String value) {
this.value = value;
}
private String value;
}
UserSecurityService
는 스프링 시큐리티 로그인 처리의 핵심 부분이다.UserSecurityService
는 스프링 시큐리티가 제공하는 UserDetailsService
를 구현했다.username
을 통해 Optional<SiteUser>
객체를 반환받고, 만약 없다면 예외를 발생시킨다.username
을 통해 해당 유저에 대한 권한을 부여한다.User
객체를 생성하여 반환한다.User
객체의 비밀번호와 브라우저에서 입력받은 비밀번호에 대한 검사를 내부적으로 진행한다.@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<SiteUser> optionalUser = userRepository.findByUsername(username);
if (optionalUser.isEmpty()) {
throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
}
SiteUser user = optionalUser.get();
List<GrantedAuthority> authorities = new ArrayList<>();
if ("admin".equals(username)) {
authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
}
return new User(user.getUsername(), user.getPassword(), authorities);
}
}
AuthenticationManager
는 스프링 시큐리티의 인증을 담당한다.AuthenticationManager
객체를 빈으로 등록하면, UserSecurityService
가 자동으로 설정된다. @Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
SecurityFilterChain filterChain (HttpSecurity http) throws Exception {
http.authorizeHttpRequests().requestMatchers("/**").permitAll().and()
.csrf()
.ignoringRequestMatchers(new AntPathRequestMatcher("/**"))
.and()
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/")
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/")
.invalidateHttpSession(true);
return http.build();
}
게시판의 질문과 답변에 누가 작성했는지를 나타내는 author
속성을 추가
@ManyToOne
private SiteUser author;
@ManyToOne
private SiteUser author;
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable Long id, @Valid AnswerForm answerForm, BindingResult bindingResult
, Principal principal) {
Question question = questionService.getQuestion(id);
SiteUser user = userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
answerService.create(question, answerForm.getContent(), user);
return "redirect:/question/detail/" + id;
}
Principal
객체를 통해 찾은 로그인 한 username
을 통해 SiteUser
객체를 반환한다. public SiteUser getUser(String username) {
Optional<SiteUser> optionalUser = userRepository.findByUsername(username);
if (optionalUser.isPresent()) {
return optionalUser.get();
}
throw new DataNotFoundException("siteuser not found");
}
SiteUser
객체를 추가로 반환받아 answer
에 세팅한다. public void create(Question question, String content, SiteUser author) {
Answer answer = new Answer();
answer.setContent(content);
answer.setCreateDate(LocalDateTime.now());
answer.setQuestion(question);
answer.setAuthor(author);
answerRepository.save(answer);
}
질문에 작성자를 저장하는 방법 역시 답변과 완전 동일하므로 넘어가도록 하겠다.
하지만, 이러한 상태로 답변이나 질문을 작성하려 하면 서버 오류(500)가 발생하게 된다.
그 이유는 Principal
객체에 Null
값이 들어가있기 때문이다. 따라서 이에 대한 처리를 해야한다.
이 오류를 해결하려면 Principal 객체를 사용하는 메서드에 @PreAuthorize("isAuthenticated()")
어노테이션을 붙여주어야 한다. 해당 어노테이션을 붙이면, 로그인하지 않은 사용자에 대해서 질문이나 답변 등록 시 로그인 페이지로 넘어가게 된다.
//QuestionController
@PreAuthorize("isAuthenticated()")
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "/question/question_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "/question/question_form";
}
SiteUser user = userService.getUser(principal.getName());
questionService.create(questionForm.getSubject(), questionForm.getContent(), user);
return "redirect:/question/list";
}
//AnswerController
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable Long id, @Valid AnswerForm answerForm, BindingResult bindingResult
, Principal principal) {
Question question = questionService.getQuestion(id);
SiteUser user = userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
answerService.create(question, answerForm.getContent(), user);
return "redirect:/question/detail/" + id;
}
@EnableMethodSecurity
의 prePostEnabled = true
로 함으로서 @PreAuthorize
어노테이션을 사용할 수 있게 한다.@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
....
...
}
로그인을 하지 않은 상태에서 질문이나 답변 등록 시 로그인 화면으로 이동한다.
이 후 로그인에 성공하면, 스프링시큐리티는 그 전에 하려했던 작업 url로 리다이렉트 시켜준다.
질문과 답변을 수정하고 삭제할 수 있도록 기능을 추가해보자
private LocalDateTime modifyDate;
null
이면 안된다.username
과 질문 작성자의 username
이 같아야 한다. <div class="my-3">
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
</div>
@PreAuthorize("isAuthenticated()")
를 통해 로그인 된 사용자만 접근 가능username
과 현재 로그인 된 사용자의 username
이 다르면 예외 발생question_form
뷰로 이동할 때 질문의 주제와 내용을 뷰로 넘겨준다. @PreAuthorize("isAuthenticated()")
@GetMapping("/modify/{id}")
public String questionModify(QuestionForm questionForm, @PathVariable("id") Integer id, Principal principal) {
Question question = this.questionService.getQuestion(id);
if(!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
questionForm.setSubject(question.getSubject());
questionForm.setContent(question.getContent());
return "question_form";
}
th:action
태그를 삭제했다. (질문 등록과 동일한 템플릿 사용을 위해)action
태그를 삭제하면 현재 Url
로 요청하게 된다.action
태그를 삭제하면 csrf
값이 자동으로 생성되지 않아 수동 설정을 해야 한다.//기존
<form th:action="@{/question/create}" th:object="${questionForm}" method="post">
//수정
<form th:object="${questionForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
question
객체의 값을 세팅한 후 저장하면 jpa
는 알아서 update
쿼리를 작성해준다. public void modify(Question question, String subject, String content) {
question.setSubject(subject);
question.setContent(content);
question.setModifyDate(LocalDateTime.now());
questionRepository.save(question);
}
questionForm
의 데이터를 검증한다.
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String questionModify(@PathVariable Long id, @Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "/question/question_form";
}
Question question = questionService.getQuestion(id);
if (!principal.getName().equals(question.getAuthor().getUsername())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
questionService.modify(question, questionForm.getSubject(), questionForm.getContent());
return "redirect:/question/detail/" + id;
}
th:data-uri
속성을 추가<a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="삭제"></a>
delete
라는 클래스를 포함하는 태그를 클릭하면 팝업 알림 발생dataset.uri
로 url 요청<script type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
public void delete(Question question) {
questionRepository.delete(question);
}
id
값으로 question
객체를 반환받는다.question
을 통해 찾은 작성자 username
과 현재 로그인된 username
비교 @PreAuthorize("isAuthenticated()")
@GetMapping("/delete/{id}")
public String questionDelete(Principal principal, @PathVariable Long id) {
Question question = questionService.getQuestion(id);
if (!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
}
questionService.delete(question);
return "redirect:/";
}
답변 수정과 삭제 역시 질문과 거의 똑같기 때문에 생략