[JWT] JWT란

Intro

세션과 쿠키를 공부했고 JWT에 대해서 공부한 내용을 정리한다.

JWT(Json Web Token)

세션, 쿠키, 토큰 이 3가지를 통해서 인증 절차를 거친다.
세션, 쿠키에 대한 포스팅은 여기서 확인!!

JWT는 JSON 웹 토큰으로, 토큰에 인증에 필요한 정보들을 담아서 암호화시킨 토큰을 의미한다. 세션과 토큰과 유사하게 토큰을 HTTP 헤더에 담아서 서버에 전송한다.
토큰의 특징으로는

  1. stateless(무상태)
    • 무상태는 말 그대로 상태유지를 하지 않는다는 것이다. 즉, 시스템에 인증 정보를 세션처럼 저장하지 않는다.
    • 인증 정보를 저장하지 않음으로써 세션은 사용자가 증가하면 관리가 어려워지는 등의 문제점들을 해결한다.
  2. 서명된 토큰
    • 토큰에서 가장 중요한 특징으로는 서명된 토큰이라는 점이다.
    • 토큰에 공개키/개인키를 쌍으로 사용하여 토큰에 서명할 경우 서명된 토큰은 개인키를 보유한 서버가 서명된 토큰이 정상적인 토큰인 지 인증할 수 있다.
    • 이러한 구조로 인해 JWT에 인증정보를 담아 안전하게 인증을 시도할 수 있게 전달할 수 있다.

JWT 구조

img

JWT는 헤더(Header), 내용(Payload), 서명(Signature)로 이뤄져있다. 또한 JWT 형태는 .(점)으로 구분되어 있다.

헤더(Header)

헤더(Header)는 토큰의 서명(signature) 생성에 사용된 암호화 알고리즘과 토큰의 타입을 정의한다.

내용(Payload)

내용(Payload)은 JWT를 통해 알 수 있는 데이터를 담은 공간이다. 즉, 실제로 사용될 정보에 대한 내용을 담고 있는 섹션이다.(ex. 이름, )

내용(Payload)에 담는 정보의 한 조각을 클레임(claim)이라고 부른다. 클레임(claim)은 key:value 형태로 구성되어 있다. (ex. “name” : “park”)

주의할 점으로는 내용(Payload)에는 민감한 정보를 담지 않아야 한다. header, payload는 json으로 디코딩만 되어 있기에 암호화 되지 않아서 jwt를 가지고 디코딩하면 header, payload에 담긴 값을 알 수 있기 때문이다.

클레임은 등록된 클레임(registered claim), 공개 클레임(public claim), 비공개 클레임(Private claim)으로 구성되어 있다.

등록된 클레임(registered claim)은 토큰의 정보를 담기 위해 정해진 클레임들이다.
등록된 클레임 종류(7가지로 정의되어 있다.)

  1. iss(issuer) : 토큰 발급자
  2. sub(subject) : 토큰 제목
  3. aud(audience) : 토큰 대상자
  4. exp(expiration) : 토큰 만료시간
  5. nbf(not before) : 토큰 활성 날짜
  6. iat(issued at) : 토큰 발급시간
  7. jti(jwt id) : JWT 토큰 식별자
1
2
3
4
{
    sub:1,
    iat: 1516239022
}

공개 클레임(public claim)은 사용자 정의 클레임으로 충돌이 방지된 이름을 가져야 하며, 충돌 방지를 위해 클레임 이름을 URI 형식으로 짓는다.

1
2
3
{
    "http://example.com/is_root": true
}

비공개 클레임(Private Claim)은 사용자 정의 클레임으로 클라이언트와 서버가 협의하에 임의로 지정한 정보를 저장해서 사용한다. 공개 클레임과 충돌이 일어나지 않게 사용하면 된다.

1
2
3
4
{
    "userId": "park",
    "username": "youngjin"
}

서명(Signature)

서명은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다.

서명은 헤더(Header)와 내용(Payload)을 서명에 명시된 암호화 방식으로 인코딩하고, 인코딩한 값을 비밀키를 이용하여 헤더에서 정의한 알고리즘으로 해싱을 하고, 이 값을 다시 인코딩하여 생성한다.

여기서 비밀키는 서버만 가지고 있는 키이다. 즉, 서명은 서버가 갖고 있는 개인키로만 암호화를 풀 수 있다.

1
2
3
4
5
6
7
{
    HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    your-256-bit-secret
    )   
}

JWT 장단점 및 주의사항

  1. 토큰은 토큰 자체가 이미 인증된 정보이기에 세션과 달리 인증 상태를 저장하지 않기에 DB를 별도로 두지 않아도 된다.
  2. 쿠키를 전달하지 않아도 되기에 쿠키를 사용함으로써 발생하는 취약점이 사라진다.
  3. 위에서도 언급했지만, Payload는 Base64로 인코딩된 것이기에 Payload를 탈취하여 디코딩하면 데이터를 볼 수 있기에 중요한 데이터를 넣지 않아야 한다.
  4. Stateless 상태이기에 한 번 만든 후에는 제어가 불가능하다. 그렇기에 토큰에 만료시간을 꼭 넣어야 한다.
  5. 토큰의 길이가 길어질 수록 네트워크에 부하가 발생할 수 있다.
  6. 특정 토큰을 강제 만료시키기가 어렵다.(세션은 특정 ID를 로그아웃 시킬 수 있음.)

JWT 로직 정리

헤더(header), 내용(payload), 서명(signature) 각각을 base64를 통해 인코딩하여 JWT 토큰으로 제작한 후 클라이언트에게 돌려준다.(로컬 스토리지에 저장함.)
클라이언트가 요청을 할 때 로컬 스토리지에 있는 JWT 토큰을 함께 실어서 보낸다.
서버는 JWT토큰을 받아서 다시 디코딩하여 다시 암호화했을 때 해당 토큰과 일치하는 지 검증하는 과정을 거친다.


JWT 설정

1. maven 및 gradle 의존성 주입

<!-- maven dependencies-->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.1</version>
</dependency>

2. CorsConfig 설정

CORS(Cross-Origin-Resource-Sharing)란, 다른 도메인에서 온 요청을 처리하기 위한 보안 기술이며 보안성을 유지하면서 클라이언트가 다른 도메인에서 리소스에 접근할 수 있도록 허용한다. (원래는 다른 도메인에서 요청이 들어올 경우 처리가 불가능함)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.apache.catalina.filters.CorsFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;

@Configuration
public class CorsConfig {

   @Bean
   public CorsFilter corsFilter() {
      UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
      CorsConfiguration config = new CorsConfiguration();
      
      config.setAllowCredentials(true); // 서버가 응답할 때 JSON을 자바스크립트에서 처리여부를 설정
      config.addAllowedOrigin("*"); // 모든 IP에 응답을 허용.
      config.addAllowedHeader("*"); // 모든 헤더에 응답을 허용.
      config.addAllowedMethod("*"); // 모든 메서드(GET, POST) 요청을 허용.
      
      source.registerCorsConfiguration("/api/**", config);
      return new CorsFilter();
   }

}

CorsConfiguration 객체를 생성하고 메서드를 통해서 CORS 정책을 정의한 다음
UrlBasedCorsConfigurationSource 클래스와 함께 사용하여 특정 URI 패턴에 대해 적용한다. 설정한 후에 SecurityConfig에 의존성을 주입한다.

3. SecurityConfig 설정

JWT 방식으로 설정해야 하기에 세션으로 로그인했던 파일과는 다르게 제작된다.

  1. stateless 서버 구축(JWT이기에 stateless 상태를 설정한다.)
  2. formLogin을 사용하지 않음.
  3. httpBasic disable(http basic 방식이 아닌 http Bearer 방식)
  4. anymatchers 설정(사용자 별 커스텀 설정)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.cos.jwt.config.jwt.JwtAuthenticationFilter;
import com.cos.jwt.config.jwt.JwtAuthorizationFilter;
import com.cos.jwt.repository.UserRepository;

@Configuration
@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록
public class SecurityConfig extends WebSecurityConfigurerAdapter{	
	
	@Autowired
	private UserRepository userRepository;
	
	@Autowired
	private CorsConfig corsConfig;
	
	//Ioc에 등록
	@Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
	
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
				.addFilterBefore(corsConfig.corsFilter(), UsernamePasswordAuthenticationFilter.class)
				.csrf().disable()
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			.and()
				.formLogin().disable()
				.httpBasic().disable()
				.addFilter(new JwtAuthenticationFilter(authenticationManager()))
				.addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))
				.authorizeRequests()
				.antMatchers("/api/v1/user/**")
				.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
				.antMatchers("/api/v1/manager/**")
					.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
				.antMatchers("/api/v1/admin/**")
					.access("hasRole('ROLE_ADMIN')")
				.anyRequest().permitAll();
	}
}
  1. .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
    • corsFilter를 UsernamePasswordAuthenticationFilter 이전에 실행 시킨다.
  2. .formLogin().disable()
    • formLogin 방식을 사용하지 않는다.
  3. .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    • stateless 방식을 사용한다.
  4. .httpBasic().disable()
    • httpBasic 방식이 아닌 httpBearer 방식을 사용한다.

JWT 필터 설정

JwtAuthenticationFilter

JwtAuthenticationFilter란, UsernamePasswordAuthenticationFilter를 상속 받는 클래스로 JWT 인증을 처리하기 위한 클래스이다.

  • attemptAuthentication, successfulAuthentication를 오버라이딩하여 클래스를 제작한다.
    • attemptAuthentication 메서드는 인증을 처리
    • successfulAuthentication 메서드는 인증 성공 후 JWT 토큰을 생성하여 Header에 Response한다.

JwtAuthenticationFilter

JwtAuthenticationFilter란, JWT의 인가를 처리하는 역할을 하는 클래스이다. (인가란, 로그인을 했을 때 해당 사용자가 맞는 지 확인하는 절차)

JwtAuthenticationFilter에서 JwtAuthenticationFilter에서 발급해준 JWT 토큰을 검증하고 유효한 토큰인지 확인한 후에 클라이언트의 요청을 처리한다.

JwtAuthenticationFilter는 BasicAuthentication을 상속하여 JWT 방식은 Basic방식이 아니기 때문에 오버라이딩하여 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 인증, 권한이 필요한 주소 요청이 있을 때 해당 필터를 탄다. 
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		System.out.println("인증, 권한이 필요한 주소 요청 발생");
		
		String jwtHeader = request.getHeader("Authorization");
		System.out.println("jwtHeader: " + jwtHeader);
		
		// Header가 있는 지 확인
		if(jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
			chain.doFilter(request, response);
			return;
		}
		
		// JWT 토큰을 검증해서 정상적인 사용자 인지 확인
		String jwtToken = request.getHeader("Authorization")
				.replace("Bearer", "");
		System.out.println("jwtToken:" + jwtToken);
		//
		String username = 
				JWT.require(Algorithm.HMAC512("secret")).build().verify(jwtToken).getClaim("username").asString();
		System.out.println("username: "+ username);
		
		if (username != null) {
			System.out.println("username 재확인:" + username);
			User userEntity = userRepository.findByUsername(username);
			System.out.println("userEntity 확인:" + userEntity);
			PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
			
			// JWT 토큰 서명을 통해 정상이면 Authentication 객체를 생성.
			Authentication authentication =
					new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
			// 세션 공간에 Authentication을 넣음.
			// 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장함.
			SecurityContextHolder.getContext().setAuthentication(authentication);
			
			chain.doFilter(request, response);
		}
		
	}

SecurityConfig 추가 설정(Filter 추가 시)

1
2
3
// 로그인을 진행하는 필터이기에 AuthenticationManager를 파라미터로 담아야 한다.
.addFilter(new JwtAuthenticationFilter(authenticationManager())) 
.addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))

참고 사이트

JWT 참고 사이트1 JWT 참고 사이트2 JWT 참고 사이트3 JWT 참고 영상 - 메타코딩