스프링 시큐리티를 이용한 로그인/로그아웃 구현하기

진크·2022년 2월 24일
0
post-thumbnail

1. UserDetailsService

  • UserDetailService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당합니다.
  • loadUserByUserName() 메소드가 존재하며, 회원 정보를 조회하여 사용자의 권한을 갖는 UserDetail 인터페이스를 반환합니다.

2. UserDetail

  • 스프링 시큐리티에서 회원의 정보를 담기 위해서 사용하는 인터페이스입니다.
  • 스프링 시큐리티에서 제공하는 User 클래스를 사용합니다.

3. 로그인/로그아웃 구현하기

로그인 기능 구현을 위해 기존에 만들었던 MemberServiceUserDetailsService를 구현해봅니다.

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

현재 상태로는 로그인을 해도 메뉴바에는 로그인이라는 메뉴가 나타납니다. 로그인 상태라면 ‘내 정보'이라는 메뉴가 나와 로그인 된 상태를 알 수 있고 드롭박스로 로그아웃 버튼이 보여지도록 하겠습니다. 또한 상품 등록 메뉴의 경우는 관리자만 상품을 등록할 수 있도록 권한체크를 하도록 하겠습니다.

thymeleaf-extra-springsecurity5 의존성 추가

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

profile
철학있는 개발자 - 내가 무지하다는 것을 인정할 때 비로소 배움이 시작된다.

0개의 댓글