카테고리 없음

[Spring 게시판] 14. Access·Refresh Token토큰 구현

낭만적인 부자 2023. 9. 5. 01:57

이전 포스팅에서 작성한 User에게 JWT 토큰을 발행해보겠습니다.

또한 발행한 토큰을 검증하고 권한을 부여하여 API에 접근할 수 있게 하겠습니다.

 


폴더 구성

다음과 같은 폴더와 파일을 생성하였습니다.

 

TokenProvider.java : 토큰을 발급, 검증, 추출하는 로직입니다. 로그인을 하는 시점에 이 클래스를 이용하여 토큰을 발급할 겁니다.

 

JwtAuthenticationFilter.java : API 요청이 들어올 때 헤더를 분해하고 TokenProvider를 이용해 검증 시행. 토큰이 검증에 실패하면 API 요청을 거절합니다. 쉽게 말해서 토큰을 필터링 하는 겁니다. 사용 가능한 토큰이라면 API를 사용할 수 있게 통과시켜 주는 부분입니다.

 

 


build.gradle

 

build.gradle 파일에

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

다음 3개의 의존성을 추가해 주겠습니다.

 

 

이후 build.gradle 우클릭 후 Gradle->Refresh Gradle Project 해서

변경사항 적용하겠습니다.

 

 


TokenProvider.java

 

package com.homepage.config;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import com.homepage.user.entity.User;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class TokenProvider {
	//application.properties에서 사전에 설정한 상수값 가져오기
	
	// 토큰 생성에 쓰일 시크릿 키
	@Value("${application.security.jwt.secret-key}")
	private String secretKey;
	// 액세스 토큰 만료기간 : 6시간
	@Value("${application.security.jwt.access-token.expiration}")
	private long accessExpiration;
	//리프레시 토큰 만료기간 : 7일
	@Value("${application.security.jwt.refresh-token.expiration}")
	private long refreshExpiration;
	
	// 토큰에 설정되어있는 "sub"(subject)를 추출합니다.
	public String extractId(String token) {
		return extractClaim(token,Claims::getSubject);
	}
	
	// 토큰에 설정되어있는 "exp"(만료기간)를 추출합니다.
	private Date extractExpiration(String token) {
		return extractClaim(token, Claims::getExpiration);
	}
	
	// 사용자 정보 조각인 Claim울 추출합니다.
	public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
		final Claims claims= extractAllClaims(token);
		return claimsResolver.apply(claims);
	}
	
	// 토큰의 Payload 데이터를 가져옵니다..
	private Claims extractAllClaims(String token) {
		return Jwts
				.parserBuilder()
				.setSigningKey(getSignInKey())
				.build()
				.parseClaimsJws(token)
				.getBody();
	}
	
	// 토큰을 생성합니다. 파라미터인 expiration에 따라서 액세스 토큰 생성과 리프레시 토큰 생성에 재활용 할 수 있습니다. 
	public String generateToken(
			Map<String, Object> extraClaims,
			String user_id,
			long expiration
	) {
		return  Jwts
				.builder()
				.setClaims(extraClaims)
				.setSubject(user_id)
				.setIssuedAt(new Date(System.currentTimeMillis()))
				.setExpiration(new Date(System.currentTimeMillis()+expiration))
				.signWith(getSignInKey(), SignatureAlgorithm.HS256)
				.compact();
	}
	
	// generateToken을 이용하여 리프레시 토큰을 생성합니다.
	public String generateRefreshToken(String user_id) {
		HashMap<String, Object> map = new HashMap<>();
		String refreshToken=generateToken(map, user_id, refreshExpiration);
		
		return refreshToken;
	}
	
	// generateToken을 이용하여 액세스 토큰을 생성합니다.
	public String generateAccessToken(String user_id) {
		HashMap<String, Object> map = new HashMap<>();
		return generateToken(map, user_id, accessExpiration); 
	}
	
	// 토큰에서 id값을 추출한 뒤, 인자로 받은 User 데이터와 비교합니다. extractId 이용
	public boolean isTokenValid(String token, User user) {
		final Integer id=Integer.parseInt(extractId(token));
		return (id.equals(user.getId())) && !isTokenExpired(token);
	}
	
	// 토큰 만료 상태 확인
	private boolean isTokenExpired(String token) {
		return extractExpiration(token).before(new Date());
	}
	
	// 시크릿 키를 이용하여 서명 키를 가져옵니다.
	// 서명 키를 알아야 토큰이 위조되지 않았는지 검증 가능합니다.
	private Key getSignInKey() {
  		byte[] keyBytes=Decoders.BASE64.decode(secretKey);
		return Keys.hmacShaKeyFor(keyBytes);
	}
}

 

 


application.properties

 

토큰 유지 시간을 밀리 초 단위로 입력합니다.

액세스 토큰은 6시간,

리프레시 토큰은 7일을 설정하겠습니다.

 


JwtAuthenticationFilter.java

 

package com.homepage.config;

import java.io.IOException;

import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.homepage.user.entity.User;
import com.homepage.user.service.UserService;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

	private final TokenProvider tokenProvider;
    // User 데이터를 가져오기 위해 선언
	private final UserService userService;
	
    // OncePerRequestFilter의 doFilterInternal를 주로 오버라이딩 해서 사용합니다.
	@Override
	protected void doFilterInternal(
		@NonNull HttpServletRequest request,
		@NonNull HttpServletResponse response,
		@NonNull FilterChain filterChain) throws ServletException, IOException {
		
        // HTTP 요청의 헤더를 확인합니다.
        // Key 값이 Authorization인 요소를 찾아 저장합니다.
	    final String authHeader = request.getHeader("Authorization");
	    final String jwt;
	    final String userId;
	    
        // 토큰이 없거나 "Bearer "로 시작하지 않으면 권한을 부여하지 않고 넘깁니다.
	    if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
	    	filterChain.doFilter(request, response);
	    	return;
	    }
	    
        // authHeader의 7번재 값까지 
	    jwt = authHeader.substring(7);
	    userId = tokenProvider.extractId(jwt);
	    
	    if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
	    	User user = userService.loadUserByUsername(userId);
          
		    if (tokenProvider.isTokenValid(jwt, user)) {
		        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
				user,
				null,
				user.getAuthorities()
		        );
		        authToken.setDetails(
		            new WebAuthenticationDetailsSource().buildDetails(request)
		        );
		        SecurityContextHolder.getContext().setAuthentication(authToken);
		    }
	    }
	    filterChain.doFilter(request, response);
	  }
}

OncePerRequestFilter를 상속받습니다.

OncePerRequestFilter는 Spring Security에서 제공하는 필터 중 하나입니다.

이름 그대로 HTTP 요청 당 한 번만 실행되도록 보장하는 역할을 합니다.

DispatcherServlet 이전에 실행되는 것이 특징입니다.

 

 


SecurityConfig.java

 

package com.homepage.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	
	private final JwtAuthenticationFilter jwtAuthFilter;
	private final AuthenticationProvider authenticationProvider;
	
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
		http.authorizeHttpRequests((authorizeRequests) ->
			authorizeRequests
            	
			// board 요청
			.requestMatchers(HttpMethod.POST, "/board/post").authenticated()//게시물 등록
			.requestMatchers(HttpMethod.GET, "/board/post/{post-Id}").permitAll()//게시물 조회
			.requestMatchers(HttpMethod.PATCH, "/board/post/{post-Id}").authenticated()//게시물 수정
			.requestMatchers(HttpMethod.DELETE, "/board/post/{post-Id}").authenticated()//게시물 삭제
			.requestMatchers(HttpMethod.GET, "/board/posts/{category}").permitAll()//게시물 페이징
	            	
			.anyRequest().hasAnyRole("ADMIN"))
			
		.sessionManagement((sessionManagement) ->
			sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
		.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
		.authenticationProvider(authenticationProvider);
		
		return http.build();
	}
}

 

개발을 하다보면 꾸준히 수정해야 하는 FilterChain 부분입니다.

필터 체인이라는 말은 보안 관련 작업을

 체인처럼 연결된 형태로 처리한다는 의미입니다.

코드가 .을 기준으로 줄줄이 연결되어 있습니다.

 

코드를 분해해 보겠습니다.

 

 

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception

Spring Security에서 제공하는 SecurityFilterChainBean에 등록하여

스프링 컨테이너가 해당 코드를 관리하도록 합니다.

@Configuration 내에서 선언된 Bean이므로

보안 설정이나 테이터베이스의 연결 등 애플리케이션 전반에 영향을 미칩니다.

 

또한 인자로 전달된 HttpSecurity는 여기서 가장 중요한 부분입니다.

웹의 보안을 구성하고 생성합니다.

이 값을 설정하고 반환함으로써 보안을 설정할 수 있습니다.

 

 

http.authorizeHttpRequests((authorizeRequests) ->
	authorizeRequests
            	
	// board 요청
	.requestMatchers(HttpMethod.POST, "/board/post").authenticated()		//게시물 등록
	.requestMatchers(HttpMethod.GET, "/board/post/{post-Id}").permitAll()		//게시물 조회
	.requestMatchers(HttpMethod.PATCH, "/board/post/{post-Id}").authenticated()	//게시물 수정
	.requestMatchers(HttpMethod.DELETE, "/board/post/{post-Id}").authenticated()	//게시물 삭제
	.requestMatchers(HttpMethod.GET, "/board/posts/{category}").permitAll()		//게시물 페이징
            	
	.anyRequest().hasAnyRole("ADMIN"))

authorizeHttpRequests 메서드를 이용하여

Http 요청에 대한 권한을 설정합니다.

-토큰 검증을 받아야 하는 api는 authenticated()를,-검증이 필요없는 api는 permitAll()을 붙여줍니다.

 

예를 들어서 게시판의 게시글을 보는 것은 로그인이 필요 없기 때문에
permitAll을 선언해 줬습니다.
하지만 게시글을 등록하는 것은 로그인을 해야하기 때문에
authenticated를 사용했습니다.

 

끝 부분에는 ".anyRequest().hasAnyRole("ADMIN"))"를 추가하여

모든 다른 Request는 ADMIN 권한을 가진 유저만 접근할 수 있도록 했습니다.

 

 

.sessionManagement((sessionManagement) ->
	sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

sessionManagement는 세션과 관련된 설정을 정의합니다.

그렇다면, 세션이란 무엇일까요?

세션은 사용자와 서버간의 상태를 의미합니다.

사용자가 어플리케이션을 사용하기 위해 웹페이지에 접속하여

로그인을 하고 창을 닫기까지의 모든 데이터가 저장되는 것이죠

 

세션에 대한 글을 이후에 추가하겠습니다.

 

 

하지만 우리는 RESTful API 방식을 이용하기 때문에

SessionCreationPolicy.STATELESS로 설정하겠습니다.

↓↓

앞서 authorizeHttpRequests에 작성한 API들은 독립적으로 작동합니다.

게시글을 요청하면 게시글의 내용을 보내주면 됩니다.

프론트에서 API를 요청하면 백엔드에서는 응답을 전해주면 끝이라는 것이죠.

그렇기 때문에 매 요청마다 토큰을 검증하게 됩니다.

이러한 방식이 RESTful API 방식입니다.

 

(RESTful API에 대한 게시글도 차후에 올리겠습니다.)

 

 

.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
	.authenticationProvider(authenticationProvider);
		
	return http.build();

마지막으로 앞서 작성한 JwtAuthenticationFilter를 필터에 추가합니다.

첫번째 줄을 해석하면

UsernamePasswordAuthenticationFilter를 실행하기 앞서

우리가 작성한 jwtAuthFilter로 필터링을 먼저 진행한다는 뜻입니다.

필터 체인은 순차적으로 진행되기 때문에 다음과 같이

정교하게 보안을 설정할 수 있습니다.

 

.authenticationProvider(authenticationProvider)
실제 사용자를 인증하고 Authenticaion 객체를 반환합니다.