[Spring] Spring Security - JWT

🤖 Spirng Security?

강력한 사용자 인증 및 Access 제어 framework이다. 이는 Java 애플리케이션에 인증 및 권한 부여를 제공하는데 중점을 두었으며 다양한 필터를 사용하여 커스터마이징이 가능하다.

용어

'인증(Authentication)은 주체(Principal)의 신원(Identity)를 증명하는 과정입니다.' 라는 주장을 검증하는 과정이다.

  • 주체(Principal)
    보통 유저(사용자)를 가리키며 주체는 자신을 인증해달라고 신원 증명 정보, 즉 Credential을 제시한다. 주체가 유저일 경우 크레덴셜은 대개 패스워드이다.

  • 인가(Authorization, 권한 부여)
    인증을 마친 유저에게 권한 Authority을 부여하여 대상 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정이다. 인가는 반드시 인증 과정 이후 수행돼야하며 권한은 롤 형태로 부여하는 것이 일반적이다.

  • 접근통제(Access Control, 접근 제어)
    애플리케이션 리소스에 접근하는 행위를 제어하는 일이다. 따라서 어떤 유저가 어떤 리소스에 접근하도록 허락할지를 결정하는 행위, 즉 접근 통제 결정이 뒤따른다. 리소스의 접근 속성과 유저에게 부여된 권한 또는 다른 속성들을 견주어 결정한다.

🪴 FilterChain

  • Spring Security는 표준 서블릿 필터를 사용한다.

  • 다른 요청들과 마찬가지로 HttpServletRequestHttpServletResponse를 사용한다.

  • Spring Security는 서비스 설정에 따라 필터를 내부적으로 구성한다. 각 필터는 각자 역할이 있고 필터 사이의 종속성이 있으므로 순서가 중요하다.

  • XML Tag를 이용한 네임스페이스 구성을 사용하는 경우 필터가 자동으로 구성되지만, 네임스페이스 구성이 지원하지 않는 기능을 써야하거나 커스터마이징된 필터를 사용해야 할 경우 명시적으로 빈을 등록 할 수 있다.

클라이언트가 요청을 하면 DelegatingFilterProxy가 요청을 가로채고 Spring Security의 빈으로 전달한다. ( DispatcherServlet보다 먼저 실행 된다.)
DeletgatingFilterProxy는 web.xml과 applicationContext 사이의 링크를 제공한다.
따라서 DelegatingFilterProxy는 한 마디로 Spring의 애플리케이션 컨텍스트에서 얻은 Filter Bean을 대신 실행한다.
그러니 이 Bean은 javax.servlet.Filter를 구현해야한다. 이 포스팅에서는 jwtAuthenticationFilter가 되겠다. (밑에 소스 코드 참조)

Spring Security Filter Chain은 아래와 같이 다양하며 커스마이징이 가능하다.

  • SecurityContextPersistentFilter :
    SecurityContextRepository에서 SecurityContext를 가져와서 SecurityContextHolder에 주입하거나 반대로 저장하는 역할을 합니다.

  • LogoutFilter :
    logout 요청을 감시하며, 요청시 인증 주체(Principal)를 로그아웃 시킵니다.

  • UsernamePasswordAuthenticationFilter :
    login 요청을 감시하며, 인증 과정을 진행합니다.

  • DefaultLoginPageGenerationFilter :
    사용자가 별도의 로그인 페이지를 구현하지 않은 경우, 스프링에서 기본적으로 설정한 로그인 페이지로 넘어가게 합니다.

  • BasicAuthenticationFilter :
    HTTP 요청의 (BASIC)인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장합니다.

  • RememberMeAuthenticationFilter :
    SecurityContext에 인증(Authentication) 객체가 있는지 확인하고 RememberMeServices를 구현한 객체 요청이 있을 경우, RememberMe를 인증 토큰으로 컨텍스트에 주입합니다.

  • AnonymousAuthenticationFilter :
    이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 익명 사용자로 취급합니다.

  • SessionManagementFilter :
    요청이 시작된 이후 인증된 사용자인지 확인하고, 인증된 사용자일 경우 SessionAuthenticationStrategy를 호출하여 세션 고정 보호 매커니즘을 활성화 하거나 여러 동시 로그인을 확인하는 것과 같은 세션 관련 활동을 수행합니다.

  • ExceptionTranslationFilter :
    필터체인 내에서 발생되는 모든 예외를 처리합니다.

  • FilterSecurityInterceptor :
    AccessDecisionManager로 권한부여처리를 위임하고 HTTP 리소스의 보안 처리를 수행합니다.

🏰 Spring Security 구조

  1. 사용자가 입력한 사용자 정보를 가지고 인증을 요청한다.(Request)

  2. AuthenticationFilter가 이를 가로채 UsernamePasswordAuthenticationToken(인증용 객체)를 생성한다

  3. 필터는 요청을 처리하고 AuthenticationManager의 구현체 ProviderManager에 Authentication과 UsernamePasswordAuthenticationToken을 전달한다.

  4. AuthenticationManager는 검증을 위해 AuthenticationProvider에게 Authentication과 UsernamePasswordAuthenticationToken을 전달한다.

  5. 이제 DB에 담긴 사용자 인증정보와 비교하기 위해 UserDetailsService에 사용자 정보를 넘겨준다.

  6. DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다.

  7. AuthenticationProvider는 UserDetails를 넘겨받고 비교한다.

  8. 인증이 완료되면 권한과 사용자 정보를 담은 Authentication 객체가 반환된다.

  9. AuthenticationFilter까지 Authentication정보를 전달한다.

  10. Authentication을 SecurityContext에 저장한다.

Authentication 정보는 결국 SecurityContextHolder 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다. 세션에 사용자 정보를 저장한다는 것은 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.

🤡 JWT(Json Web Token)

JWT(Json Web Token)은 웹표준(RFC7519)로서 일반적으로 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다.

HEADER.PAYLOAD.SIGNATURE

일반적으로 헤더, 페이로드, 서명 세 부분을 점으로 구분하는 구조로 되어있다.
헤더에는 토큰 타입과 해싱 알고리즘을 저장하고
페이로드에는 실제로 전달하는 정보, 서명에는 위변조를 방지하기 위한 값이 들어가게 된다.

사용자 인증이 완료될 시 서버 측에서는 JWT 토큰을 Body에 담아 클라이언트에 전달하고 그 후 요청하는 API 서버에 JWT 토큰을 헤더에 담아 요청을 하게 되면 이를 확인하고 권한이 있는 사용자에게 리소스를 제공하게 된다.


🧞‍♂️ 예제

개발 환경

gradle
Java 8

build.gradle

dependencies {
    .
    .
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    .
    .
    .
}

일단 필요한 라이브러리들을 받아주고, domain 객체부터 작성한다.

User.java

@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name="USERS")
public class User{

    @Id
    @Column(name = "user_id")
    private String userId;

    @Column(name = "user_name")
    private String userName;

    @Column(name = "user_pwd")
    private String userPwd;

    @Column
    private String company;

    @Column
    private String position;
}

그 후, DB 접근 시 사용할 Repository와 Service를 만들어줬다. (Service의 경우 나중에 만들어도 된다.)

UserService.java
UserService의 경우 인증용 도메인과 API 용을 따로 만든다고 한다.
(여기선 하나만 만든다.)

@Service
public class UserService {

    @Autowired
    UserRepository userRepository;

    .
    .
    .
    
    public Optional<User> findByIdPw(String id) {
    	return userRepository.findById(id);
    }

    .
    .
}

그 후 Spring Security Filter Chain을 사용한다는 것을 명시해줘야 한다.
WebSecurityConfigurerAdapter를 상속 받은 Configuration 객체를 일단 만들어주고 거기에 @EnableWebSecurity 어노테이션을 달아주면 된다.

SecurityConfiguration.java

@Slf4j
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private final JwtAuthenticationEntryPoint unauthorizedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .cors() //(1)
            .and()
            .csrf() //(2)
            .disable()
            .exceptionHandling() //(3)
            .authenticationEntryPoint(unauthorizedHandler)
            .and()
            .sessionManagement() //(4)
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests() // (5)
            .antMatchers(RestControllerBase.API_URI_PREFIX + "/auth/**")
            .permitAll()
            .antMatchers(RestControllerBase.API_URI_PREFIX + "/**")
            .authenticated()
            .and()
            .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            .formLogin().disable().headers().frameOptions().disable();
            
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("*");
        configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
        configuration.addAllowedHeader("*");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
    
    //비밀번호 암호화를 위한 Encoder 설정
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 }

(1) 교차출처 리소스 공유(CORS) 설정이다. 만약 react나 vue.js를 클라이언트로 사용한다면 포트 넘버가 다르다.
그래서 corsConfigurationSource Bean을 통해

  • addAllowedOrigin (Access-Control-Allow-Origin)에 모든 출처를 허용
  • setAllowedMethods에 위와 같은 Request 방식을 허용
  • addAllowedHeader (Access-Control-Allow-Methods)를 허용하도록 설정

(2) CSRF(Cross Site Request Forgery) 사이트 간 요청 위조 설정이다. 이건 설정이 복잡하기도하고 REST API 서버용으로만 사용할 것이기 때문에 disable로 처리했다.

(3) 인증, 허가 에러 시 공통적으로 처리해주는 부분이다.

🐝 Spring security JWT 처리 순서

1. JwtAuthFilter

  • 해당 필터는 서버에 접근하는 모든 API의 JWT 토큰에 대해 유효성 검사를 진행한다. (ex. 토큰의 위변조, 유효 기간 등등)
  • 검사 진행 후 컨트롤러로 보내게 된다.
  • API 중 로그인 전에 허용되어야 하는 API에 대해 FilterSkipMatcher 가 예외 처리를 해준다. (로그인, 회원가입 페이지, CSS, ...)
  • 로그인 처리만큼은 JWT가 생성되기 전이기 때문에 예외 처리가 되어 FormLoginFilter 로 접근하게 된다.

2. FormLoginFilter

  • 전달 받은 username과 password를 DB 와 비교하여 맞는 경우 JWT를 생성하여 응답에 포함하여 응답해준다.

3. Client

  • 클라이언트에서 응답 받은 JWT를 로컬이나 쿠키에 저장한다.
  • 그 이후 서버에 요청하는 API에 JWT 토큰을 Header에 포함하여 요청하게 된다.

4. JwtAuthFilter

  • 요청한 API 를 헤더에 포함한 JWT 에 대해 유효성 검사 후 허용해준다.
  • 허용과 동시에 로그인 정보를 생성해서 컨트롤러로 넘어간다.(UserDetailsImpl)

5. Controller

  • 로그인된 사용자의 관심 상품을 조회하여 반환한다.

좋은 웹페이지 즐겨찾기