스프링 시큐리티 JWT 구축

Minjae An·2024년 2월 15일
0

Spring ETC

목록 보기
8/8

JWT 구조 이해

JWT(JSON Web Token)은 당사자 간 정보를 JSON 객체로 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준(RFC 7519)이다. 디지털 서명을 동반하므로 확인하고 신뢰할 수 있다. JWT는 HMAC, RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있다.

JWT를 암호화하여 당사자 간 보안을 제공할 수도 있지만 가장 유용한 것은 서명된 토큰이다. 서명된 토큰은 그 안에 포함된 청구의 무결성을 확인할 수 있는 반면, 암호화된 토큰은 이런 정보를 제 3자로부터 숨긴다. 공개/개인 키 쌍을 사용하여 토큰에 서명하는 경우 서명은 개인 키를 보유하고 있는 당사자만이 서명한 사람임을 인증한다.

JWT는 다음과 같은 세 부분으로 이뤄진 형식을 가진다.

JWT의 각 부분은 Base64Url 형태로 암호화/복호화 된다.

{
  "alg": "HS256",
  "typ": "JWT"
}

헤더에는 토큰의 타입(JWT )과 서명에 사용된 알고리즘 정보를 포함한다.

Payload

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

페이로드 부분에는 토큰에서 사용할 데이터 조각들인 클레임을 포함한다. 클레임은 총 3가지로 나누어지며, 다수의 데이터를 저장할 수 있다.

세 분류로 이루어진 클레임은 필수적으로 지켜야 하는 형태는 아니지만 권장 사항이다.

Register Claim

토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터들로, 선택적으로 작성이 가능하지만 사용을 권장하고 있다. 간결한 형태로 표현되기 위해 키는 모두 길이 3의 문자열이며, sub 는 고유한 값을 사용하는 데 주로 사용자 이메일을 사용한다.

  • iss : 토큰 발급자(issur)
  • sub : 토큰 제목(subject)
  • aud : 토큰 대상자(audience)
  • exp : 토큰 만료 시간, NumericDate 형식으로 되어 있어야 함. ex) 1480849147370
  • nbf : 토큰 활성 날짜(not before), 이 날이 지나기 전의 토큰은 활성화되지 않음
  • iat : 토큰 발급 시간(issued at), 토큰 발급 이후의 경과 시간을 알 수 있음
  • jti : JWT 토큰 식별자(JWT ID), 중복 방지를 위해 사용하며, 일회용 토큰 등에 사용

Public Claim

공개 클레임은 사용자 정의 클레임으로 공개용 정보를 위해 사용된다. 충돌 방지를 위해 URI 포맷을 이용하며, 다음과 같은 예시가 있다.

{ "https://naver.com": true }

Private Claim

비공개 클레임은 사용자 정의 클레임으로 서버와 클라이언트 사이 임의로 지정한 정보를 저장한다.

{ "token_type": access }

Signature

서명은 토큰을 인코딩하거나 유효성 검증할 시 사용하는 고유한 암호 코드이다. 서명은 위에서 만든 헤더와 페이로드의 값을 각각 Base64로 인코딩하고, 인코딩한 값을 비밀 키를 이용해 헤더에서 정의한 알고리즘으로 해싱을 하고, 이 값을 다시 Base64로 인코딩하여 생성한다.

클라이언트-서버 간 JWT를 이용한 상호작용은 다음과 같이 이뤄질 수 있다.

한편, JWT를 통한 상호작용에서 RSA를 이용하는 원리는 아래와 같이 정리할 수 있다.

JWT에서 Base64는 암호화의 목적이 아닌 서명의 목적으로 쓰인다. 클라이언트가 보내온 JWT를 Base64로 디코딩하면 Header, Payload, Signature 데이터가 도출될 것이다. 서버의 개인 키와 Header, Payload 합쳐 HS256을 적용시키고 Signature와 비교하면 사용자의 인증 여부를 확인할 수 있다.

이런 구조에서 요청을 전담하는 서버가 여러 개 존재할 때 JWT를 사용하면 서버에서는 자원을 소모하며 세션을 이용할 필요가 없다. JWT를 통해 사용자를 검증하고 비밀 키만을 서버 간에 공유하면 된다.

JWT 프로젝트 세팅

프로젝트 의존성

  • Lombok
  • Spring Boot DevTools
  • Spring Web
  • MySQL Driver
  • Spring Data JPA
  • Spring Security
  • Java JWT(외부 lib)

JWT를 위한 yml 세팅

application.yml

server:
  port: 8080
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  datasource:
    url: jdbc:mysql://localhost:3390/security?serverTimezone=Asia/Seoul
    username: spring_boot_study
    password: ${DB_PW}
    driver-class-name: com.mysql.cj.jdbc.Driver

    jpa:
      properties:
        hibernate:
          naming:
            physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
          dialect: org.hibernate.dialect.MySQLDialect
          hbm2ddl:
            auto: create
        show-sql: true

@RestApiController

package com.example.jwtserver.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RestApiController {
    @GetMapping("home")
    public String home() {
        return "<h1>home</h1>";
    }
}

JWT를 위한 security 세팅

Users

package com.example.jwtserver.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.util.Arrays;
import java.util.List;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Users {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String roles; // USER,ADMIN

    @Builder
    public Users(String username, String password, String roles) {
        this.username = username;
        this.password = password;
        this.roles = roles;
    }

    public List<String> getRoles() {
        if (roles.length() > 0) {
            return Arrays.asList(roles.split(","));
        }
        return List.of();
    }
}

Role이라는 별도의 엔티티를 만들어 설정해도 된다.

SecurityConfig

package com.example.jwtserver.config;

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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http.csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(registry ->
                        registry.requestMatchers("api/v1/user/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/manager/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/admin/**")
                                .hasAnyRole("MANAGER", "ADMIN")
																.anyRequest().permitAll())
                .build();
    }
}

폼 로그인, 세션 방식을 사용하지 않으며 stateless 하게 서버를 만들 것이므로 위와 같이 설정을 구성한다.

CorsConfig

package com.example.jwtserver.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}
  • setAllowedCredentials : 자격증명과 함께 요청이 가능하게 할 것인지 여부를 설정하는 구문, 이 부분을 true 로 처리해주어야 서버에서 어떤 응답을 했을 때 프론트에서 json 데이터를 처리할 수 있다.

설정한 CorConfigSecurityConfig 에 DI해주자.

package com.example.jwtserver.config;

import lombok.RequiredArgsConstructor;

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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http.csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter) // 필터 추가
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(registry ->
                        registry.requestMatchers("api/v1/user/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/manager/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/admin/**")
                                .hasAnyRole("MANAGER", "ADMIN")
                                .anyRequest().permitAll())
                .build();
    }
}

설정을 마치고 실행하여 api/v1/** URL에 접근해보면 권한이 없어 필터가 403을 발생시키는 것을 확인할 수 있다.

JWT Bearer 인증 방식

HTTP 통신 방법

HTTP 통신은 요청을 통해 응답으로 문서 및 리소스를 받는다. 이 통신에는 인증이 필요한데, 이를테면 블로그 사용자는 자신이 작성한 게시물을 해당 사용자만 수정할 수 있어야 할 것이다. 서버는 보호된 서버 리소스를 접근하는 클라이언트의 인증 정보(Credentials)를 확인한다. HTTP 인증 프레임워크는 RFC 7235에 정의되어 있고 아래와 같은 인증 헤더를 요청에 사용한다.

Authorization : <type> <credentials>

올바른 인증 정보로 요청하면 200 OK 와 요청 데이터가 응답되지만, 인증 헤더를 누락하면 401 Unauthorized 에러가 발생한다. 인증 정보를 넣었지만 권한이 없는 접근이라면 403 Forbidden 에러가 발생한다.

인증 정보는 인증 방식에 따라 달라지는데, 대표적인 두 인증 방식에 대해 알아보자.

Basic 인증

가장 기본적인 인증 방식이다. 인증 정보로 사용자ID, 비밀번호를 사용한다. Base64로 인코딩한 사용자ID:비밀번호 문자열을 Basic 과 함께 인증 헤더에 입력한다. RFC 7617 정의 참고

Base64는 쉽게 복호화할 수 있기 때문에, 단순 base64 인코딩된 인증 정보를 HTTP 헤더로 전달하면 요청의 보안이 보장되지 않는다. 이 인증 방식을 사용하면 꼭 HTTP, SSL/TLS로 통신해야 안전하다.

Authorization: Basic base64({USERNAME}:{PASSWORD})

Basic 인증의 장단점

이 방식의 가장 큰 장점은 간단함이다. 사용자 ID, 비밀번호 외 별도의 인증 정보를 요구하지 않는다. 하지만 Basic 인증 방식은 서버에 사용자 목록을 저장한다. 요청한 리소스가 많거나 사용자가 많으면 목록에서 권한을 확인하는 시간이 길어진다. 또한 서버에 현실적으로 저장할 수 있는 데이터는 한정되어 있기에 사용자가 많거나, 사용자 변화가 잦은 서비스에 이 인증을 사용하기는 어렵다.

또 이 방식만으로는 사용자 권한을 정교하게 제어하기 힘들다. 사용자가 꼭 필요로 하는 리소스에만 권한을 주는 것이 좋은데, Basic 인증을 기반으로 사용자 권한을 세세히 설정하려면 추가 구현이 필요하다.

Bearer 인증

이 방식은 OAuth 2.0 프레임워크에서 사용하는 토큰 인증 방식으로 Bearer 은 소유자라는 뜻이며, 이 토큰의 소유자에게 권한을 부여해줘라는 의미로 이름을 붙였다고 한다. Bearer 와 토큰을 인증 헤더에 입력한다. RFC 6750을 참고하자

Authorization: Bearer <token>

OAuth 2.0 프레임워크

다양한 서비스 또는 서버 간에 안전하게 데이터를 전송하기 위해 OAuth 프레임워크가 탄생하였다. OAuth는 제 3자의 클라이언트에게 보호된 리소스를 제한적을 접근하게 해주는 프레임워크이다. 다음과 같은 요소들로 구성되어 있다.

  • 리소스 소유자: 사용자
  • 클라이언트: 사용자의 정보를 접근하는 제 3자의 서비스
  • 인증 서버: 클라이언트의 접근을 관리하는 서버
  • 리소스 서버: 리소스 소유자의 데이터를 관리하는 서버

리소스 소유자의 동의가 확인되면 인증 서버는 클라이언트에게 액세스 토큰을 발급하고, 클라이언트는 액세스 토큰을 이용해 리소스 서버에 보호된 데이터를 불러온다.

Bearer 토큰

Bearer 토큰은 OAuth 프레임워크에서 액세스 토큰으로 정의하는 토큰의 유형이다.

Bearer 토큰은 불투명한 문자열이며, 토큰의 형태는 인증 서버에서 정의하기 나름인데 JWT를 많이 사용한다. 중요한 것은 토큰이 클라이언트가 해석할 수 없는 형태여야 하고, 사용자 정보를 전달하면 안되다는 것이다. 대신 서버에서 클라이언트의 권한을 확인할 수 있는 메타데이터가 토큰에 인코딩되어 있어야 한다.

불투명이라는 용어는 정보, 내용이 외부에서 직접 이해되거나 해석되기 어렵다는 의미로 쓰였다.

Bearer 인증의 장단점

Bearer 토큰은 쉽게 복호화할 수 없고 OAuth 프레임워크의 인증 및 리소스 서버는 SSL/TLS를 필수로 사용한다. 게다가 서버에서 토큰의 리소스 접근 권한을 쉽게 철회할 수도 있고, 토큰의 유효기간을 설정할 수 있어서 안전하게 리소스를 보호할 수 있다. OAuth는 또 제한적으로 리소스 접근을 정교하게 할 수 있다.

Bearer 토큰 자체가 메타데이터를 가지고 있어 서버는 토큰을 발급만 하고 보관할 필요가 없다. 사용자가 아무리 많아도 토큰을 검증하는 과정은 같은 시간이 소요된다. 게다가 여러 서비스 및 서버 간 토큰을 공유할 수도 있어 사용자 편의성을 향상시킬 수 있다.

하지만 Bearer 토큰이 외부에 노출되면 다른 서비스도 토큰으로 바로 리소스를 접근할 수 있다. 서버가 OAuth 프레임워크에 정의된 보안 장치를 잘 구축하고, 노출이 발견되면 해당 토큰의 권한을 철회하는 방식으로 이 한계를 극복할 수 있다.

보안확장성복잡성
Basic 인증쉽게 복호화할 수 있는 base64를 사용하고, 노출된 인증 정보를 철회할 방법이 없음사용자 정보를 서버에 저장하기 때문에 사용자 많을 수록 서버에 부담됨간단한 인증 방법이지만, 정교하게 권한을 제어하기 어려움
Bearer 인증충분히 복잡한 알고리즘으로 토큰을 발급하고, 토큰이 노출되면 권한을 철회할 수 있음Bearer 토큰이 메타데이터를 담고 있어서 사용자가 많아도 서버에 부담되지 않음정교하게 권한을 제어할 수 있고 유효기간, MFA 등 추가 장치도 연동할 수 있음

JWT Filter 등록 테스트

MyFilter1

package com.example.jwtserver.filter;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import java.io.IOException;

public class MyFilter1 implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        System.out.println("filter1");
        chain.doFilter(request, response);
    }
}

필터에 대해 알아보기 위해 테스트 필터 클래스를 하나 만들었다. 만약 현재 프로세스 진행을 중단하고 다른 플로우로 진행되기를 원한다면 chain.doFilter(request, response) 가 아닌 다른 방식으로 코드를 구성해야 한다.

이 필터를 아래와 같이 SecurityConfig 에서 등록하려 하면 addFilter 로 등록할 수 없다는 에러가 발생하는데 시큐리티 필터 체인에 일반적인 필터를 등록하려면 addFilterBefore , addFilterAfter 를 통해서 시큐리티 필터와의 상대적 위치로 설정해주어야 하기 때문이다.

public class SecurityConfig {
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http.csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter) // 필터 추가
                .addFilter(new MyFilter1())
//...

따라서 다음과 같이 로직을 수정하여 MyFilter1 이 잘 동작하는 지 확인할 수 있다.

public class SecurityConfig {
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http.csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter) // 필터 추가
                .addFilterBefore(new MyFilter1(), BasicAuthenticationFilter.class)
// ...

FilterConfig

package com.example.jwtserver.config;

import com.example.jwtserver.filter.MyFilter1;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<MyFilter1> filter1(){
        FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
        bean.addUrlPatterns("/*");
        bean.setOrder(0);
        return bean;
    }
}

Filter 를 별도의 설정 클래스를 통해 빈으로 등록하여 사용할 수도 있다. 위와 같이 로직을 구성하면 특정 URL, 순서에 따라 동작하는 필터를 구성할 수 있다.

그렇다면 여러 개의 임의의 필터에 순서를 설정하여 빈으로 등록하고 시큐리티 필터와 함꼐 동작하게 구성한다면 어떤 것이 먼저 동작할까?

우선 아래와 같이 임의의 필터 클래스를 추가하고 빈으로 등록하자.

package com.example.jwtserver.config;

import com.example.jwtserver.filter.MyFilter1;
import com.example.jwtserver.filter.MyFilter2;
import com.example.jwtserver.filter.MyFilter3;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<MyFilter1> filter1() {
        FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
        bean.addUrlPatterns("/*");
        bean.setOrder(1);
        return bean;
    }

    @Bean
    public FilterRegistrationBean<MyFilter2> filter2() {
        FilterRegistrationBean<MyFilter2> bean = new FilterRegistrationBean<>(new MyFilter2());
        bean.addUrlPatterns("/*");
        bean.setOrder(2);
        return bean;
    }

    @Bean
    public FilterRegistrationBean<MyFilter3> filter3() {
        FilterRegistrationBean<MyFilter3> bean = new FilterRegistrationBean<>(new MyFilter3());
        bean.addUrlPatterns("/*");
        bean.setOrder(3);
        return bean;
    }
}

이제 임의로 만든 필터 체인을 시큐리티 필터 체인과 함꼐 동작하도록 설정해보자.

public class SecurityConfig {
    private final CorsFilter corsFilter;
    private final FilterRegistrationBean<MyFilter3> filter3Bean;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http
                .addFilterAfter(filter3Bean.getFilter(), BasicAuthenticationFilter.class)
// ...

위 코드를 실행해보면 필터3 이 가장 먼저 실행되고, 임의로 설정했던 필터 체인 순서인 필터1->필터2->필터3 순서로 동작하는 것을 확인할 수 있다. 한 마디로 시큐리티 필터 체인이 먼저 동작한 후 커스텀 필터 체인이 동작한다는 것이다. 따라서 시큐리티 필터 체인내에서 커스텀 필터가 동작하도록 하고 싶다면 addFilterBefore , addFilterAfter 를 활용하여 SecurityConfig 에 별도로 설정을 해주어야 한다.

JWT 임시 토큰 생성 & 테스트

클라이언트에서 인증 헤더를 사용하여 요청할 수 있게 postman을 이용한다.

MyFilter1 - 인증 헤더 값 출력해보기

public class MyFilter1 implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

        String headerAuth = req.getHeader("Authorization");
        System.out.println(headerAuth);
        System.out.println("filter1");
        chain.doFilter(req, res);
    }
}

로직을 다음과 같이 바꾸어 실행해 요청해보면 헤더 값이 null 로 출력된다. postman에서 헤더를 설정하여 요청해보자. 그럼 MyFilter1 에 정상적으로 해당 헤더 값을 출력하는 것을 확인할 수 있다.

이런 필터를 이용하면 서버에서 발급한 특정 토큰이 Authorization 헤더에 요청과 함꼐 설정되어 있을 때만 진입을 허용하게 로직을 구성할 수 있다. 먼저 테스트를 위한 API를 하나 구성해주자.

RestApiController - token API

@RestController
public class RestApiController {
    @GetMapping("home")
    public String home() {
        return "<h1>home</h1>";
    }
    
    @PostMapping("token")
    public String token(){
        return "<h1>token</h1>"
    }
}

MyFilter - 인증 로직 구성

public class MyFilter1 implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

       if(req.getMethod().equals(HttpMethod.POST.name())){
           System.out.println("POST request");
           String headerAuth = req.getHeader("Authorization");
           System.out.println(headerAuth);

           if(headerAuth.equals("cos")){
               chain.doFilter(req, res);
           }else{
               PrintWriter out = res.getWriter();
               out.println("not authorized");
           }
       }
    }
}

cos 인증 토큰이 요청에 포함되었을 경우만 진입을 허용한다.

실행 결과

올바른 토큰을 요청에 포함했을 경우

올바르지 않은 토큰이 요청에 동반될 경우

한편, 이런 인증 관련 필터는 시큐리티 필터 체인이 동작하기 전에 먼저 실행되야 할 것이다. 따라서 SecurityConfig 에 다음과 같이 로직을 구성해주어야 한다.

@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
    return http
            .addFilterBefore(new MyFilter1(), SecurityContextHolderFilter.class)
// ...

강의 코드에서 사용된 SecurityContextPersistenceFilter 는 deprecated 되었다고 한다. 이에 SecurityContextHolderFilter 를 대신 사용하였다.

앞서 배웠던 RSA, HS256을 사용한 JWT를 이용한다고 하면 cos 토큰이 맞는 지 검증하는 로직은 특정 암호화 알고리즘을 기반으로, 이를테면 RSA라면 비밀 키로 생성한 토큰을 공개 키로 디코딩하여 검증하는 로직을 대체될 것이다.

JWT를 위한 로그인 시도

PrincipalDetails

package com.example.jwtserver.auth;

import com.example.jwtserver.model.Users;
import java.util.Collection;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {
    private final Users user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

UserRepository

package com.example.jwtserver.repository;

import com.example.jwtserver.model.Users;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<Users, Long> {
    Optional<Users> findByUsername(String username);
}

PrincipalDetailsService

package com.example.jwtserver.auth;

import com.example.jwtserver.model.Users;
import com.example.jwtserver.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println(this.getClass().getName() + " : 진입");
        Users user = userRepository.findByUsername(username)
                .orElseThrow(() -> new IllegalStateException("해당 유저가 존재하지 않습니다."));
        return new PrincipalDetails(user);
    }
}

UserDetailsService.loadUserByUsername 메서드는 /login 으로 로그인 요청시 호출되어 로직을 수행한다. 헌데, 현재 SecurityConfig 에서 폼 로그인 방식을 사용하지 않고 있고, 또한 loginProcessingUrl 을 바꿔주는 방법도 사용할 수 없다.

이 문제는 스프링 시큐리티에서 제공하는 UsernamePasswordAuthenticationFilter 를 활용하여 해결할 수 있다.

JwtAuthenticationFilter

UsernamePasswordAuthenticationFilter/login 으로 요청이 올 때 동작한다.

package com.example.jwtserver.jwt;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        System.out.println("trying login");
        return super.attemptAuthentication(request, response);
    }
}

SecurityConfig - 검증 필터 추가

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http
                .addFilter(new JwtAuthenticationFilter(authenticationManager(http)))
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter) // 필터 추가
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(registry ->
                        registry.requestMatchers("api/v1/user/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/manager/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/admin/**")
                                .hasAnyRole("MANAGER", "ADMIN")
                                .anyRequest().permitAll())
                .build();
    }

    private AuthenticationManager authenticationManager(HttpSecurity http){
        return http.getSharedObject(AuthenticationManager.class);
    }
}

위 과정까지 구성한 코드를 실행해보면 기본적으로 /login 경로로 인증 요청이 이뤄지는 것을 확인할 수 있다. AuthenticationManager 를 통해 로그인 시도를 하면 UserDetailsService.loadUserByUsername 이 자동으로 실행된다.

지금까지 임시 방편으로 구현한 코드를 통해 각 클래스들이 어떤 원리로 동작하는 지 살펴보았다. 이 다음 장부턴 아래의 흐름에 따라 JWT를 실제로 활용하여 인증/인가를 구현한다.

회원가입 로직 추가

BCryptPasswordEncoder Bean 등록

package com.example.jwtserver;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@SpringBootApplication
public class JwtServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(JwtServerApplication.class, args);
    }

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

UserRequestDto.JoinRequest

package com.example.jwtserver.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

public class UserRequestDto {
    @Getter
    @AllArgsConstructor
    public static class JoinRequest{
        private String username;
        private String password;
    }
}

RestApiController - join API

package com.example.jwtserver.controller;

import com.example.jwtserver.dto.UserRequestDto;
import com.example.jwtserver.model.Users;
import com.example.jwtserver.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class RestApiController {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;
    
    @GetMapping("home")
    public String home() {
        return "<h1>home</h1>";
    }

    @PostMapping("token")
    public String token(){
        return "<h1>token</h1>";
    }

    @PostMapping("join")
    public String join(@RequestBody UserRequestDto.JoinRequest request){
        Users user = Users.builder()
                .username(request.getUsername())
                .password(passwordEncoding(request.getPassword()))
                .roles("ROLE_MANAGER")
                .build();
        userRepository.save(user);
        return "join complete";
    }
    
    private String passwordEncoding(String originPassword){
        return passwordEncoder.encode(originPassword);
    }
}

JWT를 위한 강제 로그인 진행

근래에 클라이언트와 서버 간에는 데이터를 주고 받을 때 JSON 포맷을 많이 사용한다. 그렇다면, 인증 필터에서는 어떻게 요청 데이터에서 username, password 를 파싱할 수 있을까? Spring Web 모듈에 기본적으로 포함되어 있는 ObjectMapper 를 이용하면 아래와 같이 스트림 데이터를 객체에 주입할 수 있다.

ObjectMapper om = new ObjectMapper();
Users user = om.readValue(request.getInputStream(), Users.class);

JWTAuthenticationFilter - 로그인 구성

package com.example.jwtserver.jwt;

import com.auth0.jwt.JWT;
import com.example.jwtserver.auth.PrincipalDetails;
import com.example.jwtserver.model.Users;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        System.out.println("trying login");

        ObjectMapper om = new ObjectMapper();
        Users user;
        try {
            user = om.readValue(request.getInputStream(), Users.class);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());

        Authentication authentication =
                authenticationManager.authenticate(authenticationToken);

        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        // 로그인 정보 확인
        String join = String.join(", ", principalDetails.getUsername(), principalDetails.getPassword(),
                principalDetails.getAuthorities().toString());
        System.out.println(join);

        return authentication;
    }

    // attemptAuthentication 실행 후 인증 정상 진행시 이 메서드 실행
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        super.successfulAuthentication(request, response, chain, authResult);
    }
}

위 로직에서 유의 깊게 알고 가야할 지점은 다음과 같다.

Authentication authentication =
                authenticationManager.authenticate(authenticationToken);

authenticate 메서드가 실행될 때 PrincipalDetailsService.loadUserByUsername 이 실행되며 해당 유저가 존재하는 지 검증한다. 정상적으로 진행 되지 못하면 authentication 을 리턴할 수 없다.

return authentication;

attemptAuthentication 에서 반환된 Authentication 객체는 시큐리티의 세션 영역에 저장된다. 굳이 JWT를 사용하며 세션을 만들 이유느 없지만, 권한 처리로 인해 세션을 사용한다.

SecurityConfig - authenticationManager 설정 로직 수정

package com.example.jwtserver.config;

import com.example.jwtserver.auth.PrincipalDetailsService;
import com.example.jwtserver.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final CorsFilter corsFilter;
    private final PrincipalDetailsService principalDetailsService;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        return http
                .addFilter(new JwtAuthenticationFilter(authenticationManager(http)))
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter) // 필터 추가
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(registry ->
                        registry.requestMatchers("api/v1/user/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/manager/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/admin/**")
                                .hasAnyRole("MANAGER", "ADMIN")
                                .anyRequest().permitAll())
                .build();
    }

    private AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class);
        sharedObject.userDetailsService(this.principalDetailsService);
        AuthenticationManager authenticationManager = sharedObject.build();
        http.authenticationManager(authenticationManager);
        return authenticationManager;
    }
}

JWT 토큰 만들어서 응답하기

앞선 JwtAuthenticationFiltersuccessfulAuthentication 메서드는 attemptAuthentication 이 정상 실행되면 이후에 실행되는 메서드이다. 이 메서드를 활용하여 클라이언트에게 JWT를 생성하여 응답할 수 있다.

JWTProperties

JWT와 관련된 상수 속성들을 일괄적으로 관리하는 인터페이스를 정의하자.

package com.example.jwtserver.jwt;

public interface JwtProperties {
    String SECRET = "min"; // 우리 서버만 알고 있는 비밀값
    int EXPIRATION_TIME = 864000000; // 10일 (1/1000초)
    String TOKEN_PREFIX = "Bearer ";
    String HEADER_STRING = "Authorization";
}

JwtAuthenticationFilter - successfulAuthentication

// attemptAuthentication 실행 후 인증 정상 진행시 이 메서드 실행
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                        Authentication authResult) throws IOException, ServletException {
    PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

    String jwtToken = JWT.create()
            .withSubject(principalDetails.getUsername())
            .withExpiresAt(Instant.now().plusMillis(JwtProperties.EXPIRATION_TIME))
            .withClaim("id", principalDetails.getUser().getId())
            .withClaim("username", principalDetails.getUsername())
            .sign(Algorithm.HMAC512(JwtProperties.SECRET));

    response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+" "+jwtToken);
}

withClaim 의 경우 사용자 정의 클레임을 추가하는 메서드로 임의의 데이터를 토큰에 포함할 수 있다.

실행 결과


발급된 JWT가 헤더를 통해 정상적으로 클라이언트에게 제공된다. 이제 클라이언트가 이 JWT를 통해 유효성을 검증받을 수 있도록 서버측에서 구현된 필터가 존재해야 한다.

JWT 토큰 서버 구축 완료

JWT를 기반으로 사용자의 권한, 인증을 관리하는 기능까지 구현해보자.

JwtAuthorizationFilter

package com.example.jwtserver.jwt;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.jwtserver.auth.PrincipalDetails;
import com.example.jwtserver.model.Users;
import com.example.jwtserver.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager,
                                  UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String header = request.getHeader(JwtProperties.HEADER_STRING);
        if (!isJwtHeaderValid(header)) {
            chain.doFilter(request, response);
            return;
        }

        System.out.println("header = " + header);
        String token = request.getHeader(JwtProperties.HEADER_STRING)
                .replace(JwtProperties.TOKEN_PREFIX, "");

        String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
                .build().verify(token)
                .getClaim("username").asString();

        if (username != null) {
            Users user = userRepository.findByUsername(username)
                    .orElseThrow(() -> new IllegalStateException("해당 유저가 존재하지 않습니다"));

            PrincipalDetails principalDetails = new PrincipalDetails(user);
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    principalDetails,
                    null,
                    principalDetails.getAuthorities()
            );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    private boolean isJwtHeaderValid(String header) {
        return header != null && header.startsWith(JwtProperties.TOKEN_PREFIX);
    }
}

위 로직의 흐름을 그림으로 정리해보면 아래와 같다.

SecurityConfig - JwtAuthorizationFilter 등록

package com.example.jwtserver.config;

import com.example.jwtserver.auth.PrincipalDetailsService;
import com.example.jwtserver.jwt.JwtAuthenticationFilter;
import com.example.jwtserver.jwt.JwtAuthorizationFilter;
import com.example.jwtserver.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final CorsFilter corsFilter;
    private final PrincipalDetailsService principalDetailsService;
    private final UserRepository userRepository;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        AuthenticationManager authenticationManager = authenticationManager(http);

        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagementConfig ->
                        sessionManagementConfig
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter) // 필터 추가
                .addFilter(new JwtAuthenticationFilter(authenticationManager))
                .addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository))
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(registry ->
                        registry.requestMatchers("api/v1/user/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/manager/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                                .requestMatchers("/api/v1/admin/**")
                                .hasAnyRole("MANAGER", "ADMIN")
                                .anyRequest().permitAll())
                .build();
    }

    private AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class);
        sharedObject.userDetailsService(this.principalDetailsService);
        AuthenticationManager authenticationManager = sharedObject.build();
        http.authenticationManager(authenticationManager);
        return authenticationManager;
    }
}

RestApiController - 테스트용 api 구성

@GetMapping("api/v1/user")
public String user(Authentication authentication) {
    PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
    return "<h1>" + principal.getUsername() + "</h1>";
}

@GetMapping("manager/reports")
public String reports() {
    return "<h1>reports</h1>";
}

실행 결과

현재 로직에서 회원 가입한 회원에 대해 MANAGER 권한을 부여해주고 있기 때문에 /api/v1/user/manager/reports 경로를 모두 정상 접근하는 것을 확인할 수 있다.

참고

profile
먹고 살려고 개발 시작했지만, 이왕 하는 거 잘하고 싶다.

0개의 댓글