(101)

추가기능 1. 조회수 구현하기 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 조회수 구현하기 조회수는 보통 게시판 목록 페이지, 즉 우리가 만든 question_list 템플릿에서 구현되어야 할 것이다. 우선 조회수가 보여질 장소를 마련해주자. "조회수" 라는 것은 해당 글을 조회할 때마다 올라가야 할 것이다. 우리가 만든 게시판의 "글"은 Question이다. 즉 Question 엔티티에 조회수에 해당하는 필드가 있어야 할 것이다. 또한 조회수 자체도 리파지토리에 저장되어야 하기 때문에, 서비스에서 이를 저장하고 조회수가 증가하게 만들어야 할 것이다. Question.java @Column(columnDefinition = "integer ..

24. 게시글 수정 삭제 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 수정 일시 Question과 Answer 엔티티에 modifyDate필드를 추가해 DB에 컬럼을 추가시키자 private LocalDateTime modifyDate; 질문 수정 작성한 질문을 수정하려면 질문 상세 화면에서 "수정" 버튼을 클릭하여 수정 화면으로 진입해야 한다. 질문 수정 버튼 question_detail에 질문 수정버튼을 추가하자. (... 생략 ...) (... 생략 ...) #authentication.getPrincipal().getUsername() == question.author.username을 적용하였다. 로그인한 사용자와 글쓴이가 다..

23. 작성자(Author) 표시 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 질문 목록 question_list.html 템플릿에 글쓴이를 표시해 보자. (... 생략 ...) 번호 제목 글쓴이 작성일시 (... 생략 ...) 글쓴이 항목을 추가했다. 그리고 th 엘리먼트를 가운데 정렬하도록 tr 태그에 text-center 클래스를 추가하고 제목의 너비가 전체에서 50%를 차지하도록 style="width:50%"도 지정해 주었다. for 문에도 다음처럼 글쓴이를 적용하자. question.list_html (... 생략 ...) (... 생략 ...) ... 엘리먼트를 삽입하여 질문의 글쓴이를 표시했다. 작성자 정보 없이 저장된 이전의 질..

22. 작성자(Author) 생성 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. Question 속성 추가 Question 엔티티에 author 속성을 추가하자. (... 생략 ...) import javax.persistence.ManyToOne; import com.example.board.user.SiteUser; (... 생략 ...) public class Question { (... 생략 ...) @ManyToOne private SiteUser author; } 여러개의 질문이 한 명의 사용자에게 작성될 수 있으므로 @ManyToOne 관계가 성립한다. Answer 속성 추가 Answer 엔티티에도 추가해주자. (... 생략 ...)..

스프링 시큐리티 권한 부여

문득 점프 투 스프링부트 교재를 공부하다 시큐리티는 인증과 권한을 담당한다는 문구를 보고 관리자같은 권한을 따로 부여하는 방법이 궁금해졌다. 해당 글은 스프링 시큐리티 - 점프 투 스프링부트(게시판 만들기) 편과 연계된다. 만들어 놓았던 SecurityConfig를 살펴보자. package com.example.board.practice; (... 생략 ...) @Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests().requestMatchers( new AntP..

21. 로그인 / 로그아웃 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 로그인 구현하기 회원 가입 단계에서 SITE_USER 테이블에 회원 정보를 저장했다. SITE_USER 테이블에 저장된 사용자명(사용자 ID)과 비밀번호로 로그인을 하려면 복잡한 단계를 거쳐야 한다. 하지만 스프링 시큐리티를 사용하면 이 단계를 보다 쉽게 진행할 수 있다. 로그인 URL 등록 먼저 스프링 시큐리티에 로그인 URL을 등록하자. SecurityConfig.java package com.example.board.practice; (... 생략 ...) public class SecurityConfig { @Bean SecurityFilterChain fil..

20. 회원가입 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 회원 정보를 위한 엔티티 회원 가입 시 회원 정보를 위한 엔티티에는 최소한 ID, PW, email이 필요하다고 가정하겠다. SiteUser 엔티티 사용자를 관리할 SiteUser 엔티티를 작성하자 파일경로 : /practice/src/main/java/com/example/board/user/SiteUser.java package com.example.board.user; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; im..

19. 스프링 시큐리티 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 스프링 시큐리티 설치 스프링 시큐리티는 스프링 기반 애플리케이션의 인증과 권한을 담당하는 스프링의 하위 프레임워크이다. 인증(Authenticate)은 로그인을 의미한다. 권한(Authorize)은 인증된 사용자가 어떤 것을 할 수 있는지를 의미한다. 스프링 시큐리티 사용을 위해 다음과 같이 build.gradle 파일을 수정하자. (... 생략 ...) dependencies { (... 생략 ...) implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.t..

18. 게시물에 답변 개수 표시 - 점프 투 스프링부트(게시판 만들기)

해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다. 자바 8, 스프링부트 2.7.7버전 입니다. 답변 개수 표시하기 question_list.html 템플릿에 코드를 추가하자. (... 생략 ...) (... 생략 ...) th:if="${#lists.size(question.answerList) > 0}"로 답변이 있는지 조사하고 th:text="${#lists.size(question.answerList)}"로 답변 개수를 표시했다. #list.size(이터러블객체)는 이터러블 객체의 사이즈를 반환하는 타임리프의 유틸리티이다.

추가기능 1. 조회수 구현하기 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 6. 22:53
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.






조회수 구현하기


조회수는 보통 게시판 목록 페이지, 즉 우리가 만든 question_list 템플릿에서 구현되어야 할 것이다.
우선 조회수가 보여질 장소를 마련해주자.



"조회수" 라는 것은 해당 글을 조회할 때마다 올라가야 할 것이다. 우리가 만든 게시판의 "글"은 Question이다. 즉 Question 엔티티에 조회수에 해당하는 필드가 있어야 할 것이다.
또한 조회수 자체도 리파지토리에 저장되어야 하기 때문에, 서비스에서 이를 저장하고 조회수가 증가하게 만들어야 할 것이다.


Question.java

@Column(columnDefinition = "integer default 0", nullable = false)
private int view;

Question엔티티에 해당 필드(컬럼)을 추가해주었다. null은 불가능하며, 디폴트값은 0이다. 조회수는 0부터 시작할 것이다.



QuestionService.java

(...생략...)
public class QuestionService {

	(...생략...)
	
    public Question getQuestion(Integer id) {  
        Optional<Question> question = this.questionRepository.findById(id);
        if (question.isPresent()) {
        	Question question1 = question.get();        	
        	question1.setView(question1.getView()+1);        	
        	this.questionRepository.save(question1);
            	return question1;
        } else {
            throw new DataNotFoundException("question not found");
        }
    }

기존의 getQuestion메서드를 위와 같이 변경했다. 처음의 getView()값은 0이다.
Question엔티티의 question1 인스턴스에서 해당 객체의 id값이 조회될 때마다 조회수를 1씩 증가시키고, question1에서 조회수값을 증가시켰기 때문에 question1 객체를 리턴하게 하였다.


이후 기존의 템플릿에서 Question엔티티의 view컬럼을 가져오게 아래와같이 수정했다.

<td th:text="${question.view}"></td>

 



조회수 값이 잘 들어온 것을 확인할 수 있다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

24. 게시글 수정 삭제 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 6. 22:33
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

 

 

수정 일시


QuestionAnswer 엔티티에 modifyDate필드를 추가해 DB에 컬럼을 추가시키자

private LocalDateTime modifyDate;

 

 

 

질문 수정


작성한 질문을 수정하려면 질문 상세 화면에서 "수정" 버튼을 클릭하여 수정 화면으로 진입해야 한다.

 

 

질문 수정 버튼

question_detail에 질문 수정버튼을 추가하자.

(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
                <div class="mb-2">
                    <span th:if="${question.author != null}" th:text="${question.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
        <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>
    </div>
</div>
(... 생략 ...)

 #authentication.getPrincipal().getUsername() == question.author.username을 적용하였다.

로그인한 사용자와 글쓴이가 다르다면 수정 버튼은 보이지 않을 것이다.

#authentication.getPrincipal()은 Principal 객체를 리턴하는 타임리프의 유틸리티이다.

 

 

QuestionController

(...생략...)
public class QuestionController
(...생략...)

    @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/question_form";
    }
    
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/modify/{id}")
    public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult,
    		Principal principal, @PathVariable("id") Integer id) {
    	if(bindingResult.hasErrors()) {
    		return "/question/question_form";
    	}
    	Question question = this.questionService.getQuestion(id);
    	if(!question.getAuthor().getUsername().equals(principal.getName())) {
    		throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
    	}
    	this.questionService.modify(question, questionForm.getSubject(), questionForm.getContent());
    	return String.format("redirect:/question/detail/%s", id);
    }

로그인한 사용자와 질문의 작성자가 동일하지 않을 경우 오류가 발생하도록 처리했다.

보통 수정을 진행하면 해당 글의 수정 전의 내용이 그대로 적혀있는 경우가 대부분이다.

수정할 질문의 제목과 내용을 화면에 보여주기 위해 questionForm 객체에 현재 question의 값을 담아 전달했다.

 

POST형식의 메서드에서는 questionForm의 데이터를 검증하고 수정하려는 사용자가 작성자와 동일한지 검증한다.

검증이 통과되면 서비스의 modify 메서드를 호출하여 데이터를 수정할 수 있게 설계했고

수정이 완료되면 질문 상세화면을 다시 호출하게 만들었다.

 

하지만 현재 QuestionService에 modify 메서드가 없기 때문에 modify에 오류가 생겼을 것이다.

quick fixes available 기능을 이용하여 modify에 마우스를 올려 서비스에 간편하게 메서드를 생성할 수 있다.

 

 

QuestionService

생성된 modify 메서드에 살을 채워넣자.

(... 생략 ...)
public class QuestionService {
    (... 생략 ...)

    public void modify(Question question, String subject, String content) {
        question.setSubject(subject);
        question.setContent(content);
        question.setModifyDate(LocalDateTime.now());
        this.questionRepository.save(question);
    }
}

 

 

 

question_form.html

질문 수정시에도 질문 등록과 동일한 템플릿을 사용할 수 있도록 수정하자

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form th:object="${questionForm}" method="post">
        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf?.token}" />
        <div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <div class="mb-3">
            <label for="subject" class="form-label">제목</label>
            <input type="text" th:field="*{subject}" class="form-control">
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea th:field="*{content}" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="저장하기" class="btn btn-primary my-2">
    </form>
</div>
</html>

폼 태그의 th:action 속성을 삭제하면 CSRF 값이 자동으로 생성되지 않기 때문에 위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가하자.

 

폼 태그의 action 속성 없이 폼을 전송(submit)하면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송이 된다. 질문 등록시에 브라우저에 표시되는 URL은 /question/create이기 때문에 POST로 폼 전송시 action 속성에 /question/create가 설정이 되고, 질문 수정시에 브라우저에 표시되는 URL은 /question/modify/2 형태의 URL이기 때문에 POST로 폼 전송시 action 속성에 /question/modify/2 형태의 URL이 설정되는 것이다.

 

 

질문 삭제


 

질문 삭제 버튼

question_detail 템플릿에 질문삭제 버튼을 추가하자.

(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        (... 생략 ...)
        <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>
            <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>
        </div>
    </div>
</div>
(... 생략 ...)

href 속성값을 javascript:void(0)로 설정했다. 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가했고

버튼이 눌리는 이벤트를 확인할 수 있도록 class 속성에 "delete" 항목을 추가해 주었다.

data-uri 속성은 자바스크립트에서 클릭 이벤트 발생시 this.dataset.uri와 같이 사용하여 그 값을 얻을 수 있다.

href에 삭제 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>

delete라는 클래스를 포함하는 컴포넌트(버튼)를 클릭하면 "정말로 삭제하시겠습니까?" 라는 질문을 하고 "확인"을 선택했을때 해당 컴포넌트의 data-uri 값으로 URL 호출을 하라는 의미이다.

"확인" 대신 "취소"를 선택하면 아무런 일도 발생하지 않을 것이다.

 

따라서 이와 같은 스크립트를 추가하면 "삭제" 버튼을 클릭하고 "확인"을 선택하면 data-uri 속성에 해당하하는

@{|/question/delete/${question.id}|}이 호출될 것이다.

 

 

자바스크립트 블록

자바스크립트는 HTML 구조에서 다음과 같이 </body> 태그 바로 위에 삽입하는 것을 추천한다.

<html>
<head>
(... 생략 ...)
</head>
<body>
(... 생략 ...)
<!-- 이곳에 추가 -->
</body>
</html>

왜냐하면 화면 렌더링이 완료된 후에 자바스크립트가 실행되기 때문이다. 화면 렌더링이 완료되지 않은 상태에서 자바스크립트를 실행하면 오류가 발생할수도 있고 화면 로딩이 지연되는 문제가 발생할 수도 있다.

 

템플릿에서 자바스크립트를 </body> 태그 바로 위에 삽입하려면 다음처럼 layout.html을 수정해야 한다.

<!doctype html>
<html lang="ko">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
    <!-- sbb CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>Hello, sbb!</title>
</head>
<body>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
<!-- 자바스크립트 Start -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
</html>

</body> 태그 바로 위에 <th:block layout:fragment="script"></th:block> 블록을 추가했다.

 

question_detail.html 하단에 스크립트 블록을 추가하자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
</div>
<script layout:fragment="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>
</html>

 

 

 

QuestionService

질문을 삭제하는 기능을 QuestionService에 추가하자.

(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public void delete(Question question) {
        this.questionRepository.delete(question);
    }
}

 

 

 

QuestionController

그리고 @{|/question/delete/${question.id}|} URL을 처리하기 위한 기능을 QuestionController에 다음과 같이 추가하자.

(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/delete/{id}")
    public String questionDelete(Principal principal, @PathVariable("id") Integer id) {
        Question question = this.questionService.getQuestion(id);
        if (!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
        }
        this.questionService.delete(question);
        return "redirect:/";
    }
}

URL로 전달받은 id값을 사용하여 Question 데이터를 조회한후 로그인한 사용자와 질문 작성자가 동일할 경우 위에서 작성한 서비스의 delete 메서드로 질문을 삭제한다.

 

 

수정과 삭제가 잘 처리되었다.

 

 

 

답변 


답변 수정 및 삭제는 답변 등록 템플릿이 따로 없으므로 답변 수정에 사용할 템플릿이 추가로 필요하다.

 

 

버튼 및 수정일시 표시하기

답변 목록이 출력되는 부분에 버튼을 추가하자.

question_detail.html

(...생략...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
        	<div class="d-flex justify-content-end">
        		<div class="badge bg-light text-dark p-2 text-start">
        			<div class="mb-2">
        				<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
        			</div>
        			<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
        		</div>
        	</div>
        	<div class="my-3">
                <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
                    sec:authorize="isAuthenticated()"
                    th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                    th:text="수정"></a>
                <a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
                    class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                    th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                    th:text="삭제"></a>
            </div>
    </div>
</div>
<!-- 답변 반복 끝  -->
(...생략...)

 

 

그리고, 작성일자를 나타냈던 것 처럼, 해당 코드를 수정일자도 원하는 위치에 넣도록 하자.

question_detail.html

<div th:if="${question.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
    <div class="mb-2">수정일자</div>
    <div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>

답변 쪽의 수정일자는 question을 answer로 바꾸면 된다.

 

 

AnswerController

(...생략...)
public class AnswerController {
	(...생략...)
    
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/modify/{id}")
    public String answerModify(AnswerForm answerForm, @PathVariable("id") Integer id, Principal principal) {
        Answer answer = this.answerService.getAnswer(id);
        if (!answer.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
        }
        answerForm.setContent(answer.getContent());
        return "/answer/answer_form";
    }
    
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/modify/{id}")
    public String answerModify(@Valid AnswerForm answerForm, BindingResult bindingResult,
            @PathVariable("id") Integer id, Principal principal) {
        if (bindingResult.hasErrors()) {
            return "/answer/answer_form";
        }
        Answer answer = this.answerService.getAnswer(id);
        if (!answer.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
        }
        this.answerService.modify(answer, answerForm.getContent());
        return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
    }
    
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/delete/{id}")
    public String answerDelete(Principal principal, @PathVariable("id") Integer id) {
        Answer answer = this.answerService.getAnswer(id);
        if (!answer.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
        }
        this.answerService.delete(answer);
        return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
    }
}

 

답변 수정을 완료한 후에는 질문 상세페이지로 돌아가기 위해 answer.getQuestion.getId()로 질문의 ID값을 가져왔다.

 

 

AnswerService

QuestionService 작성 때와 동일하게 quick fixes available 기능을 이용하여 메서드 및 파라미터값을 자동 생성시킨 뒤에 작성해보자.

package com.example.board.answer;

import java.util.Optional;
import com.example.board.practice.DataNotFoundException;
(...생략...)
public class AnswerService {

	(...생략...)

    public Answer getAnswer(Integer id) {
        Optional<Answer> answer = this.answerRepository.findById(id);
        if (answer.isPresent()) {
            return answer.get();
        } else {
            throw new DataNotFoundException("답변이 없습니다.");
        }
    }

    public void modify(Answer answer, String content) {
        answer.setContent(content);
        answer.setModifyDate(LocalDateTime.now());
        this.answerRepository.save(answer);
    }

    public void delete(Answer answer) {
        this.answerRepository.delete(answer);
    }
}

 

 

answer_form.html

그리고 답변 수정을 위한 answer_form.html 템플릿을 다음과 같이 신규로 작성하자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
    <h5 class="my-3 border-bottom pb-2">답변 수정</h5>
    <form th:object="${answerForm}" method="post">
        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf?.token}" />
        <div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea th:field="*{content}" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="저장하기" class="btn btn-primary my-2">
    </form>
</div>
</html>

 

 

 

수정, 삭제 및 수정일자가 잘 들어왔는지 확인해보자.

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

23. 작성자(Author) 표시 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 21:23
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

질문 목록


question_list.html 템플릿에 글쓴이를 표시해 보자.

(... 생략 ...)
<tr class="text-center">
    <th>번호</th>
    <th style="width:50%">제목</th>
    <th>글쓴이</th>
    <th>작성일시</th>
</tr>
(... 생략 ...)

<th>글쓴이</th> 항목을 추가했다. 그리고 th 엘리먼트를 가운데 정렬하도록 tr 태그에 text-center 클래스를 추가하고 제목의 너비가 전체에서 50%를 차지하도록 style="width:50%"도 지정해 주었다.

 

for 문에도 다음처럼 글쓴이를 적용하자.

question.list_html

(... 생략 ...)
<tr class="text-center" th:each="question, loop : ${paging}">
    <td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
    <td class="text-start">
        <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
        <span class="text-danger small ms-2" th:if="${#lists.size(question.answerList) > 0}"
            th:text="${#lists.size(question.answerList)}">
        </span>
    </td>
    <td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
    <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
(... 생략 ...)

<td> ... </td> 엘리먼트를 삽입하여 질문의 글쓴이를 표시했다. 작성자 정보 없이 저장된 이전의 질문들은 author 속성에 해당하는 데이터가 없으므로 author 속성의 값이 null이 아닌 경우만 표시하도록 했다. 그리고 테이블 내용을 가운데 정렬하도록 tr 엘리먼트에 text-center 클래스를 추가하고, 제목을 왼쪽 정렬하도록 text-start 클래스를 추가했다.

 

질문 목록 화면에 글쓴이가 추가되었다.

 

 

 

 

질문 상세


question_detail.html

 

 

(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
                <div class="mb-2">
                    <span th:if="${question.author != null}" th:text="${question.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
    </div>
</div>
(... 생략 ...)

글쓴이와 작성일시가 함께 보이도록 수정했다.

 

답변 부분도 글쓴이를 다음처럼 추가하자.

question_detail.html

(... 생략 ...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
                <div class="mb-2">
                    <span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
    </div>
</div>
<!-- 답변 반복 끝  -->
(... 생략 ...)

마찬가지로 글쓴이와 작성일시가 함께 보이도록 수정했다

 

 

아주 잘 들어왔다

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

22. 작성자(Author) 생성 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 21:12
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

 

Question 속성 추가


Question 엔티티에 author 속성을 추가하자.

(... 생략 ...)
import javax.persistence.ManyToOne;
import com.example.board.user.SiteUser;
(... 생략 ...)
public class Question {
    (... 생략 ...)

    @ManyToOne
    private SiteUser author;
}

 

여러개의 질문이 한 명의 사용자에게 작성될 수 있으므로 @ManyToOne 관계가 성립한다.

 

 

 

Answer 속성 추가


Answer 엔티티에도 추가해주자.

(... 생략 ...)
import com.example.board.user.SiteUser;
(... 생략 ...)
public class Answer {
    (... 생략 ...)

    @ManyToOne
    private SiteUser author;
}

 

 

 

author 저장


답변에 작성자 저장하기

먼저 답변을 작성하는 AnswerController를 수정하자.

(... 생략 ...)
import java.security.Principal;
(... 생략 ...)
public class AnswerController {

    (... 생략 ...)

    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, 
            @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
        (... 생략 ...)
    }
}

현재 로그인한 사용자에 대한 정보를 알기 위해서는 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다.

principal.getName()을 호출하면 현재 로그인한 사용자의 사용자명(사용자ID)을 알수 있다.

 

principal 객체를 사용하면 이제 로그인한 사용자의 사용자명을 알수 있으므로 사용자명을 통해 SiteUser객체를 조회할 수 있다.

SiteUser를 조회할 수 있는 getUser 메서드를 UserService에 추가하자.

(... 생략 ...)
import java.util.Optional;
import com.example.board.practice.DataNotFoundException;
(... 생략 ...)
@Service
public class UserService {

    (... 생략 ...)

    public SiteUser getUser(String username) {
        Optional<SiteUser> siteUser = this.userRepository.findByusername(username);
        if (siteUser.isPresent()) {
            return siteUser.get();
        } else {
            throw new DataNotFoundException("siteuser not found");
        }
    }
}

UserRepository에 이미 findByusername을 선언했으므로 쉽게 만들수 있다.

 

답변 저장시 작성자를 저장할 수 있도록 다음과 같이 AnswerService를 수정하자.

 

(... 생략 ...)
import com.example.board.user.SiteUser;
(... 생략 ...)
public class AnswerService {

    (... 생략 ...)

    public Answer create(Question question, String content, SiteUser author) {
        Answer answer = new Answer();
        answer.setContent(content);
        answer.setCreateDate(LocalDateTime.now());
        answer.setQuestion(question);
        answer.setAuthor(author);
        this.answerRepository.save(answer);
        return answer;
    }
}

create 메서드에 SiteUser 객체를 추가로 전달받아 답변 저장시 author 속성에 세팅했다.

이제 답변을 작성하면 작성자도 함께 저장될 것이다.

 

AnswerController의 createAnswer 메서드를 완성해 보자.

(... 생략 ...)
import com.example.board.user.SiteUser;
import com.example.board.user.UserService;
(... 생략 ...)
public class AnswerController {

    private final QuestionService questionService;
    private final AnswerService answerService;
    private final UserService userService;

    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, 
            @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
        Question question = this.questionService.getQuestion(id);
        SiteUser siteUser = this.userService.getUser(principal.getName());
        if (bindingResult.hasErrors()) {
            model.addAttribute("question", question);
            return "/question/question_detail";
        }
        this.answerService.create(question, answerForm.getContent(), siteUser);
        return String.format("redirect:/question/detail/%s", id);
    }
}

principal 객체를 통해 사용자명을 얻은 후에 사용자명을 통해 SiteUser 객체를 얻어서 답변을 등록하는 AnswerService의 create 메서드에 전달하여 답변을 저장하도록 했다.

 

 

질문에 작성자 저장하기

QuestionService를 수정하자.

(... 생략 ...)
import com.example.board.user.SiteUser;
(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public void create(String subject, String content, SiteUser user) {
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(LocalDateTime.now());
        q.setAuthor(user);
        this.questionRepository.save(q);
    }
}

create 메서드에 SiteUser 매개변수를 추가하여 Question 데이터를 생성했다.

 

이어서 QuestionController도 다음과 같이 수정하자.

(... 생략 ...)
import java.security.Principal;
import com.example.board.user.SiteUser;
import com.example.board.user.UserService;
(... 생략 ...)
public class QuestionController {

    private final QuestionService questionService;
    private final UserService userService;

    (... 생략 ...)

    @PostMapping("/create")
    public String questionCreate(@Valid QuestionForm questionForm, 
            BindingResult bindingResult, Principal principal) {
        if (bindingResult.hasErrors()) {
            return "/question/question_form";
        }
        SiteUser siteUser = this.userService.getUser(principal.getName());
        this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
        return "redirect:/question/list";
    }
}

 

 

 

QuestionService의 create 메서드의 매개변수로 SiteUser가 추가되었기 때문에 이전에 작성한 테스트 케이스가 오류가 발생할 것이다. 테스트 케이스의 오류를 임시 해결하기 위해 다음과 같이 수정하자.

package com.example.board;

(... 생략 ...)

    @Test
    void testJpa() {
        for (int i = 1; i <= 300; i++) {
            String subject = String.format("테스트 데이터입니다:[%03d]", i);
            String content = "내용무";
            this.questionService.create(subject, content, null);
        }
    }
}

 

 

 

로그인이 필요한 메서드


로그아웃 상태에서 질문 또는 답변을 등록하면 서버오류(500 오류)가 발생한다.
이 오류는 principal 객체가 널(null)값이라서 발생한 오류이다.
principal 객체는 로그인을 해야만 생성되는 객체이기 때문이다.

이 문제를 해결하려면 principal 객체를 사용하는 메서드에 @PreAuthorize("isAuthenticated()") 애너테이션을 사용해야 한다. @PreAuthorize("isAuthenticated()") 애너테이션이 붙은 메서드는 로그인이 필요한 메서드를 의미한다.

@PreAuthorize("isAuthenticated()") 애너테이션이 적용된 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 이동된다.

 

QuestionController를 다음과 같이 수정하자.

package com.example.board.question;

(... 생략 ...)
import org.springframework.security.access.prepost.PreAuthorize;
(... 생략 ...)
public class 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) {
        (... 생략 ...)
    }
}

로그인이 필요한 메서드들에 @PreAuthorize("isAuthenticated()") 애너테이션을 적용했다.

 

AnswerController도 다음과 같이 수정하자.

(... 생략 ...)
import org.springframework.security.access.prepost.PreAuthorize;
(... 생략 ...)
public class AnswerController {

    (... 생략 ...)

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, @Valid AnswerForm answerForm,
            BindingResult bindingResult, Principal principal) {
        (... 생략 ...)
    }
}

 

 

@PreAuthorize 애너테이션이 동작할 수 있도록 SecurityConfig를 다음과 같이 수정해야 한다.

(... 생략 ...)
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
(... 생략 ...)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    (... 생략 ...)
}

SecurityConfig에 적용한@EnableMethodSecurity(prePostEnabled = true) 설정은 QuestionController와 AnswerController에서 로그인 여부를 판별하기 위해 사용했던 @PreAuthorize 애너테이션을 사용하기 위해 반드시 필요하다.

 

이렇게 수정한후 로그아웃 상태에서 질문을 등록하거나 답변을 등록하면 자동으로 로그인 화면으로 이동하는 것을 확인할 수 있을 것이다.

로그인 하지 않은 상태에서 "질문 등록" 버튼을 누르면 "로그인" 화면으로 이동한다. 그리고 로그인을 진행하면 원래 하려고 했던 "질문 등록" 화면으로 이동한다. 이것은 로그인 후에 원래 하려고 했던 페이지로 리다이렉트 시키는 스프링 시큐리티의 기능이다.

 

 

 

disabled


작성한 글이 사라지는 문제를 해결하려면 로그아웃 상태에서는 아예 답변 작성을 못하게 막는 것이 좋을 것이다.

question_detail.html 파일을 다음과 같이 수정하자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
    <!-- 답변 작성 -->
    <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
        <div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <textarea sec:authorize="isAnonymous()" disabled th:field="*{content}" class="form-control" rows="10"></textarea>
        <textarea sec:authorize="isAuthenticated()" th:field="*{content}" class="form-control" rows="10"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
</div>
</html>

로그인 상태가 아닌 경우 textarea 태그에 disabled 속성을 적용하여 입력을 못하게 만들었다.

현재 사용자의 로그인 상태를 체크하는 속성

sec:authorize="isAnonymous()" : 현재 로그아웃 상태
sec:authorize="isAuthenticated()" : 현재 로그인 상태

 

 

답변 창이 disabled 되었다.

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

스프링 시큐리티 권한 부여

Tech/Java & Spring 2023. 1. 1. 18:11
728x90
728x90

문득 점프 투 스프링부트 교재를 공부하다 시큐리티는 인증과 권한을 담당한다는 문구를 보고

관리자같은 권한을 따로 부여하는 방법이 궁금해졌다.

 

해당 글은 스프링 시큐리티 - 점프 투 스프링부트(게시판 만들기) 편과 연계된다.

 

만들어 놓았던 SecurityConfig를 살펴보자.

package com.example.board.practice;

(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().requestMatchers(
            new AntPathRequestMatcher("/**")).permitAll()
                .and()
                    .csrf().ignoringRequestMatchers(
                    new AntPathRequestMatcher("/h2-console/**"))
                .and()
                    .headers()
                    .addHeaderWriter(new XFrameOptionsHeaderWriter(
                    XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))

                .and()
                    .formLogin()
                    .loginPage("/user/login")
                    .defaultSuccessUrl("/")

                .and()
                    .logout()
                    .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                    .logoutSuccessUrl("/")
                    .invalidateHttpSession(true)
        		;
        
        return http.build();
    }
	(... 생략 ...)
}

 

http.authorizeHttpRequests().requestMatchers(new AntPathRequestMatcher("/**")).permitAll();

해당 문장은 [ localhost:8080/~~ ] URL이하 어떠한 주소가 달려도 모두에게 접근권한을 준다는 의미였다.

 

맞게 이해한 건지 확인을 위해 **을 빼고 들어가자 로그인창이 떳다.

 

관리자일 때만 해당 페이지에 들어가지게 만들고 싶었다.

(... 생략 ...)
http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/")).permitAll()
        	.antMatchers("/question/detail/**").hasRole("ADMIN")
        		.and()
        		.csrf().ignoringRequestMatchers(
        				new AntPathRequestMatcher("/h2-console/**"))
                        
                        
	(... 생략 ...)

antMachers("해당 주소").hasRole("ADMIN") 코드를 작성하였다

 

성공한 것 같다

 

 

로그인을 했음에도 ADMIN이 아니기 때문에 페이지에 접근 불가능한 모습이다.

403 Forbidden

이 에러는 서버 자체 또는 서버에 있는 파일에 접근할 권한이 없을 경우에 발생한다. 서버에는 외부 접근을 제어하기 위한 수많은 권한 설정이 있고, 서버에서 설정해 둔 권한과 맞지 않는 접속 요청이 들어오면 접근을 거부하고 접근거부 코드를 반환하는데, 이 때 뜨는 것이 바로 403 Forbidden 에러다.

 

 

권한 설정 및 로그인, 로그아웃 그리고 403 Forbidden의 핸들링 처리를  보통 아래와 같이 한다고 한다.

protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        // 페이지 권한 설정
        .antMatchers("/admin/**").hasRole("ADMIN")
        .antMatchers("/user/myinfo").hasRole("MEMBER")
        .antMatchers("/**").permitAll()
    .and() // 로그인 설정
        .formLogin()
        .loginPage("/user/login")
        .defaultSuccessUrl("/user/login/result")
        .permitAll()
    .and() // 로그아웃 설정
        .logout()
        .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
        .logoutSuccessUrl("/user/logout/result")
        .invalidateHttpSession(true)
    .and()
    // 403 예외처리 핸들링
    .exceptionHandling().accessDeniedPage("/user/denied");
    }
}

 

 

뷰단에서(템플릿) 이런식으로의 활용도 가능하다고 한다.

<a href="members/login" sec:authorize="isAnonymous()">로그인</a>
<a href="members/new" sec:authorize="isAnonymous()">회원가입</a>
<a href="members/logout" sec:authorize="isAuthenticated()">로그아웃</a>
<a href="util/find" sec:authorize = "hasRole('USER')">찾아보기</a>
<a href="util/register" sec:authorize = "hasRole('ADMIN')">등록하기</a>

해당 앵커 태그들은 권한에 따라 이동하는 링크가 다르게 셋팅되어있다.

 

 

 

시큐리티 표현식


스프링 시큐리티에서의 표현식은 아래와 같다. 하나 유의할 점은 isAnonymous()의 경우 anonynous 인증 객체를 가진 상태이기 때문에, 일반 유저로 로그인하여 Role_USER 권한을 가진 경우 접근이 불가하다. 모든 사용자가 접근이 가능하도록 만들고 싶다면, permitAll을 해야한다.

 

표현식 설명
hasRole(String role) 해당 롤을 가지고 있는 경우 true
hasAnyRole(String… roles) 해당 롤 중에 하나를 가지고 있는 경우 true
isAnonymous() 익명 사용자인 경우 true
isRememberMe() Remember Me 인증을 통해 로그인한 경우 true
isAuthenticated() 이미 인증된 사용자인 경우 true
isFullyAuthenticated() Remember Me가 아닌 일반적인 인증 방법으로 로그인한 경우 true
permitAll 항상 true
denyAll 항상 false
principal 인증된 사용자의 사용자 정보(UserDetails 구현한 클래스의 객체) 반환
authentication 인증된 사용자의 인증 정보**(Authentication** 구현한 클래스의 객체) 반환

 

 

 

 

참조

https://whitepro.tistory.com/481?category=1058993

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

21. 로그인 / 로그아웃 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 14:04
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

 

로그인 구현하기


회원 가입 단계에서 SITE_USER 테이블에 회원 정보를 저장했다. SITE_USER 테이블에 저장된 사용자명(사용자 ID)과 비밀번호로 로그인을 하려면 복잡한 단계를 거쳐야 한다.

하지만 스프링 시큐리티를 사용하면 이 단계를 보다 쉽게 진행할 수 있다.

 

 

로그인 URL 등록

먼저 스프링 시큐리티에 로그인 URL을 등록하자.

SecurityConfig.java

package com.example.board.practice;
(... 생략 ...)
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll()
        		.and()
        		.csrf().ignoringRequestMatchers(
        				new AntPathRequestMatcher("/h2-console/**"))
        		 .and()
                 .headers()
                 .addHeaderWriter(new XFrameOptionsHeaderWriter(
                         XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
                 
                 .and()
                 .formLogin()
                 .loginPage("/user/login")
                 .defaultSuccessUrl("/")
        		;
        
        return http.build();
    }
	(... 생략 ...)
}

추가한 and().formLogin().loginPage("/user/login").defaultSuccessUrl("/") 은 스프링 시큐리티의 로그인 설정을 담당하는 부분으로 로그인 페이지의 URL은 /user/login이고 로그인 성공시에 이동하는 디폴트 페이지는 루트 URL(/)임을 의미한다.

 

 

UserController

스프링 시큐리티에 로그인 URL을 /user/login으로 설정했으므로 UserController에 해당 매핑을 추가해야 한다.

(... 생략 ...)
public class UserController {

    (... 생략 ...)

    @GetMapping("/login")
    public String login() {
        return "login_form";
    }
}

 

 

 

login_form.html

login_form.html 템플릿을 작성하자

파일경로 : /practice/src/main/resources/templates/login_form.html

<html layout:decorate="~{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>

시큐리티의 로그인이 실패할 경우에는 로그인 페이지로 다시 리다이렉트 된다. 이 때 페이지 파라미터로 error가 함께 전달된다. 따라서 로그인 페이지의 파라미터로 error가 전달될 경우 "사용자ID 또는 비밀번호를 확인해 주세요." 라는 오류메시지를 출력하도록 했다.

로그인 실패시 파라미터로 error가 전달되는 것은 스프링 시큐리티의 규칙이다.

 

 

하지만 아직 로그인을 수행할 수는 없다. 왜냐하면 스프링 시큐리티에 무엇을 기준으로 로그인을 해야 하는지 아직 설정하지 않았기 때문이다.

 

우리는 이미 회원가입을 통해 회원 정보를 데이터베이스에 저장했으므로 데이터베이스에서 회원정보를 조회하는 방법을 사용해야 할 것이다.

 

 

UserRepository

앞으로 작성할 UserSecurityService는 사용자를 조회하는 기능이 필요하므로 다음처럼 findByusername 메서드를

UserRepository에 추가하자.

package com.example.board.user;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<SiteUser, Long> {
    Optional<SiteUser> findByusername(String username);
}

 

 

UserRole

스프링 시큐리티는 인증 뿐만 아니라 권한도 관리한다. 따라서 인증후에 사용자에게 부여할 권한이 필요하다.

다음과 같이 ADMIN, USER 2개의 권한을 갖는 UserRole을 신규로 작성하자.

파일경로 : /practice/src/main/java/com/example/board/user/UserRole.java

package com.example.board.user;

import lombok.Getter;


@Getter
public enum UserRole {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

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

    private String value;
}

UserRole은 열거 자료형(enum)으로 작성했다. ADMIN은 "ROLE_ADMIN", USER는 "ROLE_USER" 라는 값을 가지도록 했다. 그리고 상수 자료형이므로 @Setter없이 @Getter만 사용가능하도록 했다.

 

 

UserSecurityService

스프링 시큐리티 설정에 등록할 UserSecurityService를 신규로 작성하자.

파일경로 : /practice/src/main/java/com/example/board/user/UserSecurityService.java

package com.example.board.user;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<SiteUser> _siteUser = this.userRepository.findByusername(username);
		if (!_siteUser.isPresent()) {
            throw new UsernameNotFoundException("사용자를 찾을 수 없습니다");
        }
        SiteUser siteUser = _siteUser.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(siteUser.getUsername(), siteUser.getPassword(), authorities);
    }
}

스프링 시큐리티에 등록하여 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현(implements)해야 한다. 스프링 시큐리티의 UserDetailsService는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스이다. loadUserByUsername 메서드는 사용자명으로 비밀번호를 조회하여 리턴하는 메서드이다.

UserSecurityService는 스프링 시큐리티 로그인 처리의 핵심 부분이다.

 

조금 더 자세히 살펴보면, loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException 오류를 내게 했다. 그리고 사용자명이 "admin"인 경우에는 ADMIN 권한을 부여하고 그 이외의 경우에는 USER 권한을 부여했다. 그리고 사용자명, 비밀번호, 권한을 입력으로 스프링 시큐리티의 User 객체를 생성하여 리턴했다. 스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 화면으로부터 입력 받은 비밀번호와 일치하는지를 검사하는 로직을 내부적으로 가지고 있다.

 

 

SecurityConfig

스프링 시큐리티에 UserSecurityService를 등록하자.

SecurityConfig.java

(... 생략 ...)
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    (... 생략 ...)

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

AuthenticationManager 빈을 생성했다. AuthenticationManager는 스프링 시큐리티의 인증을 담당한다. AuthenticationManager 빈 생성시 스프링의 내부 동작으로 인해 위에서 작성한 UserSecurityService와 PasswordEncoder가 자동으로 설정된다.

 

 

로그인 페이지에 진입할수 있는 로그인 링크를 navbar.html에 다음과 같이 추가하자.

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">게시판 연습</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
            aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/user/login}">로그인</a>                    
                </li>
		<li class="nav-item">
                    <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

 

 

로그인 / 로그아웃 링크

로그인을 한 상태라면 링크는 "로그아웃" 링크로 바뀌어야 한다.

반대로 로그아웃 상태에서는 "로그인" 링크로 바뀌어야 한다.

 

사용자의 로그인 여부는 타임리프의 sec:authorize 속성을 통해 알수 있다.

sec:authorize="isAnonymous()" : 로그인 되지 않은 경우에만 해당 엘리먼트가 표시되게 한다.
sec:authorize="isAuthenticated()" : 로그인 된 경우에만 해당 엘리먼트가 표시되게 한다.

 

다음과 같이 내비게이션바를 수정하자.

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">게시판 연습</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
            aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
                    <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
                </li>
		<li class="nav-item">
                    <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

로그인을 안한 상태라면 sec:authorize="isAnonymous()"가 참이되어 "로그인" 링크가 표시되고 로그인을 한 상태라면 sec:authorize="isAuthenticated()"가 참이되어 "로그아웃" 링크가 표시될 것이다.

 

 

로그아웃 구현하기


 

로그아웃 역시 스프링 시큐리티를 사용하여 쉽게 구현할수 있다.

 

 

SecurityConfig

SecurityConfig 파일을 수정하자.

(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig  {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll()
            .and()
                .csrf().ignoringRequestMatchers(
                        new AntPathRequestMatcher("/h2-console/**"))
            .and()
                .headers()
                .addHeaderWriter(new XFrameOptionsHeaderWriter(
                        XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
            .and()
                .formLogin()
                .loginPage("/user/login")
                .defaultSuccessUrl("/")
            .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
            ;

        return http.build();
    }

    (... 생략 ...)
}

 

 

 

정상적으로 수행했다면 로그인과 로그아웃이 처리 될 것이다.

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

20. 회원가입 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 13:12
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

회원 정보를 위한 엔티티


 

회원 가입 시 회원 정보를 위한 엔티티에는 최소한 ID, PW, email이 필요하다고 가정하겠다.

 

 

 

SiteUser 엔티티

사용자를 관리할 SiteUser 엔티티를 작성하자

파일경로 : /practice/src/main/java/com/example/board/user/SiteUser.java

package com.example.board.user;


import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class SiteUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;

    private String password;

    @Column(unique = true)
    private String email;
}

엔티티명을 User 대신 SiteUser로 한 이유는 스프링 시큐리티에 이미 User 클래스가 있기 때문이다.

 

username, email 속성에는 @Column(unique = true) 처럼 unique = true를 지정했다.

unique = true는 중복 값을 허용하지 않기 때문에 username과 email에 동일한 값이 저장되지 않는다.

 

 

 

SiteUser 테이블

H2 콘솔에 접속하여 테이블이 잘 만들어졌는지 확인해 보자.

 

 

SITE_USER 테이블과 컬럼들 그리고 unique로 설정한 속성들로 인해 생긴 UK_로 시작하는 인덱스들이 보일 것이다.

 

 

 

User 리포지터리와 서비스


사용자 엔티티가 준비되었으니 이제 User 리포지터리와 User 서비스를 만들어 보자.

 

User 리포지터리

파일경로 : /practice/src/main/java/com/example/board/user/UserRepository.java

package com.example.board.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<SiteUser, Long> {
}

SiteUser의 PK의 타입은 Long이다. 따라서 JpaRepository<SiteUser, Long>처럼 사용했다.

 

 

User 서비스

파일경로 : /practice/src/main/java/com/example/board/user/UserService.java

package com.example.board.user;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;

    public SiteUser create(String username, String email, String password) {
        SiteUser user = new SiteUser();
        user.setUsername(username);
        user.setEmail(email);
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        user.setPassword(passwordEncoder.encode(password));
        this.userRepository.save(user);
        return user;
    }
}

User 서비스에는 User 리포지터리를 사용하여 User 데이터를 생성하는 create 메서드를 추가했다.

이 때 사용자의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다.

암호화를 위해 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장했다.

BCryptPasswordEncoder는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 암호화한다.

 

 

하지만 이렇게 BCryptPasswordEncoder 객체를 직접 new로 생성하는 방식보다는 PasswordEncoder 빈(bean)으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아서 수정해야 하기 때문이다.

PasswordEncoder는 BCryptPasswordEncoder의 인터페이스이다.

 

 

PasswordEncoder 빈(bean)을 만드는 가장 쉬운 방법은 @Configuration이 적용된 SecurityConfig에 @Bean 메서드를 생성하는 것이다.

SecurityConfig를 수정하자.

(... 생략 ...)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
(... 생략 ...)

@Configuration
@EnableWebSecurity
public class SecurityConfig {
   (... 생략 ...)
    
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

 

이렇게 PasswordEncoder를 @Bean으로 등록하면 UserService도 다음과 같이 수정할수 있다.

package com.example.board.user;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;


    public SiteUser create(String username, String email, String password) {
        SiteUser user = new SiteUser();
        user.setUsername(username);
        user.setEmail(email);
        user.setPassword(passwordEncoder.encode(password));
        this.userRepository.save(user);
        return user;
    }
}

BCryptPasswordEncoder 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용하도록 수정했다.

 

 

 

회원가입 폼


 

UserCreateForm을 생성하자.

파일경로 : /practice/src/main/java/com/board/user/UserCreateForm.java

package com.example.board.user;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserCreateForm {
    @Size(min = 3, max = 25)
    @NotEmpty(message = "사용자ID는 필수항목입니다.")
    private String username;

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

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

    @NotEmpty(message = "이메일은 필수항목입니다.")
    @Email
    private String email;
}

username은 필수항목이고 길이가 3-25 사이여야 한다는 검증조건을 설정했다.

@Size는 폼 유효성 검증시 문자열의 길이가 최소길이(min)와 최대길이(max) 사이에 해당하는지를 검증한다.

 

password1password2는 "비밀번호"와 "비밀번호확인"에 대한 속성이다.

로그인 할때는 비밀번호가 한번만 필요하지만 회원가입시에는 입력한 비밀번호가 정확한지 확인하기 위해 2개의 필드가 필요하다.

 

@Email은 해당 속성의 값이 이메일형식과 일치하는지를 검증한다.

 

 

 

회원가입 컨트롤러


UserController을 만들자.

파일 경로 : /practice/src/main/java/com/example/board/user/UserController.java

package com.example.board.user;

import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm) {
        return "signup_form";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "signup_form";
        }

        if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
            bindingResult.rejectValue("password2", "passwordInCorrect", 
                    "2개의 패스워드가 일치하지 않습니다.");
            return "signup_form";
        }

        userService.create(userCreateForm.getUsername(), 
                userCreateForm.getEmail(), userCreateForm.getPassword1());

        return "redirect:/";
    }
}

/user/signup URL이 GET으로 요청되면 회원 가입을 위한 템플릿을 렌더링하고 POST로 요청되면 회원가입을 진행하도록 했다. 회원 가입시 비밀번호1 비밀번호2가 동일한지를 검증하는 로직을 추가했다. 만약 2개의 값이 일치하지 않을 경우에는 bindingResult.rejectValue를 사용하여 오류가 발생하게 했다. bindingResult.rejectValue의 각 파라미터는 bindingResult.rejectValue(필드명, 오류코드, 에러메시지)를 의미하며 여기서 오류코드는 일단 "passwordInCorrect"로 정의했다.

 

 

 

회원가입 템플릿


signup_form.html (회원가입 템플릿) 을 작성하자.

파일경로 : /practice/src/main/resources/templates/signup_form.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <div class="my-3 border-bottom">
        <div>
            <h4>회원가입</h4>
        </div>
    </div>
    <form th:action="@{/user/signup}" th:object="${userCreateForm}" 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:field="*{username}" class="form-control">
        </div>
        <div class="mb-3">
            <label for="password1" class="form-label">비밀번호</label>
            <input type="password" th:field="*{password1}" class="form-control">
        </div>
        <div class="mb-3">
            <label for="password2" class="form-label">비밀번호 확인</label>
            <input type="password" th:field="*{password2}" class="form-control">
        </div>
        <div class="mb-3">
            <label for="email" class="form-label">이메일</label>
            <input type="email" th:field="*{email}" class="form-control">
        </div>
        <button type="submit" class="btn btn-primary">회원가입</button>
    </form>
</div>
</html>

회원가입을 위한 "사용자 ID", "비밀번호", "비밀번호 확인", "이메일"에 해당되는 input 엘리먼트를 추가했다.

<회원가입> 버튼을 누르면 폼 데이터가 POST 방식으로 /user/signup/ URL로 전송된다.

 

 

 

내비게이션 바에 회원가입 링크 추가하기


 

navbar.html에 회원가입 링크를 추가하자.

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">SBB</a>
        (... 생략 ...)
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" href="#">로그인</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/user/signup}">회원가입</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

 

 

 

중복 회원가입


 

동일한 사용자ID 또는 동일한 이메일 주소로 사용자 데이터를 저장하는 것은 unique=true 설정으로 인해 허용되지 않기 때문에 오류가 발생하는 것은 당연하다. 하지만 화면에 500 오류 메시지를 그대로 보여주는 것은 좋지 않다.

UserController.java를 수정하자

package com.example.board.user;

import javax.validation.Valid;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm) {
        return "signup_form";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "signup_form";
        }

        if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
            bindingResult.rejectValue("password2", "passwordInCorrect", 
                    "2개의 패스워드가 일치하지 않습니다.");
            return "signup_form";
        }

        try {
            userService.create(userCreateForm.getUsername(), 
                    userCreateForm.getEmail(), userCreateForm.getPassword1());
        }catch(DataIntegrityViolationException e) {
            e.printStackTrace();
            bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
            return "signup_form";
        }catch(Exception e) {
            e.printStackTrace();
            bindingResult.reject("signupFailed", e.getMessage());
            return "signup_form";
        }

        return "redirect:/";
    }
}

사용자ID 또는 이메일 주소가 동일할 경우에는 DataIntegrityViolationException이 발생하므로 DataIntegrityViolationException 예외가 발생할 경우 "이미 등록된 사용자입니다."라는 오류를 화면에 표시하도록 했다.

그리고 다른 오류의 경우에는 해당 오류의 메시지(e.getMessage())를 출력하도록 했다.

bindingResult.reject(오류코드, 오류메시지)는 특정 필드의 오류가 아닌 일반적인 오류를 등록할때 사용한다.

 

 

 

 

 

h2-console에 회원가입이 잘 들어왔다.

 

 

중복 회원가입은 안되는 모습이다.

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

19. 스프링 시큐리티 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 13:04
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

스프링 시큐리티 설치


스프링 시큐리티는 스프링 기반 애플리케이션의 인증권한을 담당하는 스프링의 하위 프레임워크이다.

인증(Authenticate)은 로그인을 의미한다.
권한(Authorize)은 인증된 사용자가 어떤 것을 할 수 있는지를 의미한다.

 

스프링 시큐리티 사용을 위해 다음과 같이 build.gradle 파일을 수정하자.

(... 생략 ...)

dependencies {
    (... 생략 ...)
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.4.RELEASE'
}

(... 생략 ...)

"Refresh Gradle Project"를 수행하여 필요한 라이브러리를 설치한 후 로컬서버도 재시작 하자.

 

 

 

스프링 시큐리티 설정


스프링 시큐리티는 기본적으로 인증되지 않은 사용자는 서비스를 사용할 수 없게끔 되어 있다.

하지만 이러한 기본 기능은 그대로 적용하기에는 곤란하므로 시큐리티의 설정을 통해 바로 잡아야 한다.

 

SecurityConfig.java 파일을 작성하자.

파일경로 : /practice/src/main/java/com/mysite/sbb/SecurityConfig.java

package com.example.board;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll();
        return http.build();
    }
}

@Configuration은 스프링의 환경설정 파일임을 의미하는 애너테이션이다. 여기서는 스프링 시큐리티의 설정을 위해 사용되었다. @EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션이다.

@EnableWebSecurity 애너테이션을 사용하면 내부적으로 SpringSecurityFilterChain이 동작하여 URL 필터가 적용된다.

 

스프링 시큐리티의 세부 설정은 SecurityFilterChain 빈을 생성하여 설정할 수 있다. 다음 문장은 모든 인증되지 않은 요청을 허락한다는 의미이다. 따라서 로그인을 하지 않더라도 모든 페이지에 접근할 수 있다.

http.authorizeHttpRequests().requestMatchers(
    new AntPathRequestMatcher("/**")).permitAll();

 

 

H2 콘솔


하지만 스프링 시큐리티를 적용하면 H2 콘솔 로그인시 403 Forbidden 오류가 발생한다.

403 Forbidden 오류가 발생하는 이유는 스프링 시큐리티를 적용하면 CSRF 기능이 동작하기 때문이다.

CSRF(cross site request forgery)는 웹 사이트 취약점 공격을 방지를 위해 사용하는 기술이다. 스프링 시큐리티가 CSRF 토큰 값을 세션을 통해 발행하고 웹 페이지에서는 폼 전송시에 해당 토큰을 함께 전송하여 실제 웹 페이지에서 작성된 데이터가 전달되는지를 검증하는 기술이다.

 

스프링 시큐리티는 CSRF 토큰의 값이 정확한지 검증하는 과정을 거친다. (만약 CSRF 값이 없거나 해커가 임의의 CSRF 값을 강제로 만들어 전송하는 악의적인 URL 요청은 스프링 시큐리티에 의해 블록킹 될 것이다.)

그런데 H2 콘솔은 이와 같은 CSRF 토큰을 발행하는 기능이 없기 때문에 위와 같은 403 오류가 발생하는 것이다.

H2 콘솔은 스프링과 상관없는 일반 애플리케이션이다.

 

 

스프링 시큐리티가 CSRF 처리시 H2 콘솔은 예외로 처리할 수 있도록 설정 파일을 수정하자.

SecurityConfig.java

package com.example.board;

import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;

(... 생략 ...)
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll()
        		.and()
        		.csrf().ignoringRequestMatchers(
        			new AntPathRequestMatcher("/h2-console/**"))
        		 .and()
			.headers()
			.addHeaderWriter(new XFrameOptionsHeaderWriter(
                     	    XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
        		;
        return http.build();
    }
}

and() - http 객체의 설정을 이어서 할 수 있게 하는 메서드이다.

csrf().ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")) - /h2-console/로 시작하는 URL은 CSRF 검증을 하지 않는다는 설정이다.

 

URL 요청시 X-Frame-Options 헤더값을 sameorigin으로 설정하여 오류가 발생하지 않도록 했다.

X-Frame-Options 헤더의 값으로 sameorigin을 설정하면 frame에 포함된 페이지가 페이지를 제공하는 사이트와 동일한 경우에는 계속 사용할 수 있다.

스프링 시큐리티는 사이트의 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해
X-Frame-Options 헤더값을 사용하여 이를 방지한다. (clickjacking 공격을 막기위해 사용함)
클릭재킹은 웹 페이지 기반의 공격 유형으로 공격자가 관련이 없는 페이지 위에 있는 투명한 계층에 공격할 페이지를 표시하여 사용자가 클릭을 하거나 내용을 입력하도록 유도하는 공격이다.

 

 

이제 h2-console이 정상적으로 동작하는 것을 확인할 수 있다.

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

18. 게시물에 답변 개수 표시 - 점프 투 스프링부트(게시판 만들기)

Tech/Java & Spring 2023. 1. 1. 11:53
728x90
728x90
해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.

 

 

 

 

 

답변 개수 표시하기


 

question_list.html 템플릿에 코드를 추가하자.

(... 생략 ...)
        <tbody>
            <tr th:each="question, loop : ${paging}">
                <td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
                <td>
                    <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
                    <span class="text-danger small ms-2"
                        th:if="${#lists.size(question.answerList) > 0}" 
                        th:text="${#lists.size(question.answerList)}">
                    </span>
                </td>
                <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></d>
            </tr>
        </tbody>
(... 생략 ...)

th:if="${#lists.size(question.answerList) > 0}"로 답변이 있는지 조사하고

th:text="${#lists.size(question.answerList)}"로 답변 개수를 표시했다.

#list.size(이터러블객체)는 이터러블 객체의 사이즈를 반환하는 타임리프의 유틸리티이다.

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록