본문 바로가기
spring

JWT를 이용한 인증 처리

by 쭈꾸마뇽 2021. 5. 29.

JWT

  • Json Web Token의 약자로 클라이언트와 서버간 인증을 사용할 때 사용한다.
  • Server에 사용자 정보를 보관하지 않고 stateless한 서버를 만들기 위해 Client에 사용자 정보를 저장하는데 데이터를 암호화 하여 저장하기 위해 사용
  • Header와 Payload를 사용해 Signature를 계산하기에 내용이 변조되지 않았는지 확인 가능
  • Header, Payload, Signature로 구성이 된며 '.'로구분된다.
Header : 암호화 알고리즘 및 토큰 type 저장
Payload : 토큰에 넣을 데이터 저장
Signature : 헤더의 인코딩 값과 기타 정보를 Secret Key로 암호화한 정보

JWT 동작 절차

  1. 인증을 위해 클라이언트에서 ID, PW를 서버에 전달한다.
  2. 서버는 ID와 PW를 확인하여 인증 정보를 JWT로 만들어 클라이언트에 보낸다.
  3. 클라이언트는 JWT를 Cookie, Session 등에 저장한다.
  4. 인증정보가 필요한 API 호출 시 JWT를 보내 인증을 한다.
  5. 서버는 받은 JWT가 유효한지 검증하여 Response를 클라이언트에 보낸다

구현

JWT 생성

먼저 토큰을 생성해보자.  이 예제에서 토큰에는 만료시간과 사용자 데이터를 넣을것이다.

@PostMapping("/session")
public Map<String, Object> createSession(@RequestBody Map<String, String> input, HttpServletRequest req, HttpServletResponse res) {
    Map<String, Object> profile = Map.of(
        "id", input.get("id"),
        "pw", input.get("pw")
    );
    
    OAuthClient.Token token = oauthClient.createToken();
    
    String jwt = jwtService.create(
        payload -> {
            payload.setExp(token.getExpiration());
            payload.addClaim("info", profile);
    	});

    Cookie jwtCookie = HttpSupport.createCookie(conf -> conf
        .name(JWT_COOKIE_NAME)
        .value(jwt)
        .expires(60 * 60 * 24)
        .secure("https".equals(req.getScheme()))
    );

    res.addCookie(jwtCookie);

    return Map.of(
        "token", jwt,
        "expiration", token.getExpiration().toString(),
        "crew", profile
    );
}
    사용자 데이터를 저장할 profile map을 만들어주고 토큰을 만들어준다.  그다음 토큰과 profile을 이용해 jwt를 만들고 쿠키에 넣어준다.
@Component
public class OAuthClient {
    @Getter
    @ToString
    public static class Token {
        private ZonedDateTime expiration;
    }

    public Token createToken() {
        try {
            Token token = new Token();
            long now = Instant.now().getEpochSecond();

            token.expiration = Instant.ofEpochSecond(now + 60*60*24).atZone(ZoneOffset.UTC);

            return token;
        } catch (JSONException e) {
            throw new AuthenticationException("Fail to parse token!!");
        }
    }
}

토큰에는 만료시간이 들어간다.  만료시간은 현재시간 + 1일로 설정해주었다.

그리고 생성된 토큰을 확인해보면 만료시간과 사용자 정보가 들어간 것을 확인할 수 있다.

세션 확인

@GetMapping("/session")
public Map<String, Object> getSession(HttpServletRequest req, HttpServletResponse res) {
    return Map.of(
        "token", HttpSupport.getCookie(req, JWT_COOKIE_NAME).map(Cookie::getValue),
        "tokenName", HttpSupport.getCookie(req, JWT_COOKIE_NAME).map(Cookie::getName)
    );
}
public static Optional<Cookie> getCookie(HttpServletRequest req, String name) {
    return Stream.ofNullable(req.getCookies())
        .flatMap(Arrays::stream)
        .filter(cookie -> name.equals(cookie.getName()) && !cookie.getValue().isEmpty())
        .findFirst();
}

생성된 세션확인은 쿠키의 값을 찾아보는것으로 할 수 있다.  JWT 토큰을 생성할 때 사용한 토큰 이름을 쿠키에서 찾아서 리턴해주었다.

삭제

@DeleteMapping("/session")
public Map<String, Object> revokeSession(HttpServletRequest req, HttpServletResponse res) {
    HttpSupport.getCookie(req, JWT_COOKIE_NAME)
    	.ifPresent(cookie -> HttpSupport.removeCookie(cookie, res));
    return Map.of(
        "code", "session.revoked",
        "message", "t.pirates session has been revoked."
    );
}
public static void removeCookie(Cookie cookie, HttpServletResponse res) {
    Cookie removed = new CookieConfig()
        .name(cookie.getName()).value("").expires(10)
        .secure(cookie.getSecure())
        .build();
    res.addCookie(removed);
}

로그 아웃시 세션이 삭제되어야 한다.  그러기 위해 JWT 토큰의 값을 빈칸으로 설정해주고 만료시간도 매우 작은값으로 설정해주었다.

적용

프로젝트에서 토큰 인증이 적용되어야 할 api들이 존재한다.  예를 들어 개인 정보를 열람하거나 수정할 때 토큰 인증이 적용되어야 하지만 단순히 게시물을 보거나 일반적인 작업을 할 때는 인증 작업이 필요 없다.  그렇기 때문에 토큰 인증이 적용될 api들에만 토큰 인증이 이뤄질 수 있도록 해줘야 한다.  

@Target({ElementType.TYPE, ElementType.CONSTRUCTOR, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Authenticated {
}

먼저 토큰 인증이 이뤄질 api에 붙여줄 애노테이션을 선언해 주었다.  그리고 인터셉터를 구현해 준다.

public class JwtInterceptor implements HandlerInterceptor {
    private final JwtService jwtService;
    private final String COOKIE_KEY;

    public JwtInterceptor(JwtService jwts, String cookie) {
        this.jwtService = jwts;
        this.COOKIE_KEY = cookie;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {

        // 인증 target 이 아닌경우 pass
        if (!(handler instanceof HandlerMethod) || !isAuthenticationPresent((HandlerMethod) handler)) {
            return true;
        }

        Optional<String> header = getAuthorizationToken(request);
        Optional<String> cookie = HttpSupport.getCookie(request, COOKIE_KEY).map(Cookie::getValue);

        return Stream.concat(header.stream(), cookie.stream())
                .map(jwtService::isUsable)
                .filter(check -> check)
                .findFirst()
                .orElseThrow(() -> new AuthenticationException("Unauthorized access. need to authentication"));
    }

    private boolean isAuthenticationPresent(HandlerMethod handler) {
        return handler.hasMethodAnnotation(Authenticated.class)
                || handler.getBeanType().isAnnotationPresent(Authenticated.class);
    }

    private Optional<String> getAuthorizationToken(HttpServletRequest req) {
        return Optional.ofNullable(req.getHeader("Authorization"))
                .map(token -> token.replaceAll("Bearer", "").trim());
    }
}

api 콜이 들어올 때 인터셉터를 먼저 거치게 된다.  인터셉터의 preHandler를 오버라이딩 하여 만약 요청한 api에 @Authenticated 애노테이션이 붙어 있는 경우(인증 target인 경우) 쿠키와 헤더에서 토큰을 가져와 유요한 토큰인지 검증하게 된다.  만약 유효하지 않은 토큰이라면 예외처리를 해준다.

 

그 다음 토큰에서 사용하고자 하는 값을 꺼내와야 한다.  이를 위해 @JwtClaim 를 선언해 주었다.  이 애노테이션은 api의 파라미터 앞에 붙여서 사용하기 위해 @Target ElementType.PARAMETER 로 설정해 주었고 value는 토큰의 정보를 가져오기 위해 필요한 데이터의 파라미터를 저장할 것이다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtClaim {
    String value() default "";
}
public class JwtSessionArgumentResolver implements HandlerMethodArgumentResolver {
    private final JwtService jwtService;
    private final String COOKIE_KEY;

    public JwtSessionArgumentResolver(JwtService jwtService, String cookie) {
        this.jwtService = jwtService;
        this.COOKIE_KEY = cookie;
    }

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(JwtClaim.class) != null;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        JwtClaim annotation = parameter.getParameterAnnotation(JwtClaim.class);
        Class<?> paramType = parameter.getParameterType();
        String path = String.format("$.%s", annotation.value());

        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);

        Optional<String> header = getAuthorizationToken(request);
        Optional<String> cookie = HttpSupport.getCookie(request, COOKIE_KEY).map(Cookie::getValue);

        return Stream.concat(header.stream(), cookie.stream())
                .map(jwtService::parseClaim)
                .filter(Objects::nonNull)
                .findFirst()
                .map(claim -> JsonPath.parse(claim).read(path, paramType))
                .orElseThrow(() -> new AuthenticationException("Unavailable web token!!!"));
    }

    private Optional<String> getAuthorizationToken(HttpServletRequest req) {
        return Optional.ofNullable(req.getHeader("Authorization"))
                .map(token -> token.replaceAll("Bearer", "").trim());
    }
}

저 애노테이션을 사용하기 위해 HandlerMethodArgumentResolver를 상속받는 커스텀 JwtSessionArgumentResolver를 만들어줬다.  먼저 JwtClaim의 value를 가져와 필요한 정보의 경로를 가져오고 쿠키와 헤더로부터 JWT 토큰을 가져온다.

@Override
public String parseClaim(String token) {
	return parseJwt(token);	
}

private String parseJwt(String token) {
    try {
        Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
        return jsonMapper.writeValueAsString(claims.getBody());
    } catch (Exception e) {
        log.info("Fail to parse web token");
        log.debug("Fail to parse web token", e);
        return null;
    }
}

가져온 토큰을 디코딩하여 String값으로 가져온다.  이 String값은 json형태의 문자열이며 이 값을 위에서 가져온 path를 사용해 해당 path의 값을 리턴하게 된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
   ...
   
    @Bean
    public JwtInterceptor jwtInterceptor() {
        return new JwtInterceptor(jwtService, JWT_COOKIE_NAME);
    }

    @Bean
    public JwtSessionArgumentResolver jwtArgsResolver() {
        return new JwtSessionArgumentResolver(jwtService, JWT_COOKIE_NAME);
    }
}
@Authenticated
@GetMapping("/testApi")
public Map<String, Object> testApi(@JwtClaim("info.id") String id, @JwtClaim("info.pw") String pw) {
    return Map.of(
        "id", id,
        "pw", pw
    );
}

마지막으로 구현한 인터셉터와 JwtSessionArgumentResolver를 Bean에 등록해주면 사용할 수 있다.


소스코드 : https://github.com/rkdals213/jwt

댓글