3/26 TIL - 점프투스프링부트 따라하기 5

큰모래·2023년 3월 26일
0
post-custom-banner

점프투스프링부트


3-07 로그인과 로그아웃

SecurityConfig - 로그인 URL 등록

  • 로그인페이지를 /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("/")

UserController - 추가

  • 실제 로그인 처리가 진행되는post 전송 방식은 스프링 시큐리티가 대신 처리해주므로 작성 X
	@GetMapping("/login")
    public String login() {
        return "/user/login_form";
    }

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>

UserRepository

  • 결국, 서버에서는 로그인 하는 유저에 대한 정보를 db에서 찾아야 한다.
  • username을 통해 db에서 SiteUser를 찾고 Optional<SiteUser>객체를 반환
public interface UserRepository extends JpaRepository<SiteUser, Long> {
    Optional<SiteUser> findByUsername(String username);
}

UserRole

  • 스프링 시큐리티는 인증 뿐만 아니라 권한도 부여해야 한다.
  • 로그인에 성공한 유저에게 ADMIN, USER 중 적절한 권한을 부여한다.
@Getter
public enum UserRole {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    UserRole(String value) {
        this.value = value;
    }

    private String value;

}

UserSecurityService

  • 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);
    }

}

SecurityConfig 추가

  • AuthenticationManager는 스프링 시큐리티의 인증을 담당한다.
  • AuthenticationManager 객체를 빈으로 등록하면, UserSecurityService가 자동으로 설정된다.
	@Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

SecurityConfig - 로그아웃 추가

  • 로그아웃 URL 설정
  • 로그아웃 성공 URL 설정
  • 로그아웃 시 생성된 사용자 세션 삭제 (로그인 했던 정보 삭제)
	@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();
    }

3-08 엔티티 변경

게시판의 질문과 답변에 누가 작성했는지를 나타내는 author 속성을 추가

Question - 추가

	@ManyToOne
    private SiteUser author;

Answer - 추가

	@ManyToOne
    private SiteUser author;

AnswerController

  • 스프링 시큐리티의 Principal 객체를 통해 현재 로그인한 사용자의 정보를 알 수 있다.
	@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;
    }

UserService - 추가

  • 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");
    }

AnswerService - 추가

  • 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);
    }

질문에 작성자를 저장하는 방법 역시 답변과 완전 동일하므로 넘어가도록 하겠다.


@PreAuthorize("isAuthenticated()")

하지만, 이러한 상태로 답변이나 질문을 작성하려 하면 서버 오류(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;
    }

SecurityConfig 추가

  • @EnableMethodSecurityprePostEnabled = true로 함으로서 @PreAuthorize 어노테이션을 사용할 수 있게 한다.
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
 	....
 	...
}

로그인을 하지 않은 상태에서 질문이나 답변 등록 시 로그인 화면으로 이동한다.
이 후 로그인에 성공하면, 스프링시큐리티는 그 전에 하려했던 작업 url로 리다이렉트 시켜준다.


3-10 수정과 삭제

질문과 답변을 수정하고 삭제할 수 있도록 기능을 추가해보자

수정 일시 추가 (Question, Answer 엔티티)

private LocalDateTime modifyDate;

question_detail ( 질문 수정 버튼 추가)

  • 질문 작성자가 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>

QuestionController - 추가

  • @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";
    }

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}"/>

QuestionService - 추가

  • 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);
    }

QuestionController - 추가

  • 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;
    }

질문 삭제 버튼

  • 질문 삭제 버튼은 클릭 시 팝업을 띄우기 위해 자바스크립트 함수가 동작하게 설정했다.
  • 삭제를 실행할 URL을 얻기 위해 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>

QuestionService - 추가

  • 삭제는 간단하다.
	public void delete(Question question) {
        questionRepository.delete(question);
    }

QuestionController - 추가

  • 경로 변수의 id 값으로 question 객체를 반환받는다.
  • question을 통해 찾은 작성자 username과 현재 로그인된 username 비교
  • 같다면 질문 삭제
  • root url로 리다이렉트한다.
	@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:/";
    }

답변 수정과 삭제 역시 질문과 거의 똑같기 때문에 생략

profile
큰모래
post-custom-banner

0개의 댓글