ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 2-3. 스프링 시큐리티, 로그인/로그아웃 구현
    프로젝트/도서 관리 시스템 2023. 7. 5. 12:54

     

     

     

     

     

    스프링 시큐리티에 관한 개념은 아래 포스팅에서 따로 정리해뒀다.

     

    https://wonisdaily.tistory.com/268

     

    [Spring Boot] Spring Security 설정, 예제, 용어와 흐름

    📑 Spring Security 설정 스프링 시큐리티를 구현하는 프로젝트를 생성한다. 생성된 패스워드는 기본으로 사용해볼 수 있는 'user' 계정의 패스워드이다. 프로젝트가 정상적으로 실행된다면 브라우

    wonisdaily.tistory.com

     

    기본 개념을 넘어서 오늘은 스프링 시큐리티로 로그인과 로그아웃을 구현해보려고 한다. 

    아무래도 Spring Boot + JPA +thymeleaf 환경이 많고 블로그마다 정리된 내용이 조금씩 달라서 구현하는데 시간이 좀 걸렸는데, 잘 정리된 포스팅을 하나 발견해서 다음 기능으로 넘어갈 수 있었다. 참고한 포스팅은 아래 링크로 남기겠다. 

     

    내가 구현한 개발환경은 다음과 같다. 

     

    🎈 언어
       - JAVA 11

    🎈 IDE 
        - IntelliJ IDEA Ultimate

    🎈 FrontEnd
        - JSP, JavaScript, JQuery

    🎈 BackEnd Framework
       - Spring Boot (2.7.8)
       
    🎈 Build Tool
       - Gradle

    🎈 DB
       - Oracle 11g xe

     


     

     

    📑 목차

     

    ✔ 1. 클래스 종류와 역할

    ✔ 2. 전체 흐름

    ✔ 3. 예제 최종 패키지 구조

    ✔ 4. DB 구현 

    ✔ 5. SecurityConfig 설정

    ✔ 6. AuthenticationProvider 

    ✔ 7. UserDetailsService (Service)

    ✔ 8. UserDetails ( UserDto ) 

    ✔ 9. Controller 

    ✔ 10. Mapper (Dao)

    ✔ 11. Mapper.xml

    12. loginForm.jsp

     

     

     


     

    📑 1. 클래스 종류와 역할

     

     

    클래스의 역할과 흐름은 앞에서 설명했기 때문에 여기선 간단히 특정 클래스만 정리해보려고 한다.

     

     

    📌 UserDetails : Spring Security에서 사용자의 정보를 담는 인터페이스이다.
    📌 UserDetailsService : Spring Security에서 유저의 정보를 가져오는 인터페이스이다. 
    📌 AuthenticationProvider : DB에서 가져온 정보와 input 된 정보가 비교돼서 체크되는 로직이 포함되어있는 인터페이스이다. 

     

     

    즉 내가 이해한 토대로 이 3가지 특정 클래스를 정리해 보자면,

    DB에서 데이터를 조회하면 domain 클래스의 객체 정보를 담는다. 이 domain 클래스가 바로 UserDetails이다.

    MVC 패턴에서 DB -> Mapper(DAO) -> Service -> Controller 이런 식의 흐름을 가져가는데, UserDetailsService가 바로 Service 단에 해당한다. 즉) Service에서 db의 데이터를 조회하는 코드를 작성하는 것이다.

    AuthenticationProvider 는 로그인 화면에서 사용자가 입력한 id, password와 db에 저장되어 있는 id password를 비교해서 일치하면 UsernamePasswordAuthenticationToken 토큰에 정보를 담아서 리턴한다. 

     

     

     

    📑 전체 흐름

     

    전체 흐름을 다시 한 번 정리해본다. 이흐름만 보고 이해가 되지 않을 수 있지만, 실제로 아래 정리되어 있는 순서대로 스프링 시큐리티 로그인을 하나씩 구현하다 보면 이해가 될 것 이다. 

     

     

    🔨 1. 유저가 로그인을 요청한다. (Http Request) 

    -> 이 요청이 바로 스스로를 증명하는 인증 단계 (Authentication) 

     

    🔨 2. AuthenticationFilter에서 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에 전달한다.

     

    🔨 3. AuthenticationManager은 등록된 AuthenticationProvider 들을 조회하여 인증을 요구한다. 

    -> AuthenticationProvider가 아래서 구현할 "CustomAuthProvider" 클래스이다. 

     

    🔨 4. AuthenticationProvider 은 UserDetailsService를 통해 입력받은 아이디에 대한 사용자 정보를 User(DB)에서 조회해온다. 

     

    🔨 5. User에 로그인 요청한 정보가 있는 경우 UserDetials로 꺼내서 유저 session을 생성한다. 

     

    🔨 6. 인증이 성공된 UsernameAuthenticationToken 을 생성하여 AuthenticationManager로 반환한다.

     

    🔨 7. AuthenticationManager는 UsernameAuthenticationToken을 AuthenticationFilter로 전달한다.

     

    🔨 8. AuthenticationFilter은 전달받은 UsernameAuthenticationToken을 LoginSuccessHandler로 전송하고, spring security 인메모리 세션 장소인 SecurityContextHolder에 저장한다.

     

     

     

    📑 예제 최종 패키지 구조

     

     

     

     

    📑 4. DB 구현

     

    CREATE TABLE MEMBERS(
        email VARCHAR(255) primary key,
        password VARCHAR(255) not null,
        role VARCHAR(20) default 'ROLE_USER' not null,
        name VARCHAR(255),
        nickname VARCHAR(255),
        mfCode VARCHAR(255),
        cellNo VARCHAR(255),
        joinDate DATE DEFAULT SYSDATE,
        updateDate DATE   
    );

     

    따로 primary key로 사용할 id 값을 만들지 않고 email을 기본키로 사용했다. 소셜 로그인을 하려면 email을 아이디로 사용하는경우가 많다고 한다. 

     

     

    📑 5. SecurityConfig 설정

     

     

    Spring Security의 모든 설정을 할 수 있는 핵심 클래스이다. 

    스프링 부트 2.7.0 이전까지는 WebSecurityConfigurerAdapter라는 추상클래스를 상속해서 처리하였지만 2.7.0 버전부터는 deprecated 되었으므로 주의해서 사용한다. 

     

    package com.wish.library.security.config;
    
    import com.wish.library.security.service.CustomUserService;
    import lombok.RequiredArgsConstructor;
    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.web.builders.HttpSecurity;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.security.web.SecurityFilterChain;
    import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
    import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
    
    import javax.sql.DataSource;
    
    @Configuration
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        private final DataSource dataSource;
        private final CustomUserService customUserService;
    
        @Bean
        PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
    
    
            http.csrf()
                    .disable();
    
            //권한에 따라 허용하는 url 설정
            http.authorizeRequests()
                    .antMatchers("/assets/**").permitAll()
                    .antMatchers("/","/login","/join").permitAll()
                    .antMatchers("/admin/**").hasRole("ADMIN")
                    .antMatchers("/member/**").hasRole("USER")
                    .anyRequest().authenticated(); // 나머지 요청들은 권한의 종류에 상관 없이 권한이 있어야만 접근 가능
    
            //login 설정
            http.formLogin()
                    .loginPage("/login") // GET 요청 (login form을 보여줌)
                    .loginProcessingUrl("/login") //POST 요청 (login 창에 입력한 데이터를 처리)
                    .usernameParameter("email") //login에 필요한 id 값을 email로 설정 (default는 username)
                    .passwordParameter("password") //login에 필요한 password 값을 password
                    .defaultSuccessUrl("/"); //login에 성공하면 /로 redirect
    
            //logout 설정
            http.logout()
                    .logoutUrl("/logout")
                    .logoutSuccessUrl("/")
                    .invalidateHttpSession(true);
    
            return http.build();
        }
    
    
    }

     

     

    🚩 .csrf().disable()

    : 만약 단순히 로그인 구현이 목적이라면 위의 설정을 한다. csrf는 요청자가 의도치않게 서버에 공격하는 것을 방지하는 token 검증 옵션이. get 요청의 경우 csrf 검증이 없지만 post, put, delete의 경우 csrf 검증을 하게된다. 만약 이 csrf를 사용하려면 <form>태그의 hidden 값으로 <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> 이 태그를 넣어주면 된다. 

     

     

    🚩 .authorizeRequests()

    : 경로에 권한, 인증 설정을 한다는 선언이다.

     

     

    🚩 .antMatchers("/admin/**").hasAnyRole("ADMIN")
    : /admin/**에 대한 요청은 ADMIN 권한을 가진 회원만 접근할 수 있다. 여기서 hasAnyRole("ROLE_ADMIN")으로 적었더니 에러가 발생했다. 기본적으로 스프링은 앞에 ROLE_을 붙여주기 때문에 ADMIN만 적어줘도 된다. 

     

     

     

    BcryptPasswordEncoder는 패스워드 인코더 객체를 생성하는 역할을 담당하는 클래스이다. 

    테스트 클래스를 만들어서 인코딩이 잘 되나 확인하고 넘어가는게 좋다. 

     

    package com.wish.library.security.mapper;
    
    import com.wish.library.security.domain.MemberSaveForm;
    import com.wish.library.security.domain.Sex;
    import com.wish.library.security.service.MemberService;
    import lombok.extern.slf4j.Slf4j;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.assertj.core.api.Assertions.*;
    
    @SpringBootTest
    @Slf4j
    
    class UserMapperTest {
    
        @Autowired
        private PasswordEncoder passwordEncoder;
    
        @Autowired
        private MemberService memberService;
    
        @Autowired
        private UserMapper userMapper;
    
    
        @Test
        public void 비밀번호_인코더_테스트(){
    
            //given
            String password = "admin";
            String enPw = passwordEncoder.encode(password);
            log.info("인코더한 비먼 ={}",enPw);
            //when,then
            boolean matchResult = passwordEncoder.matches(password, enPw);
            Assertions.assertThat(matchResult).isEqualTo(true);
        }
    }

     

     

     

     

     

     

     

     

     

     

     

     

     

    📑 6. AuthenticationProvider 

     

    package com.wish.library.security.config;
    
    import com.wish.library.security.domain.UserDTO;
    import com.wish.library.security.service.CustomUserService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.catalina.User;
    import org.springframework.context.annotation.Bean;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Component;
    
    @Component
    @RequiredArgsConstructor
    @Slf4j
    public class CustomAuthProvider implements AuthenticationProvider {
    
        private final CustomUserService userService;
        private final PasswordEncoder passwordEncoder;
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            String reqEmail = (String) authentication.getPrincipal().toString(); // 로그인 창에 입력한 email
            String reqPassword = (String) authentication.getCredentials().toString(); //로그인 창에 입력한 password
            UserDTO userDTO = (UserDTO)userService.loadUserByUsername(reqEmail);
            log.info("로그인시 조회해온 정보={}",userDTO.toString());
    
            //matches : 암호화되지 않은 비밀번호와 암호화된 비밀번호가 일치하는지 비교
            if(!passwordEncoder.matches(reqPassword, userDTO.getPassword())){
                log.info("비밀번호가 일치하지 않습니다.");
                throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
            }
    
            log.info("비밀번호 일치합니다. 로그인 완료 ");
            return new UsernamePasswordAuthenticationToken(reqEmail, null, userDTO.getAuthorities());
    
        }
    
        @Override
        public boolean supports(Class<?> authentication) {
            return true;
        }
    }

     

    🚩 return new UsernamePasswrodAuthenticationToken(user, null, getAuthorities())

     

    : UsernamePasswrodAuthenticationToken는 인증이 완료되면 SecurityContextHolder.getContext()에 등록되는 객체이다. SecurityContextHolder는 세션 저장소로써 인증된 정보를 담고 있고 이후 인증된 회원이 재요청시 회원 정보를 얻을 수 있다. 

     

     

    ★ 참고로 처음에 AuthenticationProvider를 오버라이딩할 경우 supports 클래스의 return 값이 false로 되어있다. 이 값을 true로 바꿔주는 것을 잊지 말자.

     

     

     

     

    📑 7. UserDetailsService (service)

     

    package com.wish.library.security.service;
    
    import com.wish.library.security.domain.UserDTO;
    import com.wish.library.security.mapper.UserMapper;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    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.Component;
    
    @Component
    @Slf4j
    @RequiredArgsConstructor
    public class CustomUserService implements UserDetailsService {
    
        private final UserMapper mapper;
    
        @Override
        public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
            log.info("=========== security UserDetailsService");
            UserDTO user = mapper.getOne(email);
            if(user==null) throw new UsernameNotFoundException("Not Found User");
            return user;
        }
    }

     

    UserDetailsService를 구현받지 않은 클래스를 AuthenticationProvider에서 사용할 수 있다. 그러나 나는 UserDetailsService 인터페이스를 구현하고 있는 CustomUserService를 생성하였다. 

     

     

     

    📑 8. UserDetails (UserDto)

     

    package com.wish.library.security.domain;
    
    import lombok.ToString;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.List;
    
    @ToString
    public class UserDTO implements UserDetails {
    
        private String email;
        private String password;
        private Role role;
        private String nickname;
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
            authorities.add(new SimpleGrantedAuthority(role.toString()));
            return authorities;
        }
    
        @Override
        public String getPassword() {
            return password;
        }
    
        public void setPassword(String password){
            this.password = password;
        }
    
        @Override
        public String getUsername() {
            return email;
        }
    
        //계정 만료 여부(true:만료되지 않음, false:만료됨)
        @Override
        public boolean isAccountNonExpired() {
            return false;
        }
    
        // 계정 잠금 여부(true: 계정잠금아님, false: 계정잠금상태)
        @Override
        public boolean isAccountNonLocked() {
            return false;
        }
    
        // 계정 패스워드 만료 여부(true: 만료되지 않음, false: 만료됨)
        @Override
        public boolean isCredentialsNonExpired() {
            return false;
        }
    
        // 계정 사용가능 여부(true: 사용가능, false: 사용불가능)
        @Override
        public boolean isEnabled() {
            return false;
        }
    
        public String getNickname() {
            return nickname;
        }
    
        public void setNickname(String nickname) {
            this.nickname = nickname;
        }
    }

     

    UserDetails 또한 따로 구현하지 않고 원래 사용하는 domain을 가져다 써도 된다. 그렇게 될 경우 getAuthorities() 메서드가 정의되어 있지 않기 때문에 아래처럼 AuthenticationProvider에서 사용할 수 있다.

     

    <UserDetails 클래스 구현할 경우 , AuthenticationProvider>

     

    <UserDetails 클래스 구현하지 않고 사용할 경우, AuthenticationProvider>

     

     

    📑 9. Controller

     

    package com.wish.library.security.controller;
    
    import com.wish.library.security.domain.MemberSaveForm;
    import com.wish.library.security.domain.UserDTO;
    import com.wish.library.security.service.CustomUserService;
    import com.wish.library.security.service.MemberService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.servlet.ModelAndView;
    
    /**
     * GET 방식, 화면 조회시
     */
    @Controller
    @RequiredArgsConstructor
    @Slf4j
    public class UserController {
    
        private final CustomUserService customUserService;
        private final MemberService memberService;
    
        @GetMapping("/")
        public String home(Model model){
            String email = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            if(!(email.equals("anonymousUser"))) {
                log.info("index page, security 조회 email : " + email);
                UserDTO user = (UserDTO) customUserService.loadUserByUsername(email);
                user.setPassword(null);
                model.addAttribute("user", user);
            }
            return "index";
        }
    
        @GetMapping("/login")
        public String login(){
            return "login";
        }
    
    }

     

    index인 메인 페이지에서는 SecurityContextHolder.getContext().getAuthentication().getPrincipal()를 사용해 스프링 시큐리티 세션에 저장되어 있는 email의 정보를 조회해온다. 만약 email이 있다면 model에 user의 정보를 저장한다. 

     

     

     

    📑 10. Mapper (Dao)

     

    package com.wish.library.security.mapper;
    
    import com.wish.library.security.domain.MemberSaveForm;
    import com.wish.library.security.domain.UserDTO;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface UserMapper {
         UserDTO getOne(String email);
    }

     

     

     

    📑 11. Mapper.xml

     

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.wish.library.security.mapper.UserMapper">
    
        <select id="getOne" resultType="UserDTO">
            select email,
                   password,
                   role,
                   nickname
            from members
            where email = #{email}
        </select>
    
    
    
    </mapper>

     

     

     

    📑 12. loginForm.jsp

     

    <%@ page language="java" contentType="text/html; charset=UTF-8"%>
    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    
    
    <!doctype html>
    <html lang="en" data-bs-theme="auto">
    <head>
    
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="description" content="">
        <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
        <meta name="generator" content="Hugo 0.111.3">
        <title>로그인</title>
    
        <link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/sign-in/">
        <link href="assets/dist/css/sign-in.css" rel="stylesheet">
        <jsp:include page="./includes/common_includes.jsp"></jsp:include>
    
    
    
        <style>
            @media (min-width: 768px) {
                .bd-placeholder-img-lg {
                    font-size: 3.5rem;
                }
            }
    
            .nav-scroller .nav {
                display: flex;
                flex-wrap: nowrap;
                padding-bottom: 1rem;
                margin-top: -1px;
                overflow-x: auto;
                text-align: center;
                white-space: nowrap;
                -webkit-overflow-scrolling: touch;
            }
    
    
        </style>
    
        <script>
            $(document).ready(function (){
                const result = '<c:out value="${result}" />';
                checkAlert(result);
    
                //회원
                function checkAlert(result){
                    console.log(result);
                    if(result === ''){
                        //해당 메서드를 아예 끝내버려라.
                        return;
                    }
    
                    alert(result);
    
                }
            })
        </script>
    </head>
    <body class="text-center">
    
    
    <main class="form-signin w-100 m-auto">
        <form action="/login" method="post">
            <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
            <img class="mb-4" src="assets/brand/bootstrap-logo.svg" alt="" width="72" height="57"
                 onclick="location.href='/'">
            <h1 class="h3 mb-3 fw-normal">Please sign in</h1>
    
            <div class="form-floating">
                <input type="email" class="form-control" name="email" id="email" placeholder="name@example.com">
                <label for="email">Email address</label>
            </div>
            <div class="form-floating">
                <input type="password" class="form-control" name="password" id="password" placeholder="Password" autoComplete="off">
                <label for="password">Password</label>
            </div>
    
            <div class="checkbox mb-3">
                <label>
                    <input type="checkbox" name="remember-me"> Remember me
                </label>
            </div>
            <button class="w-50 btn btn-lg btn-primary" type="submit">Sign in</button>
            <button class="w-50 btn btn-lg btn-light" onclick="location.href='/join'">Sign up</button>
        </form>
    </main>
    
    
    
    </body>
    </html>

     

     

     


     

    스프링 시큐리티 적용을 위해 하나씩 클래스를 만들어가고 설정하고 시간이 조금 걸리긴 했지만 구현해두고 나니 뿌듯하다. spring boot, jsp와 mybatis를 사용해서 구현하려는 분들은 참고하심 좋을 것 같다. 

     

     


    [참고]

     

     

    참고 1

    참고 2

    참고 3

    반응형

    댓글

Designed by Tistory.