Spring Security 예제와 함께 이해하기

Doyeon·2023년 2월 18일
0
post-thumbnail

🔐 로그인 기능이 포함된 게시판 백엔드 서버를 만들면서, 회원인 경우에만 게시글 작성, 수정, 삭제를 할 수 있도록 JWT를 활용하여 인증/인가를 구현했다.
로그인을 성공하면, 성공한 사용자의 정보와 JWT를 활용하여 토큰을 발급했고 Header에 토큰을 추가해서 Client에게 반환했다.
Client는 게시글을 작성, 수정, 삭제할 때 request header에 토큰을 넣어 요청을 보내고, Service 에서 토큰을 검증하고, 토큰으로부터 사용자 정보를 가져와 회원DB에 사용자가 있는지 찾는 과정을 반복했다.
그런데 서비스에 있는 게시글 작성 메서드, 게시글 수정 메서드, 게시글 삭제 메서드는 작성, 수정, 삭제라는 로직만 구현하는게 아니라, 인증/인가 관련(토큰 검증과 회원DB 조회) 로직을 함께 수행하고 있다.
인증/인가 관련 코드가 메서드마다 반복되고 있으며, 하나의 메서드에서 두 개 이상의 로직을 구현하고 있어서 객체지향과 거리가 생겼다.
인증/인가 부분을 따로 처리할 수 있는 방법이 없을까?
바로 Spring Security 프레임워크를 사용하면 이 고민을 해결할 수 있다.
지금부터 차근차근 Spring Security를 적용해보자!

Spring Security란?

  • Spring 기반의 애플리케이션 보안(인증과 권한, 인가)을 담당하는 스프링 하위 프레임워크이다.
  • 스프링 서버에 필요한 인증, 인가를 위해 많은 기능을 제공한다.

Spring Security 주요 컴포넌트

  • Filter

    • 톰캣과 같은 웹 컨테이너에서 관리되는 서블릿의 기술이다.
    • Client 요청이 전달되기 전후의 URL 패턴에 맞는 요청에 필터링을 해준다.
    • Spring Security는 요청이 들어오면 Filter를 chain 형태로 묶어놓은 형태인 Servlet FilterChain을 자동으로 구성한 후 거치게 한다.
  • SecurityFilterChain

    • Spring의 보안 Filter를 결정하는데 사용되는 Filter이다.

    • session, jwt 등 인증방식을 사용할 때 필요한 설정을 서비스 로직 구현으로부터 분리할 수 있는 환경을 제공한다.

    • SecurityFilterChain에는 여러 개의 Security Filter들이 있는데, 우선 UsernamePasswrodAuthenticationFilter 만 살펴볼 예정이다.

  • UsernamePasswordAuthenticationFilter

    • Form Login 기반에서 username과 password를 확인하여 인증한다.
    • 인증이 필요한 URL 요청이 들어왔을 때 인증이 되지 않았다면 로그인페이지를 반환한다.
  • SecurityContextHolder

    • SecurityContextHolder : Spring Security로 인증한 사용자의 상세 정보를 저장한다.
    • SecurityContext : SecurityContextHolder로 접근할 수 있으며, Authentication 객체를 갖는다.
  • Authentication

    • 현재 인증된 사용자를 나타내며, SecurityContext 에서 가져올 수 있다.
    • principal : 사용자를 식별한다. username/password 방식으로 인증할 때 보통 UserDetails 인스턴스다.
    • credentials : 주로 비밀번호 정보이다. 대부분 사용자 인증에 사용한 다음 비운다.
    • authorities : 사용자에게 부여한 권한을 GrantedAuthority 로 추상화하여 사용한다.
  • UserDetailsService

    • username/password 인증방식을 사용할 때, 사용자를 조회하고 검증한 후 UserDetails를 반환한다.
    • 커스텀하여 Bean으로 등록 후 사용할 수 있다.
  • UserDetails

    • 검증된 UserDetails는 UsernamePasswordAuthenticationToken 타입의 Authentication 을 만들 때 사용된다.
    • 이 인증 객체는 SecurityContextHolder 에 세팅된다.
    • 커스텀하여 사용할 수 있다.

Spring Security 적용해보기

프로젝트 환경 : springboot 2.7.8, java 17

  • build.gradle에 Spring Security 프레임워크 추가

    // 스프링 시큐리티
    implementation 'org.springframework.boot:spring-boot-starter-security'
  • Spring Security 활성화 → WebSecurityConfig 추가

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @EnableGlobalMethodSecurity(securedEnabled = true)
    public class WebSecurityConfig {
    
        private final JwtUtil jwtUtil; // Jwt를 사용할 것이므로 선언
    
        @Bean  // 비밀번호 암호화
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public WebSecurityCustomizer webSecurityCustomizer() {
            // h2-console 사용 및 resources 접근 허용 설정
            return (web) -> web.ignoring()
                    .requestMatchers(PathRequest.toH2Console())
                    .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
        }
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http.csrf().disable();
    
            // 기본 설정인 Session 방식 사용하지 않고 JWT 방식 사용
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    
            http.authorizeRequests().antMatchers("/api/user/**").permitAll()
                    .antMatchers(HttpMethod.GET, "/api/posts").permitAll()
                    .antMatchers(HttpMethod.GET, "/api/post/{id}").permitAll()
                    .anyRequest().authenticated()
                    .and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
    
            http.formLogin().permitAll();
    
            return http.build();
        }
    }
    • jwtUtil : 토큰 생성, 토큰 검증, 토큰에서 사용자 정보 가져오기 기능이 구현된 클래스
    • 회원가입, 로그인 (/api/user/**) 인증 부분은 서비스에서 구현할 것이므로 인증을 허용한다.
    • 게시글 조회는 인증이 필요 없으므로 permitAll 한다.
    • addFilterBefore : UsernamePasswordAuthenticationFilter 필터 전에 JwtAuthFilter(jwtUtil) 을 거친다.
  • JwtAuthFilter : Jwt 사용을 위해 커스텀한 시큐리티 필터

    @Slf4j
    @RequiredArgsConstructor
    public class JwtAuthFilter extends OncePerRequestFilter {
    
        private final JwtUtil jwtUtil;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // request 에 담긴 토큰을 가져온다.
            String token = jwtUtil.resolveToken(request);
    
            // 토큰이 null 이면 다음 필터로 넘어간다.
            if (token == null) {
                filterChain.doFilter(request, response);
                return;
            }
    
            // 토큰이 유효하지 않으면 예외처리
            if (!jwtUtil.validateToken(token)) {
                jwtExceptionHandler(response, ErrorType.NOT_VALID_TOKEN);
                return;
            }
    
            // 유효한 토큰이라면, 토큰으로부터 사용자 정보를 가져온다.
            Claims info = jwtUtil.getUserInfoFromToken(token);
            setAuthentication(info.getSubject());   // 사용자 정보로 인증 객체 만들기
    
            // 다음 필터로 넘어간다.
            filterChain.doFilter(request, response);
    
        }
    
        private void setAuthentication(String username) {
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            Authentication authentication = jwtUtil.createAuthentication(username); // 인증 객체 만들기
            context.setAuthentication(authentication);
    
            SecurityContextHolder.setContext(context);
        }
    
        // 토큰에 대한 오류가 발생했을 때, 커스터마이징해서 Exception 처리 값을 클라이언트에게 알려준다.
        public void jwtExceptionHandler(HttpServletResponse response, ErrorType error) {
            response.setStatus(error.getCode());
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");
            try {
                String json = new ObjectMapper().writeValueAsString(new MessageResponseDto(error.getMessage(), error.getCode()));
                response.getWriter().write(json);
            } catch (Exception e) {
                log.error(e.getMessage());
            }
        }
    
    }
    • request에 담긴 토큰을 가져와서 검증하고, 사용자 정보를 가져온다. 가져온 사용자 정보로 인증 객체를 만든 후, 다음 필터로 넘어간다.
    • setAuthentication : 인증 객체 (authentication)를 만들어 securityContext 안에 넣은 후, SecurityContextHolder 안에 넣는다.
    • SeccurityContextHolder 안에 Spring Security로 인증한 사용자의 상세정보가 담긴 인증 객체가 만들어졌다면, 다음 필터로 넘어간다.
    • 만약 토큰이 유효하지 않다면 jwtExceptionHandler 를 통해 예외처리한다.
  • JwtUtil

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class JwtUtil {
    
        private final UserDetailsServiceImpl userDetailsService;
    
    		// header 토큰 가져오기
    		// token 생성
    		// token 검증
    		// token에서 사용자 정보 가져오기
    	
    		// ...	
    
        // 인증 객체 생성
        public Authentication createAuthentication(String username) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        }
    }
    • createAuthentication : userDetails를 사용해서 UsernamePasswordAuthenticationToken 타입의 Authentication 을 만든다.
  • UserDetailsServiceImpl

    @Service    // custom 하고 Bean 으로 등록 후 사용 가능
    @RequiredArgsConstructor
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        private final UserRepository userRepository;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            User user = userRepository.findByUsername(username)
                    .orElseThrow(() -> new UsernameNotFoundException(ErrorType.NOT_FOUND_USER.getMessage()));   // 사용자가 DB 에 없으면 예외처리
    
            return new UserDetailsImpl(user, user.getUsername());   // 사용자 정보를 UserDetails 로 반환
        }
    }
    • loadUserByUsername : 사용자가 DB에 있는지 찾고, 있다면 UserDetailsImpl 에 사용자 객체를 담아서 리턴한다.
  • UserDetailsImpl

    public class UserDetailsImpl implements UserDetails {
    
        private final User user;
        private final String username;
    
        // 인증이 완료된 사용자 추가하기
        public UserDetailsImpl(User user, String username) {
            this.user = user;
            this.username = username;
        }
    
        public User getUser() {
            return user;
        }
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            UserRoleEnum role = user.getRole();
            String authority = role.getAuthority();
    
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
            Collection<GrantedAuthority> authorities = new ArrayList<>();   // 사용자 권한을 GrantedAuthority 로 추상화
            authorities.add(simpleGrantedAuthority);
    
            return authorities; // GrantedAuthority 로 추상화된 사용자 권한 반환
        }
    
        @Override
        public String getPassword() {
            return null;
        }
    
        @Override
        public String getUsername() {
            return this.username;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return false;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return false;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return false;
        }
    
        @Override
        public boolean isEnabled() {
            return false;
        }
    }
    • 인증 완료된 사용자 정보를 담는다.
    • getAuthorities : 사용자 권한을 GrantedAuthority로 추상화한다.
  • BoardController

    @RestController
    @RequiredArgsConstructor
    public class BoardController {
    
        private final BoardService boardService;
    
        // 게시글 작성
        @PostMapping("/api/post")
        public ResponseEntity<BoardResponseDto> createPost(@RequestBody BoardRequestsDto requestsDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
            return boardService.createPost(requestsDto, userDetails.getUser());
        }
    
        // 선택된 게시글 조회
        @GetMapping("/api/post/{id}")
        public ResponseEntity<BoardResponseDto> getPost(@PathVariable Long id) {
            return boardService.getPost(id);
        }
    
    }
    • @AuthenticationPrincipal : SecurityContextHolder 안에 인증된 사용자의 객체 정보에서 principal 부분을 가져올 수 있다.
    • Client에서 보낸 API 요청이 Controller까지 도착했다는 것은, Spring Security에서 사용자에 대한 인증이 완료되었음을 의미한다.
    • 서비스의 메서드를 호출할 때, userDetails.getUser() 로 인증된 사용자 객체를 바로 넘길 수 있다.
  • BoardService

    @Service
    @RequiredArgsConstructor
    public class BoardService {
    
        private final BoardRepository boardRepository;
    
        // 게시글 작성
        @Transactional
        public ResponseEntity<BoardResponseDto> createPost(BoardRequestsDto requestsDto, User user) {
    
            // 작성 글 저장
            Board board = boardRepository.save(Board.builder()
                    .requestsDto(requestsDto)
                    .user(user)
                    .build());
    
            // BoardResponseDto 변환
            BoardResponseDto responseDto = BoardResponseDto.builder()
                    .entity(board)
                    .build();
            // responseEntity 로 반환
            return ResponseEntity.ok(responseDto);
    
        }
    
        // 선택된 게시글 조회
        @Transactional(readOnly = true)
        public ResponseEntity<BoardResponseDto> getPost(Long id) {
            // Id에 해당하는 게시글이 있는지 확인
            Optional<Board> board = boardRepository.findById(id);
            if (board.isEmpty()) { // 해당 게시글이 없다면
                throw new RestApiException(ErrorType.NOT_FOUND_WRITING);
            }
    
            // 댓글리스트 작성일자 기준 내림차순 정렬
            board.get()
                    .getCommentList()
                    .sort(Comparator.comparing(Comment::getModifiedAt)
                            .reversed());
    
            BoardResponseDto responseDto = BoardResponseDto.builder()   // DTO 로 변환
                    .entity(board.get())
                    .build();
    
            // ResponseEntity body 에 dto 담아 리턴
            return ResponseEntity.ok(responseDto);
        }
    
    }
    • 이제 서비스에서 토큰을 검증하고, 사용자를 찾는 로직은 구현하지 않아도 된다.
    • 메서드에서 이미 인증된 사용자 객체를 전달받았기 때문에 서비스에서 리포지토리를 거치지 않고 사용자 객체를 사용할 수 있다.

그림으로 정리하기

profile
🔥

0개의 댓글