
UserDetailService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당합니다.loadUserByUserName() 메소드가 존재하며, 회원 정보를 조회하여 사용자의 권한을 갖는 UserDetail 인터페이스를 반환합니다.User 클래스를 사용합니다.로그인 기능 구현을 위해 기존에 만들었던 MemberService가 UserDetailsService를 구현해봅니다.
package me.jincrates.gobook.service;
//...기존 임포트 생략
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;
@RequiredArgsConstructor
@Transactional
@Service
public class MemberService implements UserDetailsService {
//...코드 생략
@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();
}
}
package me.jincrates.gobook.config;
import me.jincrates.gobook.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.builders.WebSecurity;
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());
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
loginPage() : 로그인 페이지 URL 설정회원가입과 아주 유사하다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/default}">
<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
<style>
.error {
color: #bd2130;
}
</style>
</th:block>
<!-- 사용자 스크립트 추가 -->
<th:block layout:fragment="script">
<script th:inline="javascript">
//로그인 실패시 에러 메시지 출력
$(document).ready(function(){
var errorMessage = [[${errorMessage}]];
if(errorMessage != null){
alert(errorMessage);
}
});
</script>
</th:block>
<div layout:fragment="content">
<form role="form" method="post" action="/members/login">
<div class="form-group py-2">
<label th:for="email">이메일 주소</label>
<input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요">
</div>
<div class="form-group py-2">
<label th:for="password">비밀번호</label>
<input type="password" name="password" class="form-control" placeholder="비밀번호 입력">
</div>
<p th:if="${loginErrorMsg}" class="error" th:text="${loginErrorMsg}"></p>
<div style="text-align:center" class="py-3">
<button type="submit" class="btn btn-outline-dark">로그인</button>
<button type="button" class="btn btn-outline-dark" onclick="location.href='/members/new'">회원가입</button>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</div>
</html>
package me.jincrates.gobook.web;
import lombok.RequiredArgsConstructor;
import me.jincrates.gobook.domain.members.Member;
import me.jincrates.gobook.service.MemberService;
import me.jincrates.gobook.web.dto.MemberFormDto;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.validation.Valid;
@RequiredArgsConstructor
@RequestMapping("/members")
@Controller
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
....코드생략
@GetMapping(value = "/login")
public String loginMember() {
return "/member/memberLoginForm";
}
@GetMapping(value = "/login/error")
public String loginError(Model model) {
model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요.");
return "/member/memberLoginForm";
}
}

현재 상태로는 로그인을 해도 메뉴바에는 로그인이라는 메뉴가 나타납니다. 로그인 상태라면 ‘내 정보'이라는 메뉴가 나와 로그인 된 상태를 알 수 있고 드롭박스로 로그아웃 버튼이 보여지도록 하겠습니다. 또한 상품 등록 메뉴의 경우는 관리자만 상품을 등록할 수 있도록 권한체크를 하도록 하겠습니다.
// https://mvnrepository.com/artifact/org.thymeleaf.extras/thymeleaf-extras-springsecurity5
implementation group: 'org.thymeleaf.extras', name: 'thymeleaf-extras-springsecurity5', version: '3.0.4.RELEASE'
<!-- src/main/resources/templates/fragments/header.html-->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<div th:fragment="header">
<!-- Navigation-->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container px-4 px-lg-5">
<span>
<a class="navbar-brand" href="/">
<img src="/assets/img/pixel-squirtle.png" style="width: 28px; padding-bottom: 4px;">
<b style="font-size: 28px; color:#60BFB6; text-shadow: -1px 0 #0D0D0D, 0 1px #0D0D0D, 1px 0 #0D0D0D, 0 -1px #0D0D0D; margin-right: -8px">고북</b>
<b style="font-size: 28px; color:#F2D22E; text-shadow: -1px 0 #0D0D0D, 0 1px #0D0D0D, 1px 0 #0D0D0D, 0 -1px #0D0D0D;">고북</b>
</a>
</span>
<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 ms-lg-4">
<li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link active" aria-current="page" href="/admin/item/new">상품등록</a>
</li>
<li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link" href="/admin/items">상품관리</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/cart">장바구니</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/orders">구매이력</a>
</li>
<li class="nav-item" sec:authorize="isAnonymous()">
<a class="nav-link" href="/members/login">로그인</a>
</li>
<li class="nav-item dropdown" sec:authorize="isAuthenticated()">
<a class="nav-link dropdown-toggle" id="navbarDropdown" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">내 정보</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="/members/logout">로그아웃</a></li>
<!--
<li><a class="dropdown-item" href="#!">마이페이지</a></li>
<li><hr class="dropdown-divider" /></li>
-->
</ul>
</li>
</ul>
<form class="d-flex">
<button class="btn btn-outline-dark" type="submit">
<i class="bi-cart-fill me-1"></i>
Cart
<span class="badge bg-dark text-white ms-1 rounded-pill">0</span>
</button>
</form>
</div>
</div>
</nav>
<!-- Header-->
<header class="bg-dark py-5">
<div class="container px-4 px-lg-5 my-5">
<div class="text-center text-white">
<h1 class="display-4 fw-bolder">Let's Go-Book</h1>
<p class="lead fw-normal text-white-50 mb-0">With this shop hompeage template</p>
</div>
</div>
</header>
</div>
</html>
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" : Spring Security 태그를 사용하기 위해서 네임스페이스를 추가합니다.sec:authorize="hasAnyAuthority('ROLE_ADMIN')" : 특정 권한으로 로그인한 경우에만 보여줍니다.sec:authorize="isAuthenticated()" : 로그인을 했을 경우에만 보여주도록 합니다.sec:authorize="isAnonymous()" : 로그인하지 않은 상태에만 보여줍니다.


ADMIN 계정만 접근할 수 있는 상품 등록 페이지와 이에 접근할 수 있도록 ItemController 클래스를 만들겠습니다.
<!-- /src/main/resources/templates/item/itemForm.html -->
<!DOCTYPE html>
<html xmlns:th="http//www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/default}">
<div layout:fragment="content">
<h1>상품등록 페이지입니다.</h1>
</div>
</html>
package me.jincrates.gobook.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ItemController {
@GetMapping(value = "/admin/item/new")
public String itemForm() {
return "/item/itemForm";
}
}
만약 인증되지 않은 사용자가 리소스를 요청할 경우 “Unauthorized” 에러를 발생하도록 config 패키지 하위에 AuthenticationEntryPoint 인터페이스를 구현합니다.
package me.jincrates.gobook.config;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
}
}
package me.jincrates.gobook.config;
//...기존 임포트 생략
import org.springframework.security.config.annotation.web.builders.WebSecurity;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//...코드 생략
@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("/")
;
http
.authorizeRequests()
.mvcMatchers("/", "/members/**", "/item/**", "/assets/**", "/h2-console/**").permitAll()
.mvcMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
;
http
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
;
}
//...기존 코드 생략
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
authorizeRequests() : 시큐리티 처리에 HttpServletRequest를 이용한다는 것을 의미합니다.permitAll() : 모든 사용자가 인증(로그인)없이 해당 경로를 접근할 수 있도록 설정합니다.hasRole("ADMIN") : 해당 권한을 가진 사용자만 경로에 접근할 수 있습니다.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) : 인증되지 않은 사용자가 리소스에 접근하였을 때 수행되는 핸들러를 등록합니다.일반 사용자가 관리자 권한 페이지 리소스에 접근시 403(Forbidden) 에러코드를 반환합니다.

