스프링부트(Spring Boot) MyBatis 게시판 만들기4 - Spring Security 적용 [Mysql, DBeaver, IntelliJ, Maven]

예림·2024년 6월 7일
3
post-thumbnail

저번 포스팅
이번엔 Cookie로 사용자를 데이터를 받아올 때 보안 문제가 있으므로 이 점을 개선하기 위해 스프링 프레임워크에서 제공하는 Spring Security를 적용했다.

우선 pom.xml에 dependency를 추가해준 뒤, SecurityConfig에 접근 권한 설정을 해준다.

스프링 시큐리티


개발 환경

  • 언어: java

  • Spring Boot ver : 2.3.1.RELEASE

  • Mybatis : 2.3.0

  • IDE: intelliJ

  • SDK: JDK 17

  • 의존성 관리툴: Maven

  • DB: MySQL 8.0.12

  • pom.xml 설정 추가

    관련 dependency를 추가해준다. 아래는 Maven Project 기준으로 작성했다.

    <!--spring security-->
    <dependency>
    		<groupId>org.springframework.boot</groupId>
    		<artifactId>spring-boot-starter-security</artifactId>
    </dependency>

SecurityConfig.java →

package org.study.board.config;

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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/login", "/join").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/main", true)  // 로그인 성공 후 리다이렉트 설정
                .permitAll()
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/main")  // 로그아웃 성공 후 리다이렉트 설정
                .permitAll();
    }
}
  • 스프링 부트 2.3.1버전 기준으로는 정상 작동되지만, 최신 버전으로 업데이트 되면서 WebSecurityConfigurerAdapter 클래스를 더이상 사용할 수 없게 되었다고 한다.
  • 따라서 프로젝트 버전에 맞게 작성하면 될 것 같다.

CustomUserDetailsService.java →

package org.study.board.service;

import org.springframework.beans.factory.annotation.Autowired;
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.study.board.dto.User;
import org.study.board.repository.UserMapper;

import java.util.ArrayList;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        User user = mapper.findByLoginId(userId);
        if (user == null) {
            throw new UsernameNotFoundException("User not found");
        }
        return new org.springframework.security.core.userdetails.User(user.getUserId(), user.getPassword(), new ArrayList<>());
    }
}

실행 화면


  • /user/main 요청


    1. test(ADMIN) 로그인 → 관리자 권한o

    • /user/info/user
---

1. **user(USER) 로그인** → 일반 사용자 (관리자 권한x)
- 403 Forbidden 에러페이지
 
 



    

에러 해결


  • Spring Security 적용 방식이 버전마다 조금씩 달라서 헷갈렸다.

    • WebSecurityConfigurerAdapter → Spring Security 5.7.0-M2 버전부터 사용 중지됨
    • 필자는 이전 버전이기에 그대로 사용함
    • 이후 버전부터는 filterChain으로 대체
  • 사용자가 요청한 login페이지에서 다음 페이지로 넘어가지 않는 문제

    • csrf 비활성화로 해결
  • cookie에 로그인한 값이 저장되지 않는 문제

    • jsp에서 이전 쿠키로그인 방식의 데이터를 받아오고 있었어서 사용자 로그인 정보를 불러오는 방식을 수정했다.

    • 수정 코드 (board/main.jsp)

      <!-- 로그인 여부에 따라 버튼 표시 -->
      <div class="button-container">
          <c:choose>
              <c:when test="${pageContext.request.userPrincipal != null}">
                  <!-- 사용자가 로그인한 경우 -->
                  <input type="button" value="user 목록" onclick="location.href='/user/main'"><br/><br/>
                  <input type="button" value="글 작성" onclick="location.href='/write'"><br/><br/>
                  <input type="button" value="로그아웃" onclick="location.href='/logout'"><br/><br/>
              </c:when>
              <c:otherwise>
                  <!-- 사용자가 로그인하지 않은 경우 -->
                  <input type="button" value="로그인" onclick="location.href='/login'"><br/><br/>
                  <input type="button" value="회원가입" onclick="location.href='/join'"><br/><br/>
              </c:otherwise>
          </c:choose>
      </div>
    • Spring Security를 적용하면, UserController에서 작성한 기존 login 메소드가 무의미해진다.

  • 글 상세/작성 페이지에서 작성자(로그인한 사용자) 정보를 받아오지 못함

    • BoardController

      @RequestMapping("/write")
      public String write(@CookieValue(name="idx", required = false) Long idx, Model model, Board board){
              /*User loginUser=userMapper.findById(idx);
              model.addAttribute("user", loginUser);*/
      
              **// 수정 1**
              Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
              String username = authentication.getName();
              model.addAttribute("user", username);
              
              if(board.getBno()==null){
                  model.addAttribute("getBoard", board);
                  model.addAttribute("getFile", boardService.getFile(board));
              }
      
              return "board/write";
      }
      
      @RequestMapping("/insertBoard")
      public String insertBoard(@ModelAttribute Board board, @CookieValue(name="idx", required = false) Long idx, Model model) {
              /*User loginUser=userMapper.findById(idx);
              board.setWriter(loginUser.getUsername());
              model.addAttribute("user", loginUser);*/
              
              **// 수정 2**
              Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
              String username = authentication.getName();
              board.setWriter(username);
              // 모델에 사용자 정보 추가
              model.addAttribute("user", username);
              boardService.insertBoard(board);
              return "redirect:/main";
      }
    • board/write.jsp

      <tr width="90%">
                  <td width="10%" align="center">작성자</td>
                  <c:if test="${not empty board.writer}">
                      <td width="50%">${board.writer}</td>
                  </c:if>
                  <%--<td width="50%">${user.username}</td>--%>
                  <td width="50%">${user}</td> **// 수정 3**
      </tr>
  • Spring Security에서의 사용자 인증 상태 확인 방식
    • AuthInterceptor

      // 3. Spring Security를 이용한 사용자 인증 상태 확인
      Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
      
      // 사용자가 인증되어 있지 않은 경우 로그인 페이지로 리다이렉트
      if (authentication == null || !authentication.isAuthenticated()) {
              response.sendRedirect("/login");
              return false;
      }
      
      // 사용자가 인증되어 있으면 요청을 허용
              return true;
  • **접근 권한 403페이지 이슈!!**

    • SecurityConfig

      http.authorizeRequests()
                      .antMatchers("/login", "/join").permitAll()
                      .antMatchers("/user/**").hasRole("ADMIN") **// 권한 부여**
                      .anyRequest().authenticated()
    • 데이터베이스 수정 (USER table UPDATE)

      update board_study.user set role='ADMIN' where userId ='test';
      commit;
    • ‘test’라는 userId를 가진 사용자에게 ‘ADMIN’권한을 부여함

      그런데도 test로 로그인해서 /user/main 에 접근할 때도 똑같이 403에러가 뜬다??!

    • 권한 처리

      • 기본적으로 스프링에서는 따로 예외 처리를 하지 않았다면 예외 발생 시 500 에러가 발생한다. 그런데 스프링 시큐리티를 적용하면 메소드에서 예외가 발생했을 때 403 에러가 발생한다.

      • 심지어 존재하지 않는 URL로 접속하여 404 Not Found가 발생해야 하는 상황에서도 403 Forbidden이 발생한다.

      • 스프링 공식 블로그에 따르면 스프링부트에서는 에러가 발생하면 /error라는 URI로 매핑을 시도한다. 실제로 localhost:8080/error 링크로 이동하면 아래와 같은 페이지가 나타난다.

      • 일반적으로 permitAll()을 통해 모든 사용자의 접근을 허용할 URI에는 권한 검증이 필요하지 않은 URI만 추가한다.

      • 스프링부트 프로젝트에서 에러가 발생하면 /error로 매핑한다.

      • 그런데 /error는 모두에게 허용된 URI에 포함되지 않는다.

    • 이 문제는 anyRequest().authenticated()로 인해 /error도 인증이 필요한 것으로 간주되어 발생한 것이기 때문에 모두에게 허용할 URI 목록에 /error를 추가하면 단순하게 해결할 수 있다

      .antMatchers("/login", "/join", "/error").permitAll()

      그런데.. 아직도 403에러가 뜬다!

    • 그렇다면 이번에는 핸들러를 추가해보겠다.

      • aunthenticationEntryPoint 추가

        package org.study.board.config;
        
        import org.springframework.security.core.AuthenticationException;
        import org.springframework.security.web.AuthenticationEntryPoint;
        import org.springframework.stereotype.Component;
        
        import javax.servlet.RequestDispatcher;
        import javax.servlet.ServletException;
        import javax.servlet.http.HttpServletRequest;
        import javax.servlet.http.HttpServletResponse;
        import java.io.IOException;
        
        @Component
        public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
        
            @Override
            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                RequestDispatcher dispatcher = request.getRequestDispatcher("/error/401");
                dispatcher.forward(request, response);
            }
        }
      • accessDeniedHandler 추가

        package org.study.board.config;
        
        import org.springframework.security.access.AccessDeniedException;
        import org.springframework.security.web.access.AccessDeniedHandler;
        import org.springframework.stereotype.Component;
        
        import javax.servlet.RequestDispatcher;
        import javax.servlet.ServletException;
        import javax.servlet.http.HttpServletRequest;
        import javax.servlet.http.HttpServletResponse;
        import java.io.IOException;
        
        @Component
        public class CustomAccessDeniedHandler implements AccessDeniedHandler {
        
            @Override
            public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                RequestDispatcher dispatcher = request.getRequestDispatcher("/error/403");
                dispatcher.forward(request, response);
            }
        }
      • SecurityConfig 수정

        package org.study.board.config;
        
        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.WebSecurityConfigurerAdapter;
        import org.springframework.security.core.userdetails.UserDetailsService;
        import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
        import org.springframework.security.crypto.password.PasswordEncoder;
        import org.springframework.security.web.AuthenticationEntryPoint;
        import org.springframework.security.web.access.AccessDeniedHandler;
        
        @Configuration
        public class SecurityConfig extends WebSecurityConfigurerAdapter {
        
            @Autowired
            private UserDetailsService userDetailsService;
            @Autowired
            private AccessDeniedHandler accessDeniedHandler;
            @Autowired
            private AuthenticationEntryPoint authenticationEntryPoint;
        
            @Bean
            public PasswordEncoder passwordEncoder() {
                return new BCryptPasswordEncoder();
            }
        
            @Override
            protected void configure(AuthenticationManagerBuilder auth) throws Exception {
                auth.userDetailsService(userDetailsService)
                        .passwordEncoder(passwordEncoder());
            }
        
            @Override
            protected void configure(HttpSecurity http) throws Exception {
                http.authorizeRequests()
                        .antMatchers("/login", "/join").permitAll()
                        .antMatchers("/user/main").hasRole("ADMIN")
                        .antMatchers("/user/info/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                        .and()
                        .formLogin()
                        .loginPage("/login")
                        .loginProcessingUrl("/login")
                        .usernameParameter("loginId")  // 로그인 ID 필드 이름 설정
                        .passwordParameter("password") // 비밀번호 필드 이름 설정
                        .defaultSuccessUrl("/main", true)  // 로그인 성공 후 리다이렉트 설정
                        .permitAll()
                        .and()
                        .logout()
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/main")  // 로그아웃 성공 후 리다이렉트 설정
                        .permitAll()
                        .and()
                        .exceptionHandling()
                        .accessDeniedHandler(accessDeniedHandler)  // Custom AccessDeniedHandler 등록
                        .authenticationEntryPoint(authenticationEntryPoint)  // Custom AuthenticationEntryPoint 등록
                        .and()
                        .csrf().disable() // csrf 비활성화
                        .rememberMe();
            }
        }
      • 에러페이지 생성

        error/401
        
        ```java
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <title>401 Unauthorized</title>
        </head>
        <body>
        <h1>401 Unauthorized</h1>
        <p>You need to log in to access this page.</p>
        </body>
        </html>
        
        ```
        
        error/403
        
        ```java
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <title>403 Forbidden</title>
        </head>
        <body>
        <h1>403 Forbidden</h1>
        <p>You do not have permission to access this page.</p>
        </body>
        </html>
        ```

        여전히 403이 뜬다. 권한 설정 자체가 잘못된 건가?

    • 그리고 나서는 이것저것 바꿔보다 보니 실행됐다

      • 데이터베이스 수정 —> 해결된 이유 유력함
        • user테이블에서 role컬럼의 데이터 값들에 ROLE_을 붙여줬다. ex) USERROLE_USER 이런식

        • 아마 데이터베이스에서 role을 정할 때 이러한 규칙으로 정해야 인식하는 것 같다.

    - **User** implements ****UserDetails**
        
        ```java
        package org.study.board.dto;
        
        import lombok.AllArgsConstructor;
        import lombok.Builder;
        import lombok.Data;
        import lombok.NoArgsConstructor;
        import org.springframework.security.core.GrantedAuthority;
        import org.springframework.security.core.authority.SimpleGrantedAuthority;
        import org.springframework.security.core.userdetails.UserDetails;
        
        import java.sql.Timestamp;
        import java.util.ArrayList;
        import java.util.Collection;
        
        @Data
        @AllArgsConstructor
        @NoArgsConstructor
        @Builder
        public class User implements UserDetails { **//수정**
        
            private Long idx;
            private String userId;
            private String password;
            private String username;
            private Timestamp regdate;
            private String role;
        
            @Override
            public Collection<? extends GrantedAuthority> getAuthorities() {
                Collection<GrantedAuthority> authorities = new ArrayList<>();
        
                for(String role : role.split(",")){
                    authorities.add(new SimpleGrantedAuthority(role));
                }
                return authorities;
            }
        
            @Override
            public boolean isAccountNonExpired() {
                return true;
            }
        
            @Override
            public boolean isAccountNonLocked() {
                return true;
            }
        
            @Override
            public boolean isCredentialsNonExpired() {
                return true;
            }
        
            @Override
            public boolean isEnabled() {
                return true;
            }
        }
        ```
        
    - **SecurityConfig**
        
        ```java
        @Configuration
        public class SecurityConfig extends WebSecurityConfigurerAdapter {
        
            @Autowired
            private CustomUserDetailsService userDetailsService; **//수정**
            @Autowired
            private AccessDeniedHandler accessDeniedHandler;
            @Autowired
            private AuthenticationEntryPoint authenticationEntryPoint;
        ```
        
    

회고


  • Spring Security 사용이 처음이기도 하고 직접 설정해서 사용하는 방식이라 그런지 검색도 정말 많이 해봤고 시간도 오래 걸렸지만, 사용 방법만 익힌다면 편리한 기능들을 많이 제공해주는 프레임워크라는 생각이 들었다.
  • 아직도 Spring Security의 내부 구조와 동작 방식에 대해 이해하기 어려운 부분들이 많지만 여러 프로젝트에서 직접 사용해보며 알아가려고 한다.
profile
백엔드 개발하는 사람

2개의 댓글

comment-user-thumbnail
2024년 6월 11일

핵심만 정리 잘 해주셔서 참고 잘 했습니다 잘 읽고 갑니다!

1개의 답글