Updated:

저번 포스트에 이어 서버에서 사용자 인증 여부를 체크하는 방법에 대해 알아보자
모든 요청에 대해서 사전 체크를 할 수 있도록 필터 클래스를 하나 만든다.
doFilterInternal() 첫 줄 영역에 들어갈 체크로직들은 밑에서 하나씩 살펴보도록 한다.
예제는 ASAP-api 개인 프로젝트 기준으로 설명한다.
전체 코드는 이곳 에서 확인 가능하다.

public class JWTAuthorizationFilter extends OncePerRequestFilter {

    private final String HEADER = "Authorization";
    private final String BEARER = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
		    // JWT 토큰을 디코딩하여 체크하는 로직이 들어갈 영역
    }
}

1. 헤더 스키마 검증

먼저 헤더 스키마를 검증한다.
request 객체에서 “Authorization” 이름을 가진 헤더 영역 값이 “Bearer “ 문자로 시작하는지 체크한다.

private final String HEADER = "Authorization";
private final String BEARER = "Bearer ";

...
// 헤더 스키마 체크
if (!checkJWTToken(request)) {
  outPrintln(response, ResponseCode.TOKN_E001);
  return;
}
...
private boolean checkJWTToken(HttpServletRequest request) {
  String authenticationHeader = request.getHeader(HEADER);
  return authenticationHeader != null && authenticationHeader.startsWith(BEARER);
}

2. JWT 토큰 디코딩, 사용자 고유 번호 검증

다음은 payload 값을 검증하기 위해 . 으로 구분되어있는 토큰의 두번째 영역을 디코딩한다.
디코딩한 값 중 “jti” 키값을 통해서 사용자 고유 번호 여부를 체크한다.
예제에서는 사용자 고유 번호를 업체코드라 칭했고, 이 업체코드는 Vendor라는 enum 클래스로 관리했다.
정의되지 않은 업체라고 판단되면 outPrintln() 함수를 통해서 오류에 맞게 json으로 응답하도록 개발했다.

// 헤더 jwt 토큰의 header 영역 디코딩
String jwtToken = request.getHeader(HEADER).replace(BEARER, "");
byte[] headerBytes = jwtToken.split("\\.")[1].getBytes();
Base64.Decoder decoder = Base64.getDecoder();
byte[] decodedBytes = decoder.decode(headerBytes);

// 업체코드 가져오기
ObjectMapper mapper = new ObjectMapper();
Map<String, String> map = mapper.readValue(new String(decodedBytes), new TypeReference<Map<String, String>>(){});
String jti = map.get("jti");

Vendor vendor = Vendor.find(jti);

// 정의되지않은 업체
if (vendor == Vendor.UNKNOWN) {
	outPrintln(response, ResponseCode.TOKN_E002);
	return;
}

3. JWT 토큰 파싱

다음은 사용자에게 전달받은 JWT 토큰을 secretKey로 파싱 가능한지 검증해보자
secretKey는 각 Vendor마다 필드로 가지고 있다.
“Bearer “ 를 제외한 JWT 토큰을 Jwts를 통해서 파싱 후 body 영역인 Claims 를 가져온다.
파싱이 정상적으로 됐다면 null 이 아닌 객체일 것이다.

String secretKey = vendor.getSecretKey();

// 헤더 jwt 토큰 파싱
Claims claims = Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(jwtToken).getBody();

// 클레임 파싱 오류 체크
if (claims == null) {
	outPrintln(response, ResponseCode.TOKN_E004);
	return;
}

4. 생성일, 만료일 검증

다음은 생성일, 만료일을 검증해보자
필자는 Claims에서 생성일인 “issuedAt” 값만으로 생성일과 만료일을 체크했다.
이유는 토큰 발급 당시 유효 기간을 길게 하기 위해, 생성일과 만료일을 늘릴 수 있으므로
전달받은 생성일 5초 전부터 10분 후까지만 토큰을 사용 가능하도록 개발했다.

Date dtCurrent = new Date(System.currentTimeMillis()); // 현재시간
// 토큰 생성시간, 서버간 시간차가 있을 수 있기에 5초간 텀을 둔다
Date dtIssuedAt = new Date(Objects.requireNonNull(claims).getIssuedAt().getTime() - 5000);
Calendar cal = Calendar.getInstance();
cal.setTime(dtIssuedAt);
cal.add(Calendar.MINUTE, 10);
Date dtExpiration = cal.getTime();  // 토큰 만료시간

// 생성일 체크
if (dtIssuedAt.after(dtCurrent)) {
	outPrintln(response, ResponseCode.TOKN_E005);
	return;
}

// 만료일 체크
if (dtExpiration.before(dtCurrent)) {
	outPrintln(response, ResponseCode.TOKN_E005);
	return;
}

5. 인증 객체 등록

마지막으로 검증이 다 끝나면 인증 객체에 등록 해주면 된다.
특정 권한을 가진 Claims id의 인증 토큰 객체(UsernamePasswordAuthenticationToken) 를 만든 후 인증 객체에 등록한다.
여기서 claims.getId() 로는 “jti” 로 전달된 값이 사용된다.

setSpringAuthentication(claims);
chain.doFilter(request, response);
...
private void setSpringAuthentication(Claims claims) {
	Set<GrantedAuthority> grantedAuthorities = new HashSet<>();
	grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER")); // 고정
	UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(claims.getId(), null, grantedAuthorities);
	
	// 인증 객체 등록
	SecurityContextHolder.getContext().setAuthentication(auth);
}

6. 스프링 시큐리티 설정

필터를 다 만들었으니, 스프링 시큐리티 설정 내용을 반영하는 클래스를 만들어 준다.
@EnableWebSecurity 어노테이션은 스프링 시큐리티를 활성화하는 어노테이션이다.
authorizeRequests() 는 요청에 대한 권한을 지정한다는 뜻이다.
anyRequest().authenticated() 는 어떤 요청이든 인증이 되어야 한다는 뜻이다.
addFilterBefore() 는 지정된 필터인 UsernamePasswordAuthenticationFilter 보다
커스텀 필터인 JWTAuthorizationFilter 가 먼저 실행된다는 뜻이다.

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests().anyRequest().authenticated()
                .and().addFilterBefore(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/"
                , "/swagger-ui.html"
                , "/v2/api-docs"
                , "/webjars/**"
                , "/swagger-resources/**"
                , "/favicon.ico"
                , "/csrf"
                , "/h2-console/**"
                , "/profile"
                , "/token");
    }
}

추가로 UsernamePasswordAuthenticationFilter 란 Form based Authentication 방식으로 인증을 진행할 때
아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다.
쉽게 설명하자면 유저가 로그인 창에서 Login을 시도할 때 보내지는 요청에서 아이디(username)와 패스워드(password) 데이터를 가져온 후
인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임하는 역할을 하는 필터다.
참고로만 알아두고 다음에 다룰 일 있을 때 찾아봐야겠다.

그리고 configure(WebSecurity web) 함수가 있는데
web.ignoring().antMatchers 에 url을 추가해주면, 필터 실행을 건너뛰게 된다.
configure(HttpSecurity http) 에도 아래 코드처럼, url을 추가 후 permitAll() 해줄 수 있는데
이건 접근을 전부 허용한다는 뜻일 뿐 필터와는 무관하다. 추가해준다 하더라도 필터는 무조건 실행하니 혼동하지 말자

http
	.csrf().disable()
	.headers().frameOptions().disable()
	.and()
		.authorizeRequests()
		.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile", "/swagger-ui.html"
				, "/v2/api-docs", "/webjars/**", "/swagger-resources/**", "/favicon.ico", "/csrf"
				, "/api/user/nickname/**", "/api/user/reset").permitAll();

이렇게 Spring Boot에 JWT를 적용하는 방법에 대해 정리해 봤다.
실무에서 사용해 본 지 좀 돼서 기억이 가물가물했는데, 정리하면서 다시금 정리가 됐다! 뿌듯하다!

Leave a comment