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)
에러코드를 반환합니다.