회원가입 구현이 끝났으면 이제 로그인 구현을 해야 할 차례입니다.
순서도는 service작성 -> SecurityConfig작성->로그인 뷰 html 작성 ->컨트롤러 작성 ->의존성에 security5 추가하기
->header.html 작성 -> index.html 에 상속
갑자기 index화면이 바뀔겁니다. 부트스트랩을 적용했습니다. 자바 스크립만으로 로그인 버튼을 구현하려다가 너무 코드가 복잡해지고 원하는 값이 안 나와서 부트 스트랩을 추가해서 사용할 겁니다.
일단 의존성을 추가합시다.
그래들
implementation('nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect')
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
메이븐
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
application.properties
#Thymeleaf cache
spring.thymeleaf.cache = false
spring.thymeleaf.prefix= classpath:/templates/
spring.thymeleaf.suffix=.html
추가합시다.
그다음으로 heder.html, footer.html, layout1.html를 작성해야 합니다.
일단 사진으로 페키지안에 어떤 것들이 추가했고 수정했는지 미리 봅시다.
board 관련한 html이 밖에 있고 기준이 없어서 board라는 패키지를 만들어서 한 곳에 모아놨습니다. 마찬가지로 controller에서 board 관련한 리턴 url 값도 수정을 했습니다.
BoardController
package cho.boardplus.controller;
import cho.boardplus.dto.BoardDTO;
import cho.boardplus.service.BoardService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RequiredArgsConstructor
@RequestMapping("/board")
@Controller
public class BoardController {
private final BoardService boardService;
//메인페이지
@GetMapping("/index")
public String index() {
return "index";
}
//글 작성 페이지
@GetMapping("/save")
public String writeForm() {
return "board/save";
}
// 글작성 컨트롤러
@PostMapping("save")
public String save(@ModelAttribute BoardDTO boardDTO) {
System.out.println("boardDTO = " + boardDTO);
boardService.save(boardDTO);
return "index"; //index 로 변경
}
// 게시글 목록
// @GetMapping("/")
// public String findAll(Model model) {
// // DB에서 전체 게시글 데이터를 가져와서 list.html에 보여준다.
// List<BoardDTO> boardDTOList = boardService.findAll();
//
// model.addAttribute("boardList", boardDTOList);
// return "list";
// }
//게시글 조회
@GetMapping("/{id}")
public String findById(@PathVariable Long id, Model model,
@PageableDefault(page =1) Pageable pageable){
boardService.updateHits(id);
BoardDTO boardDTO =boardService.findById(id);
model.addAttribute("board",boardDTO);
model.addAttribute("page",pageable.getPageNumber());
return "board/detail";
}
//게시글 수정
@GetMapping("/update/{id}")
public String updateForm(@PathVariable Long id,Model model) {
BoardDTO boardDTO = boardService.findById(id);
model.addAttribute("boardUpdate",boardDTO);
return "board/update";
} //게시글 수정
@PostMapping("/update")
public String update(@ModelAttribute BoardDTO boardDTO, Model model){
BoardDTO board = boardService.update(boardDTO);
model.addAttribute("board", board);
return "board/detail";
// return "redirect:/board/"+ boardDTO.getId(); // 이것도 가능
}
//게시글 삭제
@GetMapping("/delete/{id}") //추가
public String delete(@PathVariable Long id){
boardService.delete(id);
return "redirect:/board/paging";
}
//게시글 페이징
// /board/paging?page=1
@GetMapping("/paging")
public String paging(@PageableDefault(page = 1) Pageable pageable, Model model) {
//pageable.getPageNumber();
Page<BoardDTO> boardList = boardService.paging(pageable);
List<BoardDTO> boardDTOList = boardService.findAll(); //추가
int blockLimit = 3;
int startPage = (((int)(Math.ceil((double)pageable.getPageNumber() / blockLimit))) - 1) * blockLimit + 1; // 1 4 7 10 ~~
int endPage = ((startPage + blockLimit - 1) < boardList.getTotalPages()) ? startPage + blockLimit - 1 : boardList.getTotalPages();
// page 갯수 20개
// 현재 사용자가 3페이지
// 1 2 3
// 현재 사용자가 7페이지
// 7 8 9
// 보여지는 페이지 갯수 는 3
// 총 페이지 갯수 8개
model.addAttribute("boardList", boardDTOList); //추가
model.addAttribute("boardList", boardList);
model.addAttribute("startPage", startPage);
model.addAttribute("endPage", endPage);
return "board/paging";
}
}
리턴 값 주소에 board/ 만 추가하시면 됩니다.
이제 부트 스크립 작성 차례입니다.
heder.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<meta charset="UTF-8">
<div th:fragment="header">
<nav class="navbar navbar-expand-sm bg-primary navbar-dark">
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbarTogglerDemo03" aria-controls="navbarTogglerDemo03"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/">게시판</a>
<div class="collapse navbar-collapse" id="navbarTogglerDemo03">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-board" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link" href="/board/save">게시글등록</a>
</li>
<li class="nav-board" sec:authorize="isAuthenticated()">
<a class="nav-link" href="board/paging">게시판목록</a>
</li>
<li class="nav-board" sec:authorize="isAnonymous()">
<a class="nav-link" href="/members/login">로그인</a>
</li>
<li class="nav-board" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/members/logout">로그아웃</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get">
<input name="searchQuery" class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
</div>
</html>
footer.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<meta charset="UTF-8">
<div class="footer" th:fragment="footer">
<footer class="page-footer font-small cyan darken-3">
<div class="footer-copyright text-center py-3">
2020 Shopping Mall Example WebSite
</div>
</footer>
</div>
</html>
layout1.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<title>Title</title>
<!-- CSS only -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link th:href="@{/css/layout1.css}" rel="stylesheet">
<!-- JS, Popper.js, and jQuery -->
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<th:block layout:fragment="script"></th:block>
<th:block layout:fragment="css"></th:block>
</head>
<body>
<div th:replace="fragments/header::header"></div>
<div layout:fragment="content" class="content">
</div>
<div th:replace="fragments/footer::footer"></div>
</body>
</html>
마지막으로 index에 추가할 내용들입니다.
index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout1}">
<head>
<meta charset="UTF-8">
<title>index</title>
<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
<style>
.carousel-inner > .item {
height: 350px;
}
.margin{
margin-bottom:30px;
}
.banner{
height: 300px;
position: absolute; top:0; left: 0;
width: 100%;
height: 100%;
}
.card-text{
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
a:hover{
text-decoration:none;
}
.center{
text-align:center;
}
</style>
</th:block>
</head>
<body>
<div layout:fragment="content"> <!-- <div layout:fragment="content"> 제외 나머지 삭제 해도 상관무-->
<button onclick="saveReq()">글작성</button> <!-- 삭제해도 상관없음-->
<button onclick="listReq()">글목록</button>
<button onclick="pagingReq()">페이징목록</button>
<button id="loginBtn" onclick="loginReq()">로그인</button>
<button id="logoutBtn" onclick="logoutReq()">로그아웃 </button>
</div> <!-- /div 를 제외 나머지 밑 함수 코드들 다 삭제해도 상관무-->
</body>
<script>
const saveReq = () => {
location.href = "/board/save";
}
const listReq = () => {
location.href = "/board/paging";
}
const pagingReq = () => {
location.href = "/board/paging";
}
const loginReq = () => {
location.href = "/members/login";
}
const logoutReq = () => {
fetch('/members/logout', { method: 'POST' })
.then(() => location.reload())
.catch(error => console.error(error));
}
</script>
<!-- 여기까지 삭제 해도 상관무-->
</html>
이제 본격적인 로그인 화면 구현입니다. 시작합니다.
MemberService
package cho.boardplus.service;
import cho.boardplus.entity.Member;
import cho.boardplus.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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 org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
public Member saveMember(Member member){
validateDuplicateMember(member);
return memberRepository.save(member);
}
private void validateDuplicateMember(Member member) {
Member findMember = memberRepository.findByEmail(member.getEmail());
if (findMember != null){
throw new IllegalStateException("이미 가입된 회원입니다.");
}
}
@Override //추가
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member =memberRepository.findByEmail(email);
if (member ==null){
throw new UsernameNotFoundException(email);
}
return User.builder()
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRole().toString())
.build();
}
}
- MemberService가 UserDetailsService를 구현합니다.
- UserDetailsService인터페이스의 loadUserByUsername() 메소드를 오버라이딩합니다. 로그인할 유저의 email을 파라미터로 전달받습니다.
- UserDetial을 구현하고 있는 User 객체를 반환해 줍니다. User 객체를 생성하기 위해서 생성자로 회원의 이메일, 비밀번호, role을 파라미터 넘겨줍니다.
SecuriyConfig
package cho.boardplus.confing;
import cho.boardplus.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
MemberService memberService; //추가
@Override //추가 변경
protected void configure(HttpSecurity http) throws Exception{
http.formLogin()
.loginPage("/members/login")
.defaultSuccessUrl("/")
.usernameParameter("email")
.failureUrl("/members/login/error")
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))
.logoutSuccessUrl("/");
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override //추가
protected void configure(AuthenticationManagerBuilder auth)throws Exception{
auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
}
}
- 로그인 페이지 URL을 설정합니다.
- 로그인 성공 시 이동할 URL을 설정합니다.
- 로그인 시 사용할 파라미터 이름으로 email을 지정합니다.
- 로그인 실패 시 이동할 URL을 설정합니다.
- 로그아웃 URL을 설정합니다.
- 로그아웃 성공 시 이동할 URL을 설정합니다.
- Spring Security에서 인증은 AuthenticationManager를 통해 이루어지며 AuthenticationManagerBuilder가 AuthenticationManager를 생성합니다. userDetailService를 구현하고 이쓴 객체로 memberService를 지정해 주며, 비밀번호 암호화를 위해 passwordEncoder를 지정해 줍니다.
이제 로그인 페이지를 만들어야 합니다 로그인 페이지에서는 회원의 아이디와 비밀번호를 입력하는 입력란과 회원 가입을 하지 않았을 경우 회원 가입 페이지로 이동할 수 있는 버튼을 만들겠습니다.
memberLoginForm.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
</head>
<body>
<h1>회원가입</h1>
<form action="#" th:action="@{/members/new}" th:object="${memberFormDto}" method="post">
<label for="name">이름:</label>
<input type="text" id="name" name="name" th:field="*{name}" required>
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
<div></div>
<label for="email">이메일주소:</label>
<input type="email" id="email" name="email" th:field="*{email}" required>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
<div></div>
<label for="password">비밀번호:</label>
<input type="password" id="password" name="password" th:field="*{password}" required>
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></p>
<div></div>
<label for="address">주소:</label>
<input type="text" id="address" name="address" th:field="*{address}" required>
<p th:if="${#fields.hasErrors('address')}" th:errors="*{address}"></p>
<input type="submit" value="가입하기">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</body>
</html>
로그인 페이지를 만들었으니까 이동할 수 있도록 MemberController에 로직을 구현하겠습니다. 또한 로그인 실패 시"아이디 또는 비밀번호를 확인해 주세요"라는 메시지를 담아서 로그인 페이지로 보내겠습니다.
MemberController
package cho.boardplus.controller;
import cho.boardplus.dto.MemberFormDto;
import cho.boardplus.entity.Member;
import cho.boardplus.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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 javax.validation.Valid;
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@GetMapping(value = "/new")
public String memberForm(Model model) {
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm"; //뷰 단으로 리턴
}
@PostMapping(value = "/new")
public String newMember(@Valid MemberFormDto memberFormDto, BindingResult bindingResult, Model model){
if(bindingResult.hasErrors()){
return "member/memberForm";
}
try {
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
} catch (IllegalStateException e){
model.addAttribute("errorMessage", e.getMessage());
return "member/memberForm";
}
return "redirect:/";
}
@GetMapping(value = "/login") //추가
public String loginMember(){
return "/member/memberLoginForm";
}
@GetMapping(value = "/login/error") //추가
public String loginError(Model model){
model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요");
return "/member/memberLoginForm";
}
}
테스트 코드로 Spring Security를 테스트해봅시다. 의존성을 추가 안 하신 분들은 추가하세요
testImplementation 'org.springframework.security:spring-security-test'
MemberControllerTest
package cho.boardplus.controller;
import cho.boardplus.dto.MemberFormDto;
import cho.boardplus.entity.Member;
import cho.boardplus.service.MemberService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders;
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
public class MemberControllerTest {
@Autowired
private MemberService memberService;
@Autowired
private MockMvc mockMvc;
@Autowired
PasswordEncoder passwordEncoder;
public Member createMember(String email, String password){
MemberFormDto memberFormDto = new MemberFormDto();
memberFormDto.setEmail(email);
memberFormDto.setName("홍길동");
memberFormDto.setAddress("서울시 마포구 합정동");
memberFormDto.setPassword(password);
Member member = Member.createMember(memberFormDto,passwordEncoder);
return memberService.saveMember(member);
}
@Test
@DisplayName("로그인 성공 테스트")
public void loginSuccessTest() throws Exception{
String email = "test@email.com";
String password = "1234";
this.createMember(email,password);
mockMvc.perform(formLogin().userParameter("email")
.loginProcessingUrl("/members/login")
.user(email).password(password)).andExpect(SecurityMockMvcResultMatchers.authenticated());
}
@Test
@DisplayName("로그인 실패 테스트")
public void loginFailTest() throws Exception{
String email = "test@email.com";
String password ="1234";
mockMvc.perform(formLogin().userParameter("email")
.loginProcessingUrl("/members/login").user(email).password("1234"))
.andExpectAll(SecurityMockMvcResultMatchers.unauthenticated());
}
}
- MockMvc 테스트를 위해 @AutiCinfigureMockMvc 어노테이션을 선언합니다.
- MockMvc 클래스를 이용해 실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체입니다. MockMvc객체를 이용하면 웹 브라우저에서 요청을 하는 것처럼 테스트할 수 있습니다.
- 로그인 예제 진행을 위해서 로그인 전 회원을 등록하는 메소드를 만들어줍니다.
- 회원 가입 메소드를 실행 후 가입된 회원 정보로 로그인이 되는지 테스트를 진행합니다. userParameter()를 이용하여 이메일을 아이디로 세팅하고 로그인 URL에 요청합니다.
- 로그인이 성공하여 인증되었다면 테스트 코드가 통과합니다.
- 회원 가입은 정상적으로 진행하였지만 회원가입 시 입력한 비밀번호가 아닌 다른 비밀번호로 로그인을 시도하여 인증되지 않은 결과 값이 출력되어 테스트가 통과합니다.
로그인/로그아웃 구현이 완료됐습니다. DB테이블을 초기화했었으면 다시 회원가입을 하고 회원가입을 진행 후
로그인을 진행하세요
로그인을 했을 시 게시글 등록, 게시판목록, 로그아웃 이 활성화됩니다.
그리고 로그아웃 했을 시
이렇게 로그인 버튼이 생깁니다. 성공적으로 로그인 구현을 마쳤습니다.
'Spring > 게시판 CRUD + 추가기능들' 카테고리의 다른 글
게시글 작성시 회원 정보 게시글에 자동 포함시키기 (0) | 2023.04.13 |
---|---|
게시판 권한 부여 (0) | 2023.04.04 |
회원가입 기능 구현 (0) | 2023.04.01 |
게시글 페이징 (0) | 2023.03.30 |
게시판 삭제 (0) | 2023.03.29 |