Spring Security 5.4 + JWT 구현한 것 회고해보기

Arthur·2023년 5월 12일
0
post-thumbnail
post-custom-banner

스프링 시큐리티를 사용하는 이유는?


스프링 시큐리티는 보안 관련해서 인증, 권한, 인가 등 다양한 것들을 제공해줍니다.
보안 관련 로직을 직접 작성하거나 라이브러리를 추가하는 소요를 덜어주고,
스프링 시큐리티 프레임워크를 활용해 설정 관련 코드를 작성하면 됩니다.

스프링 시큐리티가 제공해주는 보안

  • 인증(Authentication)
    • 사용자의 인증 정보를 검증하고, 인증된 사용자에게 토큰을 발급하여 사용자의 인증 상태를 유지한다. 이를 통해, 인증되지 않은 사용자는 보호된 자원에 접근하지 못하게 된다.
  • 인가(Authorization)
    • 사용자가 접근 가능한 자원과 권한을 지정한다. 이를 통해, 인가되지 않은 사용자는 보호된 자원에 대한 권한이 없으므로 접근할 수 없게 된다.
  • CSRF(Cross-Site Request Forgery) 방지
    • CSRF 공격을 방지하기 위해 CSRF 토큰을 사용한다.
  • CORS(Cross-Origin Resource Sharing) ⇒ 공식 문서 링크
    • CORS를 지원해줍니다.
    • same-origin 정책 보안

이 외에도 세션 관리, 로깅, LDAP 등 다양한 것들을 지원 해준다고 합니다.

보안은 거의 모든 애플리케이션에서 필요한 부분입니다.
이런 공통적인 부분을 직접 커스터마이징 할 필요 없이,
제공해주는 스프링 시큐리티를 편하게 사용할 수 있습니다.



스프링 시큐리티가 5.4에서 달라진 점


WebSecurityConfigurerAdapter 가 deprecated 되어 사용하지 않기를 권장하고 있습니다.
WebSecurityConfigurerAdapter Seucurity Config을 구현할 때 꼭 extends를 해줘야 했던 부분입니다.
왜 Deprecated 되었는지는 공식 문서(링크)에도 아예 언급되어 있지 않았습니다.
그래서 이유를 한번 찾아봤습니다.

*Deprecated된 WebSecurityConfigurerAdapter 클래스

‘WebSecurityConfigurerAdapter’ 는 왜 Deprecated 됐을까?

스프링 시큐리티 5.4 이전 버전에서 WebSecurityConfigurerAdapter를 extends한 것이 문제가 되는 것이 거의 없었습니다.

스프링 시큐리티를 세팅 할 때 Override 해야 할 메소드를 알 수 있어서 오히려 편했었습니다.

그렇다면 왜 Deprecated된 걸까?
이유는 Webflux 모듈과의 호환성에 있었습니다.

  • Spring Security 5.0에 도입된 ‘Reactive’ 모듈과의 호환성 문제가 발생
  • ‘Reactive’ 모듈과의 호환성을 유지하기 위해 ‘WebSecurity’ 인터페이스를 구현하도록 변경(링크)

변경 전

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authz) -> authz
                .anyRequest().authenticated()
            )
            .httpBasic(withDefaults());
    }

}

변경 후

@Configuration
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authz) -> authz
                .anyRequest().authenticated()
            )
            .httpBasic(withDefaults());
        return http.build();
    }

}
  • 직접 @Bean 을 등록하고 클래스명을 정의할 수 있습니다.
  • HttpSecurity를 build() 하고 return 해줘야 합니다.


필터 체인이라는게 있다?


스프링 시큐리티는 인증, 인가에 대한 처리를 여러개의 필터를 연쇄적으로 실행하여 수행합니다.

  • 구현할 방향성에 따라서 필요한 필터와 필요 없는 필터가 있습니다.
    • ex) JWT 토큰 인증 방식을 구현 하는데 SessionManagementFilter는 필요가 없다.
    • HttpSecurity 객체를 활용해 세부적인 보안 기능과 필터를 설정합니다.
      • 커스텀 필터를 추가할 수도 있습니다.
      • 실제 필터를 생성하는 클래스가 HttpSecurity 입니다.

필터체인 순서

*사진 출처 ⇒ 링크

각 필터에 대해 자세히 알고 싶으신 분은 아래 블로그의 링크를 참고하시기 바랍니다.

  • Spring Security, 제대로 이해하기 - FilterChain ⇒ 링크

필터에 대해서는 위 블로그가 제일 잘 설명되어있고 포인트를 잘 전달해줍니다.

필터를 전부 다 이해하려고 것보다는 프로젝트에 필요한 필터만 이해를 해도 괜찮다고 생각합니다.



커스텀 필터라는게 있다?


JWT(Json Web Token) 기반 인증을 구현한다고 할 때 토큰의 포맷이 각각 다른 경우가 많습니다.
그래서 이런 포맷에 맞게 필터를 커스터마이징 해줘야 합니다.

여기서 javax.servlet Filter 를 커스텀해서 사용 하는게 아니라,
OncePerRequestFilter 를 커스텀해서 사용합니다.

그 이유는 아래와 같습니다.

  • OncePerRequestFilter 가 모든 서블릿에 일관된 요청을 처리한다.
  • RequestDispatcher를 사용하여 요청이 발송되면 요청을 처리할 서블릿에 도달하기 전에 필터 체인을 다시 거치게 된다.
    ⇒ 잘못하면 필터가 두번 타게 되는 문제가 발생한다.

위 내용관련 레퍼런스


OncePerRequestFilter 를 extends 후 커스텀한 필터 예시

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
																		FilterChain filterChain) {
				// .....
		}
}

그렇다면 JWT란 무엇일까?



JWT란 무엇인지 간단하게 알아보자


JSON Web Token(JWT)은 웹 표준(RFC 7519)으로서 두 개체에서 JSON 객체를 사용하여 가볍고 자가수용적인(self-contained) 방식으로 정보를 안정성 있게 전달해줍니다.

여기서 자가 수용적(self-contained) 방식은 토큰에 정보들을 포함해서 전달 하는 것을 말합니다.

포함하는 정보들을 암호화해서 전달이 가능합니다.

어떤 암호화를 사용 했는지도 토큰에 포함시킵니다.

  • 웹 서버의 경우 HTTP 헤더, URL 파라미터, 쿠키 등을 사용해서 전달할 수 있습니다.
  • 회원 인증 외에도 sign이 되어있는 문서 정보를 교류할 때도 사용합니다.

*제가 작성한 글에는 Spring Security와 같이 사용해 회원 인증 관련 위주로 작성되어 있습니다.

JWT 관련해서 자세하게 정리되어 있는 링크

  • [JWT] JSON Web Token 소개 및 구조 ⇒ 링크


JWT를 사용하는 이유


기존 세션 방식에서는 서버에서 세션을 유지해야 하는 소요가 있었습니다.
이런 세션을 통해서 유저가 로그인되어 있는지 체크도 하고 신경 써야 하는 부분이 있었습니다.

JWT는 클라이언트가 request를 서버에 전달하면 토큰 검증만 하면 되기 때문에,
세션 관리 소요가 줄어들어 자원을 아낄 수 있습니다.


그렇다면 JWT의 단점은 없을까?

단점에 대해서 가장 많이 나오는 내용은 아래와 같습니다.

  • JWT Access 토큰이 탈취 되었을 때는 어떻게 해야 할까?
    • 토큰 탈취를 대비해 Expire 시간을 짧게 둬야 하는 것
      • Expire 시간을 짧게 둔다는 것은 결국 토큰을 다시 발급 해줘야 하는 소요가 생긴다.
  • JWT 토큰에 유저의 정보를 담는다면 어느 정도까지 담아야 할까?
    • 토큰이 탈취되면 유저의 정보가 노출되는 문제가 생긴다.
      • 위 문제 때문에 아예 유저 정보를 토큰에 담지 않는 곳도 있다고 한다.

위와 같은 문제들이 가장 많이 본 단점들입니다.

Access Token의 탈취를 방지하기 위해 Refresh 토큰을 적용하는 경우가 많지만,
그 부분도 소요가 생기고 Access Token과 Refresh 토큰을 저장하는 소요가 발생합니다.

이런 부분에 대해서 정확하게 정립된 부분은 없지만 고민을 해서 구현해야 할 것 같습니다.



구현한 코드를 보고 자세히 알아보자


이번에는 위 내용을 바탕으로 제가 구현한 소스코드를 공유 할려고 합니다.
공유를 하면서 제가 의도했던 내용을 같이 아래에 작성해보겠습니다.


시퀀스 다이어그램으로 플로우를 알아보자

우선 코드를 바로 공유하기 전에 구현한 부분의 플로우를 시각적으로 파악하기 위해,
시퀀스 다이어그램을 그려봤습니다.

  1. 클라이언트가 로그인 관련 정보 데이터로 서버에 request
  2. OncePerRequestFilter 를 extends한 JwtAuthenticationFilter 에서 토큰 검증을 진행한다.
    • 토큰을 쿠키에 담아서 전달합니다.
    • 이미 토큰이 있으면 ‘ALREADY_HAD_TOKEN’ Exception 발생
  3. 토큰이 없으면 필터체인에 request 를 전달해 UserContoller에 로그인 데이터가 전달된다.
    • request로 온 데이터는 jackson을 통해 JSON ⇒ UserLoginRequestDto 객체로 변환되게 된다.
  4. UserLoginRequestDto 객체를 UsersService의 userLogin 메서드에 전달한다.
  5. UserLogin

JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final CustomUserDetailsService userDetailsService;

    private static final String CHARACTER_ENCODING = "UTF-8";
    private static final String CONTENT_TYPE = "application/json";

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

    @Override
    protected void doFilterInternal(
            @NotNull HttpServletRequest request,
            @NotNull HttpServletResponse response,
            @NotNull FilterChain filterChain
    ) throws ServletException, IOException {
        String servletPath = request.getServletPath();
        if(servletPath.contains("/users/signup")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwtToken;
        final String username;

        jwtToken = getJwtToken(request.getCookies());

        if(servletPath.contains("/users/login")) {
            if(jwtToken != null) {
                exceptionResponse(response, JwtExceptionCode.ALREADY_HAD_TOKEN);
                return;
            }

            filterChain.doFilter(request, response);
            return;
        }

        if(Strings.isNullOrEmpty(jwtToken)) {
            exceptionResponse(response, JwtExceptionCode.TOKEN_NULL_OR_EMPTY);
            return;
        }

        username = jwtService.extractUsername(jwtToken);

        if(username != null && SecurityContextHolder.getContext().getAuthentication() == null)  {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if(!jwtService.isTokenValid(jwtToken, userDetails)) {
                exceptionResponse(response, JwtExceptionCode.INVALID_TOKEN);
                return;
            }

            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
            );
            authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) );
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }
        filterChain.doFilter(request, response);
    }

    private String getJwtToken(Cookie[] cookies) {
        if(cookies == null) return null;

        String token = null;
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals(JwtToken.TOKEN_NAME.getTokenName())) {
                token = cookie.getValue();
            }
        }

        return token;
    }

    private void exceptionResponse(HttpServletResponse response, JwtExceptionCode jwtExceptionCode) throws IOException {
        logger.error("JWT Exception : {}", jwtExceptionCode.getMessage());
        
        ObjectMapper mapper = new ObjectMapper();
        response.setCharacterEncoding(CHARACTER_ENCODING);
        response.setContentType(CONTENT_TYPE);
        response.setStatus(jwtExceptionCode.getCode());

        JavaTimeModule javaTimeModule=new JavaTimeModule();
        javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ISO_DATE_TIME));
        mapper.registerModule(javaTimeModule);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        ExceptionResponseDto exceptionResponseDto = ExceptionResponseDto.builder()
                        .occurredTime(LocalDateTime.now())
                        .message(jwtExceptionCode.getMessage())
                        .code(jwtExceptionCode.getCode())
                        .build();

        String result = mapper.writeValueAsString(exceptionResponseDto);
        response.getWriter().write(result);
    }
}
  • OncePerRequestFilter 를 extends 받아서 클라이언트의 request에서 JWT 쿠키를 검증합니다.
  • JWT 쿠키가 없으면 Exception을 클라이언트에게 response합니다.
    • JWT 쿠키가 이미 발급 받은 상태인지 확인
    • JWT 쿠기가 Expire 되었는지 확인
  • ServletPath를 확인해 로그인(”/users/login”) 회원가입(”/users/signup”) 인지 확인합니다.
    • 회원가입이면 토큰 검증 없이 다음 Filter로 넘어가게 됩니다.
    • 로그인이면 토큰이 있는지 검증 후에 다음 Filter로 넘어가게 됩니다.
      • UsersController의 userLogin에서 토큰을 생성 후 Response 합니다.
  • 유저가 로그인, 회원가입을 제외한 모든 Request에서 토큰의 검증이 이뤄집니다.

UsersController

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UsersController {

    private final UsersService usersService;

		// ...

    @PostMapping("/login")
    public ResponseEntity<String> userLogin(@Valid @RequestBody UserLoginRequestDto requestDto, HttpServletResponse response) {
        String tokenValue = usersService.userLogin(requestDto);
        Cookie cookie = new Cookie(JwtToken.TOKEN_NAME.getTokenName(), tokenValue);
        cookie.setMaxAge(60 * 60 * 24);
        cookie.setPath("/");
        response.addCookie(cookie);
        return ResponseEntity.ok()
                .body(tokenValue);
    }
}
  • 로그인을 UsersController의 userLogin 메서드에서 실행이 됩니다.
  • UserLoginRequestDto를 UserService의 userLogin 인자로 넘기게 됩니다.
  • UserService의 userLogin 메서드를 통해 토큰이 발급(generate)되면 쿠키를 생성합니다.
  • 쿠키의 Expire 시간을 세팅하고 쿠키를 사용할 Path을 세팅합니다.
  • response에 Cookie를 추가합니다.

UsersService(userLogin)

@Service
@RequiredArgsConstructor
public class UsersService {

    private final UsersRepository usersRepository;

    private final JwtService jwtService;

    private final AuthenticationManager authenticationManager;

		// ....

    public String userLogin(UserLoginRequestDto requestDto) {
        String username = requestDto.getUsername();
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        username,
                        requestDto.getPassword()
                )
        );
        Users user = findUserByUsernameIfExist(username);
        CustomUserDetails userDetails = CustomUserDetails.builder()
                        .users(user)
                        .build();

        String tokenValue = jwtService.generateToken(userDetails);

        return tokenValue;
    }

    private Users findUserByUsernameIfExist(String username) {
        return usersRepository.findByUsernameAndUserState(username, UserState.Enabled)
                .orElseThrow(
                        () -> new RestApiException(ClientExceptionCode.CANT_FIND_USER)
                );
    }
}
  • AuthenticationManager에 AuthenticationToken 값을 세팅합니다.
  • dto에 담겨있는 username을 통해 실제로 회원가입을 한 유저인지 DB 데이터를 확인합니다.
    • 회원가입이 안되어있는 유저면 Exception이 발생합니다.
  • DB에서 조회한 User 객체로 CustomUserDetails 객체를 생성합니다.
  • userDetails 객체를 통해 jwtService에서 토큰을 생성(generate)합니다.
  • 생성된 토큰을 return 합니다.

JWT Service

@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secretKey;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .setClaims(extraClaims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts
                .parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private Key getSignInKey() {
        byte[] keyByte = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyByte);
    }
}
  • 토큰을 생성 및 검증 하는 로직들이 담겨있습니다.
    • generateToken() 메서드에서 토큰 생성 로직이 담겨 있습니다.
      • Jwts.builder()를 사용해 유저 아이디, Expire 시간, Issue 시간, Signature를 세팅합니다.
    • 유저의 Claim을 확인하는 로직이 포함되어 있습니다.

구현 하면서 배운 것들

예전에 부트캠프를 들었을 때도 Spring Security + JWT를 구현했었는데,
그때는 Filter의 개념과 왜 FilterChain이 있는지도 잘 모르는 상태였었습니다.
이번에는 OncePerRequestFilter 필터를 사용해서, JWT 구현을 군더더기 없이 구현해봤습니다.

구현하면서 배운 것들을 정리하면 아래와 같습니다.

  • Servlet response로 직접 Exception 처리를 해본 것
  • JWT에 최소한의 데이터를 담아야 한다는 것
    • 유저 아이디만 담도록 구현해봤습니다.
  • 쿠키를 세팅 후에 클라이언트에게 전달해본 것
    • 클라이언트에서 토큰 처리 로직 소요 제거
  • Filter에 대해 공부를 하면서 Interceptor와의 차이도 좀 더 명확하게 알게 되었습니다.
  • 검증 로직을 작성하면서, HttpServletRequest와 HttpServletResponse에 담겨있는 데이터를 직접 열어보고 공부할 수 있었습니다.


구현하면서 느낀 점 및 보완해야 할 점


스프링 시큐리티가 상당히 거대하고 어렵다고만 느껴졌는데,
이번에 구현을 해보면서 좀 더 이해하기 쉬워졌습니다.
당연히 아직 부족한게 많고 보완할 점이 상당히 많다고 느꼈습니다.

보완할 점에 대해서도 멘토님이 코드리뷰를 위와 같이 남겨 주셨었습니다.
Access Token이 만료되어 로그인 로직을 다시 탄다는건 유저 입장에서 상당히 불편한 요소입니다.
그렇다고 이런 문제를 해결하기 위해 토큰 Expire 시간을 일주일이나 한 달로 잡아버리면,
탈취 되었을 때 탈취한 유저가 악용할 수 있는 시간이 상당히 길어집니다.


이런 부분을 Expire 시간을 줄이고 refresh 토큰을 도입 하는 것도 고민해봤습니다.
이 부분도 한번 구현을 해보고 refresh 토큰의 단점도 보고 싶었는데 아직은 못했습니다.


그리고 중복 로그인 관련해서도 고려해보지 못한 점이 아쉬웠습니다.
이미 JWT 토큰을 쿠키에 저장되어 있는 클라이언트는 로그인을 못하도록 막아놨지만,
다른 브라우저로 로그인을 하거나 다른 기기로 접속하면 로그인이 가능하도록 되어있습니다.


여러 자료를 찾아보니 DB에 JWT 토큰을 저장해서 검증 하는 방식이나,
로그인 이력을 DB에 저장해서 확인하는 방식이 있었습니다.
하지만 이 방법도 결국 DB를 사용해야 하는 소요가 생깁니다.
JWT가 토큰을 DB에서 검증하지 않는 장점을 버려야 된다는게 아쉬운 해결책이라고 느꼈습니다.


중복 로그인 관련 참고한 자료

  • OKKY - JWT 중복 로그인 방지 가능한가요? ⇒ 링크
  • 인프런 질문 ⇒ 링크


🔗참고 자료


  • Spring security Docs ⇒ 링크
  • Spring boot 3.0 - Secure your API with JWT Token [2023] ⇒ 링크
    • 참고한 깃 레포지토리 ⇒ 링크
  • 망나니개발자 - [SpringBoot] Spring Security란? ⇒ 링크
  • Spring Security, 제대로 이해하기 - FilterChain ⇒ 링크
  • Spring Security, 어렵지 않게 설정하기 ⇒ 링크
  • OncePerRequestFilter와 Filter의 차이 ⇒ 링크
  • [JWT] JSON Web Token 소개 및 구조 ⇒ 링크
profile
기술에 대한 고민과 배운 것을 회고하는 게임 서버 개발자의 블로그입니다.
post-custom-banner

0개의 댓글