Hansel

코드로 보는 JWT 토큰 인증 과정 본문

프로젝트/과정

코드로 보는 JWT 토큰 인증 과정

핑슬 2022. 4. 30. 22:23

우선 유저의 요청에서 JWT가 유효한지, 인증이 가능한지 검증할 수 있도록 필터를 하나 생성한다.

OncePerRequestFilter는 요청 당 딱 한번만 작동하는 필터이다.

 

우선 31번 라인에서 request.getHeader를 통해 Authorization에 담긴 value를 받아온다.

JWT 토큰은 Bearer 'jwt' 형식으로 되어있기 때문에 받아온 String은 반드시 Bearer로 시작해야 한다.

헤더에 이런식으로 JWT가 담겨온다.

 

우리가 필요한건 Base64로 인코딩된 JWT이다. 따라서 앞에 Bearer는 필요가 없다.

 

if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")){
    jwt = authorizationHeader.substring(7);
    username = jwtUtil.extractUsername(jwt);
}

우리가 받아온 헤더의 Authorization value가 Null이 아니고 Bearer로 시작한다면 그건 JWT 이다. 

substring으로 Bearer + 공백까지 제거해주고 순수 jwt를 가져온다.

그 후 이전에 작성한 jwtUtil의 메서드를 이용해 jwt의 페이로드에서 subject를 가져오는 것이다.

 

다시 JwtUtil로 돌아가 이 세 메서드를 분석해보자.

extractUsername은 JWT의 페이로드에서 subject를 가져오는 것이다. 

(JWT를 build 하는 과정에서 subject를 username으로 설정했기 때문에 그렇다)

 

extractUsername 메서드가 호출되면 그 안에서 클레임의 subject(여기선 username이다)와 토큰을 extractClaim의 파라미터로 넘겨 호출한다.

(Claims::getSubject를 따로 변수로 빼낸 이유는 테스트용이다. 전혀 필요 없는 코드이다.)

 

Function과 :: 문법이 조금 생소할수도 있다.

이것도 잠깐 간단하게 알아보고 넘어가자.

 

Function은 자바8에서 등장한 인터페이스로 명시된 오브젝트 T를 받아 오브젝트 R로 반환해준다고 한다.여기서는 Claims과 String을 다루니 Claims를 String으로 반환해주는구나 생각하면 된다.

 

public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
    final Claims claims = extractAllClaims(token);
    return claimsResolver.apply(claims);
}

private Claims extractAllClaims(String token) {
    return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
}

그럼 extractClaim이 호출됐다. 여기선 또 extractAllClaims의 파라미터로 token을 넘기고 해당 메서드를 호출한다.

 

extractAllClaims는 token을 받아 서버가 가진 시크릿 키로 해당 jwt를 복호화한 후 페이로드의 데이터를 넘겨주는 역할을 한다.

 

내가 현재 JWT 페이로드에 넣은 데이터는 username, 토큰 발급 기간, 만료 기간 이렇게 3개이다.

따라서 이 3개의 데이터를 꺼내올 수 있는데 파라미터로 넘긴 claimsResolver는 Claims::getSubject였다.

 

그러니 extractAllClaims에서 넘긴 바디 값을 가지고 apply 메서드를 호출하면 username을 받아올 수 있다.

 

 

다시 원점으로 돌아가서

if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")){
    log.info("jwtRequestFilterCall");
    jwt = authorizationHeader.substring(7);
    username = jwtUtil.extractUsername(jwt);
}

여기서 extractUsername을 살펴보고 왔다.

토큰에 담긴 username을 성공적으로 가져오게 됐다.

 

이제 다음으로 넘어가서 아래 코드를 살펴보자

 //SecurityContextHolder.getContext().getAuthentication()==null 이미 인증된 유저가 아닌지 체크
    if(username!=null && SecurityContextHolder.getContext().getAuthentication()==null){
        PrincipalDetails userDetails = (PrincipalDetails) this.principalDetailsService.loadUserByUsername(username);

        //JWT가 유효한지 체킹
        //if(jwtUtil.validateToken(jwt,userDetails)){
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
            	userDetails,null,userDetails.getAuthorities());

            usernamePasswordAuthenticationToken
                    .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        }
    //}
    chain.doFilter(request,response);
}

username이 null이라는 것은 페이로드에서 가져온 username이 없거나 아예 JWT 가 없었다는 것이다.

또한 인증된 유저는 SecurityContextHolder에 그 정보를 저장한다. 따라서 그게 비어있다면 현재 인증된게 없는것이다.

(사실 extractAllClaims 에서 검증을 거치기 때문에 username이 null이 나오긴 어렵다. 검증에 실패하면 exception을 throw 한다. 하지만 헤더에 토큰이 없으면 null이기 때문에 필요하긴 하다.)

 

 

토큰에 별 문제가 없으면 기존에 작성한 userDetailsService를 이용해 UserDetails(Principal)을 생성하고

그 principal을 이용해 Spring Security의 Authentication을 생성한다. 

 

setDetails는 현재 요청에 대한 추가 정보를 세팅하는데 선택사항이다. IP 주소도 가능하다는데 지금은 쓸 용도가 딱히 생각나지 않는다.

 

마지막으로 SecurityContextHolder에 인증 정보를 저장하고 다음 필터로 넘겨주면 인증 과정은 종료된다!

 

작성한 JWT 필터를 스프링 시큐리티 config에 필터 체인 걸어주는 걸 꼭 까먹지 말자.

 

이제 JWT를 현재 진행중인 프로젝트에 적용시켜야겠다.