개발/Java|Spring

Spring Security JWT 토큰으로 인증하기

달리초이 2023. 2. 7. 17:31

[환경]

Java8

SpringBoot 2.7.7 with Gradle

Spring Security 5.7.6

 

세션(Session) 인증 방식 장단점

  1. JSESSIONID는 서버에서 세션(사용자) 정보를 찾는 Key로만 활용한다. 그 자체로는 개인정보가 들어 있지 않지만, 세션하이재킹 공격을 당할 수 있기때문에 절대적으로 안전하지는 않다.
  2. 서버에 세션 정보를 저장할 공간이 필요하다.
  3. 분산 서버에서는 세션을 공유하기 어렵다.

 

토큰(Token) 인증 방식

장점)

  1. 세션을 관리할 필요가 없어 별도의 서버 저장소가 필요없다.
  2. 서버 분산이나 클러스터 환경처럼 확장성에 좋다.

단점)

  1. 한 번 제공된 토큰은 회수가 어렵다. 
    세션은 서버의 세션 정보를 삭제하게 되면 클라이언트 브라우저의 JSESSIONID는 사용할 수 없게 된다. 그러나 토큰은 세션을 저장하지 않기 때문에 한 번 제공된 토큰은 회수할 수 없다. 그래서 통상 토큰 유효기간을 짧게 설정한다.
  2. 토큰에는 사용자의 정보가 들어 있기 때문에 상대적으로 안정성이 떨어진다.
    따라서 비밀번호나 민감한 개인정보 등을 토큰에 포함시키지 않도록 주의한다.

 

토큰 인증 방식을 이용하는 이유

토큰 인증 방식이 세션 인증 방식보다 상대적으로 안정성이 떨어지지만, 최근 서버 아키텍처들은 수직 확장보다 수평 확장을 사용하는 분산 서버 환경을 추구한다. 때문에 서버가 여러대인 경우가 대부분이다. 따라서 세션 불일치 문제와 세선 스토리지 한계 등의 단점을 극복하고 분산 서버 환경에 적합한 토큰 인증 방식을 많이 이용하고 있다.

 

JWT 구조

https://jwt.io/

토큰 인증 방식 중 가장 널리 알려진 게 JWT(Json Web Token)이다.

 

JWT Encoded 샘플

 

JWT는 이렇게 점(.)을 기준으로 Header, Payload, Signature로 나뉘어져 있으며, 각각의 영역에는 Json 정보가 UTF-8로 인코딩한 뒤 Base64 URL-Safe로 인코딩한 값이 들어가 있다. 사람이 읽을 수 없는 문자열로 보이지만, 손쉽게 디코딩이 가능하기 때문에 딱히 암호화되었다고 볼 수 없는 값이다.

HEADER.PAYLOAD.SIGNATURE

 이 샘플 정보를 Decoding 해 보면,

 

JWT Decoded 예시

 

Header

Header에는 JWT를 검증하는데 필요한 정보를 담고 있다. Signature에 사용한 암호화 알고리즘이 뭔지, Key ID(kid)가 뭔지 정보를 담고 있다.

 

Payload

실질적으로 인증에 쓰이는 데이터를 담고 있다. 데이터의 각각 필드들을 Claim이라고 한다. 대개 Claim에 username을 포함시켜서 인증 시 이 payload에 있는 username(예시에서 sub)을 가져와 유저 정보를 조회하는데 이용한다. 기본적으로 토큰 발행시간(iat)와 (예시엔 없지만)토큰 만료시간(exp)을 포함하는 편이다. 이 외에도 어떤 정보든 Claim에 추가할 수 있다. 하지만 암호화되지 않은 값임을 명심하고 민감정보는 제외하도록 하자.

 

Signature

haeder와 payload는 암호화되지 않았기 때문에 이 정보만으로는 토큰에 대한 진위여부를 판단할 수 없다. 그래서 JWT의 구조에 가장 마지막에 Signature 영억을 두어 위변조 가능성을 막고 토큰 자체의 진위여부를 판단하는 용도로 사용한다. Signature는 Header와 Payload를 합친 뒤 비밀키로 Hashing하고 Base64로 변경한다.

 

Key Rolling

JWT는 Secret Key가 노출되면 사실상 모든 데이터가 유출될 수 있다. 이런 문제를 방지하고자 Secret Key를 여러개 사용하여 키가 노출되는 것에 대비할 수 있다. Secret Key를 여러개 사용하고 수시로 추가/삭제한다면 Secret Key 중 하나가 노출된다 해도 다른 Secret Key와 데이터는 안전한 상태가 된다. 이를 Key Rolling이라고 한다.

 

Key Rolling은 선택사항이며, Secret Key 1개에 Unique한 ID(kid 혹은 key id라고 부름)를 연결시켜 둔다. JWT 토큰을 만들 때 Header에 kid를 포함해 제공하고 서버에서 토큰을 해석할 때 kid로 Secret Key를 찾아서 Signature를 검증한다.

 

 

JWT Util

스프링부트 의존성 추가(Gradle)

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

 

JwtKey

JWT Secret Key를 관리하고 제공한다. 

Key Rolling을 지원한다.

public class JwtKey {
	/**
	 * Kid Key List 외부로 절대 유출되어서는 안된다.(테스트용)
	 */
	private static final Map<String, String> SECRET_KEY_SET = new HashMap<String, String>() {
		{
			put("key1", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
			put("key2", "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ");
			put("key3", "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c");
		}
	};

	private static final String[] KID_SET = SECRET_KEY_SET.keySet().toArray(new String[0]);
	private static Random randomIndex = new Random();

	/**
	 * SECRET_KEY_SET 에서 랜덤한 KEY 가져오기
	 *
	 * @return kid와 key Pair
	 */
	public static Pair<String, Key> getRandomKey() {
		String kid = KID_SET[randomIndex.nextInt(KID_SET.length)];
		String secretKey = SECRET_KEY_SET.get(kid);
		return new Pair<>(kid, Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)));
	}

	/**
	 * kid로 Key찾기
	 *
	 * @param kid kid
	 * @return Key
	 */
	public static Key getKey(String kid) {
		String key = SECRET_KEY_SET.getOrDefault(kid, null);
		if (key == null) {
			return null;
		}
		return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
	}

}

 

JwtUtils

JWT 토큰을 생성하거나 Parsing하는 메소드를 제공한다.

public class JwtUtils {
	/**
	 * 토큰에서 username 찾기
	 *
	 * @param token 토큰
	 * @return username
	 */
	public static String getUsername(String token) {
		// jwtToken에서 username을 찾는다.
		return Jwts.parserBuilder()
				.setSigningKeyResolver(SigningKeyResolver.instance)
				.build()
				.parseClaimsJws(token)
				.getBody()
				.getSubject(); // 토큰에 담긴 정보에서 username을 가져온다.
	}

	/**
	 * user로 토큰 생성
	 * HEADER : alg, kid
	 * PAYLOAD : sub, iat, exp
	 * SIGNATURE : JwtKey.getRandomKey로 구한 Secret Key로 HS512 해시
	 *
	 * @param user 유저
	 * @return jwt token
	 */
	public static String createToken(User user) {
		Claims claims = Jwts.claims().setSubject(user.getUsername());
		Date now = new Date();
		Pair<String, Key> key = JwtKey.getRandomKey();
		return Jwts.builder()
				.setClaims(claims) // 토큰에 담을 정보 설정
				.setIssuedAt(now) // 토큰 발행 시간 설정
				.setExpiration(new Date(now.getTime() + JwtProperties.EXPIRATION_TIME)) // 토큰 만료 시간 설정
				.setHeaderParam(JwsHeader.KEY_ID, key.getKey()) // 토큰에 kid 설정
				.signWith(key.getValue()) // signature 생성
				.compact();
	}

}

 

SigningKeyResolver

JWT의 헤더에서 kid를 찾아서 Key(SecretKey+알고리즘)를 찾아온다.

Signature를 검증할 때 사용한다.

public class SigningKeyResolver extends SigningKeyResolverAdapter {
	public static SigningKeyResolver instance = new SigningKeyResolver();

	@Override
	public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) {
		String kid = jwsHeader.getKeyId();
		if (kid == null)
			return null;
		return JwtKey.getKey(kid);
	}
}

 

 

JWT Filter 만들기

JwtAuthenticationFilter

로그인을 하면 JWT 토큰을 응답 쿠키에 넣어준다.

UsernamePasswordAuthenticationFilter를 상속했기 때문에 기본동작은 거의 비슷하다.

로그인에 성공하면 User 정보로 JWT Token을 생성하고 응답 쿠키에 값을 넣어준다.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	public JwtAuthenticationFilter(
			AuthenticationManager authenticationManager) {
		super(authenticationManager);
	}

	/**
	 * 로그인 인증 시도
	 */
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
			AuthenticationException {
		UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
				request.getParameter("username"),
				request.getParameter("password"),
				new ArrayList<>()
		);
		return getAuthenticationManager().authenticate(authenticationToken);
	}

	/**
	 * 인증에 성공했을 때 사용
	 * JWT Token을 생성해서 쿠키에 넣는다.
	 */
	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		User user = (User)authResult.getPrincipal();
		String token = JwtUtils.createToken(user);
		// 쿠키 생성
		Cookie cookie = new Cookie(JwtProperties.COOKIE_NAME, token);
		cookie.setMaxAge(JwtProperties.EXPIRATION_TIME); // 쿠키 만료 시간
		cookie.setPath("/");
		response.addCookie(cookie);
		response.sendRedirect("/");
	}

	@Override
	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException failed) throws IOException, ServletException {
		response.sendRedirect("/login");
	}
}

 

 

JwtAuthorizationFilter

  1. Cookie에서 JWT  Token을 구한다.
  2. JWT Token을 파싱하여 username을 구한다.
  3. username으로 User를 구하고 Authentication을 생성한다.
  4. 생성된 Authentication을 SecurityContext에 넣는다.
  5. Exception이 발생하면 응답 Cookie를 null로 변경한다.
package com.example.springsecuritystudy.jwt;

import java.io.IOException;
import java.util.Arrays;
public class JwtAuthorizationFilter extends OncePerRequestFilter {

	private final UserRepository userRepository;

	public JwtAuthorizationFilter(UserRepository userRepository) {
		this.userRepository = userRepository;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
			FilterChain filterChain) throws ServletException, IOException {
		String token = null;
		try {
			// cookie에서 JWT token을 가져온다.
			token = Arrays.stream(request.getCookies())
					.filter(cookie -> cookie.getName().equals(JwtProperties.COOKIE_NAME)).findFirst()
					.map(Cookie::getValue)
					.orElse(null);
		} catch (Exception ignored) {
		}
		if (token != null) {
			try {
				Authentication authentication = getUsernamePasswordAuthenticationToken(token);
				SecurityContextHolder.getContext().setAuthentication(authentication);
			} catch (Exception e) {
				Cookie cookie = new Cookie(JwtProperties.COOKIE_NAME, null);
				cookie.setMaxAge(0);
				response.addCookie(cookie);
			}
		}
		filterChain.doFilter(request, response);
	}

	private Authentication getUsernamePasswordAuthenticationToken(String token) {
		String username = JwtUtils.getUsername(token);
		if (username != null) {
			User user = userRepository.findByUsername(username).orElseThrow(UserNotFoundException::new);
			return new UsernamePasswordAuthenticationToken(
					user,
					null,
					user.getAuthorities()
			);
		}
		return null;
	}
}

 

 

SecurityConfig에 JWT 필터 추가

@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {

	private final UserRepository userRepository;

	@Bean
	public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
		return authenticationConfiguration.getAuthenticationManager();
	}


	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		// stopwatch filter
		http.addFilterBefore(
				new StopwatchFilter(),
				WebAsyncManagerIntegrationFilter.class
		);
		// JWT filter
		http.addFilterBefore(
				new JwtAuthenticationFilter(authenticationManager(http.getSharedObject(AuthenticationConfiguration.class))),
				UsernamePasswordAuthenticationFilter.class
		).addFilterBefore(
				new JwtAuthorizationFilter(userRepository),
				BasicAuthenticationFilter.class
		);
		http
				.httpBasic().disable()
				.csrf().disable();
		http
				.rememberMe().disable()
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
		http
				.authorizeHttpRequests(auth -> auth~~~~생략~~~~

 

 

github 전체 코드

https://github.com/clowncdi/spring-security-study

 

GitHub - clowncdi/spring-security-study

Contribute to clowncdi/spring-security-study development by creating an account on GitHub.

github.com

 

 

 

[참고]

패스트캠퍼스 Spring Security - 안성훈

728x90
반응형