Spring Security 적용

최민길(Gale)·2023년 1월 16일
5

Spring Boot 적용기

목록 보기
11/46

안녕하세요 오늘은 Spring Security 적용기에 대해서 포스팅해보도록 하겠습니다.

우선 로그인 방식에는 크게 세션을 이용한 방식과 jwt를 이용한 방식이 있습니다.


출처 : https://velog.io/@gusdnr814/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-4%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95

세션 방식의 경우 로그인을 통해 사용자를 확인한 후 회원 정보를 세션에 저장한 후 이와 연결되는 세션 ID를 발급합니다. 이후 클라이언트는 매 요청마다 header에 세션 ID가 담긴 쿠키를 넣어 통신하는 방식으로 인증을 진행합니다. 세션 방식의 경우 쿠키 자체에는 유의미한 값을 가지고 있지 않기 때문에 안전하며 일일이 회원 정보를 확인할 필요가 없기 때문에 서버 자원에 접근하기 용이합니다. 하지만 쿠키가 탈취되었을 경우 정보를 잘못 전달할 수 있는 위험성이 있으며, 서버에 세션 저장소를 필요로 하기 때문에 추가적인 저장 공간이 필요하다는 단점이 있습니다.


출처 : https://velog.io/@gusdnr814/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-4%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95

jwt(JSON Web Token)는 인증에 필요한 정보들이 암호화된 토큰이며 엑세스 토큰과 리프레시 토큰으로 사용됩니다. 사용자가 로그인을 진행하면 계정 정보를 확인한 후 jwt 형식의 엑세스 토큰을 발급하여 건네주면 매 요청마다 헤더에 엑세스 토큰을 실어 보내는 방식으로 인증을 진행합니다. jwt 방식은 추가 저장소 없이 간편하게 인증할 수 있는 장점이 있으나 토큰이 탈취되었을 시 토큰의 유효 기간이 만료되기 전까지 대처할 방법이 없다는 단점이 있습니다.


출처 : https://velog.io/@gusdnr814/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-4%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95

이를 극복하기 위해 리프레시 토큰을 이용합니다. 엑세스 토큰의 유효 기간을 적게 하여 진행하며 엑세스 토큰의 유효 기간이 만료되었을 경우 리프레시 토큰을 서버에 요청하여 새롭게 엑세스 토큰을 발급받습니다. 이를 통해 해커가 엑세스 토큰을 탈취하더라도 유효 기간이 짧아 사용 기간이 짧으며, 리프레시 토큰은 안전한 저장소에 저장되기 때문에 기존 방식보다 안전합니다. 하지만 구현이 복잡하며 엑세스 토큰이 만료될 때마다 새롭게 발급하기 때문에 서버의 자원 낭비가 발생합니다.


출처 : https://velog.io/@gusdnr814/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-4%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95

SNS 로그인 등에서 자주 사용하는 OAuth 방식도 존재합니다. OuAuth 방식의 경우 클라이언트가 서버에 인증 요청을 진행하면 서버는 권한 증서를 구글, 네이버 등의 서드 파티 서버에 전달합니다. 서드 파티 서버에서는 권한 내용을 검증한 후 리프레시 토큰을 전달한 후 서버에서는 리프레시 토큰을 이용하여 엑세스 토큰을 발급받아 통신하는 방식으로 인증을 진행합니다. 엑세스 토큰이 만료되면 서버는 이를 확인하여 새로운 엑세스 토큰을 받급합니다.


출처 : https://blog.javabom.com/minhee/session/spring-security-1/spring-security

이번 실습에서는 Spring Security를 이용하여 jwt 방식의 엑세스 토큰을 발급받아 테스트를 진행해보도록 하겠습니다. Spring Security란 Spring 기반 에플리케이션의 인증, 권한, 인가 등을 담당하는 Spring 하위 프레임워크입니다. 위의 그림에서 보시는 것처럼 필터 기반으로 동작하며 DispathcerServlet을 호출하기 전에 처리됩니다. 이번 포스팅의 경우 인프런의 무료 강의(https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard)를 참고하여 진행하였습니다.

    // Spring Security
    implementation "org.springframework.boot:spring-boot-starter-security"
    implementation "org.springframework.security:spring-security-test"

    // JWT
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

우선 build.gradle에 다음의 dependency들을 추가해줍니다.

jwt.header=Authorization
jwt.secret={토큰 인코딩 키}
jwt.token-validity-in-seconds=86400

이후 application.properties에 위의 값들을 추가해줍니다. header의 경우 데이터 통신 시 헤더 패킷의 이름이고 secret의 경우 토큰을 인코딩할 때의 키, token-validity-in-seconds는 토큰 만료 시간입니다. 이 때 SignatureAlgorithm.HS512 알고리즘을 사용할 예정이기 때문에 512자 이상의 값으로 설정해주시면 됩니다. (만약 작다면 에러를 반환합니다.)

package com.example.test.jwt;

// 토큰의 생성, 토큰의 유효성 검증 등을 담당

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Component
public class TokenProvider implements InitializingBean {

    private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private static final String AUTHORITIES_KEY = "auth";

    private final String secret;
    private final long tokenValidityInMilliseconds;
    private Key key;

    // 의존성 주입
    public TokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds
    ){
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
    }

    // Bean이 생성이 되고 주입을 받은 후에 secret값을 Base64로 Decode 해서 key 변수에 할당
    @Override
    public void afterPropertiesSet() {

        System.out.println("afterPropertiesSet");

        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // Authentication 객체의 권한 정보를 이용해서 토큰을 생성
    public String createToken(Authentication authentication){

        System.out.println("createToken");

        // authorities 설정
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        // 토큰 만료 시간 설정
        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY,authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    // 토큰에 담겨있는 정보를 이용해 Authentication 객체 리턴
    public Authentication getAuthentication(String token){

        System.out.println("getAuthentication");

        // 토큰을 이용하여 claim 생성
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        // claim을 이용하여 authorities 생성
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // claim과 authorities 이용하여 User 객체 생성
        User principal = new User(claims.getSubject(), "", authorities);

        // 최종적으로 Authentication 객체 리턴
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // 토큰의 유효성 검증 수행
    public boolean validateToken(String token){

        System.out.println("validateToken");

        // 토큰 파싱 후 발생하는 예외 캐치하여 문제 있으면 false, 정상이면 true 리턴
        try{
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        }
        catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { logger.info("잘못된 JWT 토큰 서명"); }
        catch (ExpiredJwtException e) { logger.info("만료된 JWT 토큰"); }
        catch (UnsupportedJwtException e) { logger.info("지원되지 않는 JWT 토큰"); }
        catch (IllegalArgumentException e) { logger.info("잘못된 JWT 토큰"); }
        return false;
    }
}

토큰의 생성, 토큰의 유효성 검증 등을 담당하는 TokenProvider 클래스를 생성합니다. InitializingBean를 implements하여 afterPropertiesSet 메소드를 오버라이드하여 Bean이 생성이 되고 주입을 받은 후에 secret값을 Base64로 Decode해서 key 변수에 할당합니다.

package com.example.test.jwt;

// JWT를 위한 커스텀 필터

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;

public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private final TokenProvider tokenProvider;

    public JwtFilter(TokenProvider tokenProvider){
        this.tokenProvider = tokenProvider;
    }

    // 실제 필터링 로직 작성
    // doFilter : 토큰의 인증 정보를 SecurityContext에 저장
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        System.out.println("doFilter");

        // resolveToken을 통해 토큰을 받아옴
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        // 토큰 유효성 검증 후 정상이면 SecurityContext에 저장
        if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)){
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}",authentication.getName(),requestURI);
        }

        else logger.debug("유효한 JWT 토큰이 없습니다, uri: {}",requestURI);

        // 생성한 필터 실행
        chain.doFilter(httpServletRequest,response);
    }

    // Request Header에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request){
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){

            System.out.println("token : " + bearerToken);

            return bearerToken.substring(7);
        }
        return null;
    }
}

다음으로 JWT를 위한 커스텀 필터인 JwtFilter 클래스를 생성합니다. GenericFilterBean를 extends 하여 doFilter 메소드를 오버라이딩하여 토큰의 인증 정보를 SecurityContext에 저장합니다. 여기서 주의할 점은, doFilter 마지막에 chain.doFilter 메소드를 실행시켜야 한다는 점입니다. 이 부분에 대한 자세한 설명은 밑에서 이어서 진행하겠습니다.

package com.example.test.jwt;

import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// TokenProvider, JwtFilter를 SecurityConfig에 적용할 떄 사용
// SecurityConfigurerAdapter를 extends

public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>{
    private final TokenProvider tokenProvider;

    // TokenProvider를 주입
    public JwtSecurityConfig(TokenProvider tokenProvider){
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) {

        System.out.println("configure");

        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

위에 생성한 TokenProvider를 주입받아서 JwtFilter를 필터 실행 이전에 실행하도록 설정하는 JwtSecurityConfig 클래스를 생성합니다.

package com.example.test.jwt;

// 유효한 자격 증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러 리턴

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
package com.example.test.jwt;

// 필요한 권한이 존재하지 않는 경우 403 Forbidden 에러 리턴

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

위의 코드들은 예외 처리를 위한 클래스입니다. 아래에서 설명할 SpringConfig 클래스에서 예외 처리 시 사용됩니다.

package com.example.test.config;

import com.example.test.jwt.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity // Spring Security 활성화
@EnableMethodSecurity // @PreAuthorize 어노테이션 메소드 단위로 추가하기 위해 적용 (default : true)
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    // TokenProvider,JwtAuthenticationEntryPoint,JwtAccessDeniedHandler 의존성 주입
    public SecurityConfig(
            TokenProvider tokenProvider,
            JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
            JwtAccessDeniedHandler jwtAccessDeniedHandler
    ){
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    // 비밀번호 암호화
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                // Spring Security should completely ignore URLs starting with /resources/
                .requestMatchers("/resources/**");
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http
                // 토큰을 사용하기 때문에 csrf 설정 disable
                .csrf().disable()

                // 예외 처리 시 직접 만들었던 클래스 추가
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // 세션 사용하지 않기 때문에 세션 설정 STATELESS
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 토큰이 없는 상태에서 요청이 들어오는 API들은 permitAll
                .and()
                .authorizeHttpRequests()
                .requestMatchers("/login").permitAll()
                .anyRequest().authenticated()

                // JwtFilter를 addFilterBefore로 등록했던 jwtSecurityConfig 클래스 적용
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }
}

실질적으로 Spring Security에서 가장 중요한 SpringConfig 클래스입니다. @Configuration 설정과 함께 @EnableWebSecurity 어노테이션을 통해 Spring Security를 활성화하고, 유저 권한에 따라 접근 가능한 메소드를 제한할 때 사용하는 @PreAuthorize 어노테이션을 메소드 단위로 추가하기 위해 @EnableMethodSecurity 어노테이션도 추가압니다. 위에서 언급한 영상에서와 달리 WebSecurityConfigurerAdapter가 Deprecated 되어(Spring boot 3.0.1, Spring Security 6.0.1) SecurityFilterChain 객체를 직접 구현하여 @Bean을 주입하는 방식으로 구현하였습니다. 여기서 중요한 점은 .apply를 통해 JwtSecurityConfig 내부에 있는 JwtFilter가 addFilterBefore 설정으로 인해 다른 옵션보다 먼저 실행이 되는데, 이 때 chain.doFilter 메소드가 없다면 SpringConfig 내에서 설정한 옵션들이 실행되지 않고 동작합니다. 따라서 토큰이 없는 상태에서 요청이 들어올 경우 permitAll 처리를 통해 정상적으로 실행되어야 할 로그인 API가 doFilter 과정에서 토큰이 없어 실행되지 않으며, authenticated 처리한 요청들은 요청에 대한 permit이 없어 401 에러를 반환합니다.

여기까지 설정하시면 Spring Security 설정이 완료됩니다. 그럼 이어서 로그인 테스트를 진행해보겠습니다.

package com.example.test.controller;

import com.example.test.jwt.JwtFilter;
import com.example.test.jwt.TokenProvider;
import com.example.test.model.request.LoginRequestDTO;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.HttpHeaders;

@RestController
public class AuthController {

    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public AuthController(
            TokenProvider tokenProvider,
            AuthenticationManagerBuilder authenticationManagerBuilder
    ){
        this.tokenProvider = tokenProvider;
        this.authenticationManagerBuilder = authenticationManagerBuilder;
    }

    @PostMapping("/login")
    public String getLoginToken(@RequestBody LoginRequestDTO loginRequestDTO) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginRequestDTO.getEmail(), loginRequestDTO.getPassword());

        System.out.println(authenticationToken);

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = tokenProvider.createToken(authentication);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER,"Bearer " + jwt);

        return jwt;
    }
}

위의 코드는 로그인 요청을 담당하는 컨트롤러입니다. UsernamePasswordAuthenticationToken 객체에 로그인 시 입력하는 키값(여기서는 이메일과 비밀번호입니다)을 넣습니다. UsernamePasswordAuthenticationToken은 아이디 값의 Principal과 비밀번호 값의 Credentials로 이루어져 있습니다. 이 객체를 authenticationManagerBuilder.getObject().authenticate 메소드로 실행시키면 아래에서 언급할 CustomUserDetailsService로 이동합니다.

package com.example.test.service;

import com.example.test.model.dao.UserDAO;
import com.example.test.repository.AuthRepository;
import org.springframework.security.core.authority.AuthorityUtils;
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.Component;
import org.springframework.transaction.annotation.Transactional;

@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {

    private final AuthRepository authRepository;

    public CustomUserDetailsService(AuthRepository authRepository){
        this.authRepository = authRepository;
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDAO userDAO = authRepository.getUserInfo(username);
        UserDetails userDetails = (UserDetails)new User(
                userDAO.getEmail(), userDAO.getPassword(), AuthorityUtils.createAuthorityList("USER")
        );

        System.out.println(userDetails);

        return userDetails;
    }


}

위에서 언급한 authenticationManagerBuilder.getObject().authenticate 메소드가 실행되면 UserDetailsService를 implements했을 때 오버라이딩하는 loadUserByUsername 메소드가 실행됩니다. 여기서 repository를 이용하여 DB에서 검증을 진행하고 결과값을 UserDetails 객체로 반환합니다. UserDetails 객체는 Username과 password 변수가 존재하며 이렇게 리턴된 Username 객체와 UsernamePasswordAuthenticationToken 객체와 비교하여 authentication을 생성하고 이를 SecurityContext에 저장하여 인증이 진행됩니다.

package com.example.test.controller;

import com.example.test.model.request.LoginRequestDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@SpringBootTest
class AuthControllerTest {
    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @BeforeEach
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .build();
    }

    @Test
    public void getUserInfoTest() throws Exception{
        String body = new ObjectMapper().writeValueAsString(
                new LoginRequestDTO({이메일},{비밀번호})
        );

        mvc.perform(post("/login")
                        // Mockmvc에 바디 데이터 추가
                        .content(body)
                        // 받을 데이터 타입 설정 --> JSON으로 받기 때문에 해당 설정 ON
                        .contentType(MediaType.APPLICATION_JSON)
                )
                .andExpect(status().isOk());
    }
}

구현이 완료되었다면 단위 테스트를 진행해보겠습니다. LoginRequestDTO 내에 잘못된 비밀번호를 입력하면 위와 같이 Bad credentials 에러, 즉 잘못된 비밀번호를 입력했다는 에러가 나타납니다.

하지만 정상적인 비밀번호를 입력하면 위와 같이 테스트가 정상적으로 작동하는 것을 알 수 있습니다.

package com.example.test.controller;

import com.example.test.model.request.InsertUserRequestDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@SpringBootTest
class UserControllerTest {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @BeforeEach
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }
    
    @Test
    @WithMockUser
    public void getUserInfoTest() throws Exception{
        mvc.perform(get("/user/1"))
                .andExpect(status().isOk());
    }
}

이어서 로그인 토큰 여부에 따른 API 단위 테스트를 진행해보겠습니다. org.springframework.security:spring-security-test dependency를 추가하면 springSecurity() 메소드를 사용할 수 있는데 이를 setup 단계에 넣어 Spring Security 설정을 진행합니다. 또한 @WithMockUser를 이용하여 Spring Security 인증을 통과한 상태로 테스트를 진행이 가능합니다.

위의 코드에서 @WithMockUser 어노테이션을 주석 처리 후 실행하면 다음과 같이 401 에러가 나타나면서 Unauthorized 상태가 됩니다.

반면 @WithMockUser 어노테이션을 설정할 경우 통과되어 테스트가 정상적으로 진행되는 것을 확인하실 수 있습니다. @WithMockUser 설명을 보니 어노테이션 내부에 username, password를 설정해서 해당 유저로 테스트할 수 있다고 하는데 제가 테스트했을 때는 어떤 유저 정보를 넣든 전부 통과되는 것을 확인했습니다. 이 부분은 추후 다시 한 번 알아보고 적용시켜보도록 하겠습니다.

이어서 postman으로 테스트를 진행해보겠습니다. 먼저 로그인 api를 실행한 결과입니다. 정상적으로 동작하며 로그인 토큰을 반환합니다.

현재 로그인 API만 permitAll 설정으로 인해 토큰이 없어도 동작합니다. 그럼 permitAll 설정이 없는 다른 API를 잘못된 토큰으로 실행한다면 401 에러를 반환하면서 API가 차단됩니다.

같은 API 실행 시 올바른 토큰을 넣어 실행시키면 정상적으로 값이 출력되는 것을 확인하실 수 있습니다.

그럼 이상으로 오늘의 포스팅 마치도록 하겠습니다!

profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글