JWT

강민형·2021년 1월 9일
0

rkdals213/jwt

토큰 생성하기

OAuthClient.Token token = oauthClient.createToken();

...

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!!");
    }
}

Token이라는 Class를 만들어주고 안에 만료일 데이터를 넣었다. 안에 들어갈 데이터는 프로젝트에 맞게 적절히 튜닝하면 된다.

처음 로그인을 진행할때 ID와 PW값을 받고 토큰을 생성한다

String jwt = jwtService.create(
                payload -> {
                    payload.setExp(token.getExpiration());
                    payload.addClaim("info", profile);
                });

...

public String create(Consumer<Payload> composer) {
        Payload payload = new Payload();
        composer.accept(payload);
        return create(payload);
    }

public String create(Payload payload) {
    JwtBuilder builder = Jwts.builder()
            .setHeaderParam("typ", "JWT")
            .setExpiration(Date.from(payload.getExp().toInstant()))
            .signWith(getSecretKey());

    for (Map.Entry<String, Object> entry : payload.getClaims().entrySet()) {
        builder.claim(entry.getKey(), entry.getValue());
    }

    return builder.compact();
}

...

@Getter
class Payload {
    @Setter private ZonedDateTime exp;
    private final Map<String, Object> claims = new HashMap<>();

    public void addClaim(String key, Object value) {
        claims.put(key, value);
    }
}

그다음 실제로 인증에 사용할 jwt토큰을 만들어준다

jwtService에 정의된 create함수를 이용해서 Payload에 들어갈 내용을 넣어서 생성해준다

현재 Payload에는 만료일과 claims 데이터가 있고 claims에는 사용자의 정보(profile)가 info라는 이름으로 Map형태로 들어간다

JwtBuilder를 이용해 정보를 넣어준다음 String값으로 리턴해준다

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

...

public static Cookie createCookie(UnaryOperator<CookieConfig> composer) {
    return composer.apply(new CookieConfig()).build();
}

...

public static class CookieConfig {
    private String name;
    private String value;
    private int expires;
    private boolean secure;

    public CookieConfig name(String name) {
        this.name = name;
        return this;
    }

    public CookieConfig value(String value) {
        this.value = value;
        return this;
    }

    public CookieConfig expires(int expires) {
        this.expires = expires;
        return this;
    }

    public CookieConfig secure(boolean secure) {
        this.secure = secure;
        return this;
    }

    private Cookie build() {
        Cookie cookie = new Cookie(name, value);
        cookie.setMaxAge(expires);
        cookie.setSecure(secure);
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        return cookie;
    }
}

그다음 jwt토큰을 쿠키에 저장한다

HttpSupport라는 클래스에 createCookie 함수를 정의해서 CookieConfig라는 클래스에 정보를 넣어서 생성한다

res.addCookie(jwtCookie);

마지막으로 HttpServletRequest res에 addCookie를 해준다

토큰 확인

HttpSupport.getCookie(req, JWT_COOKIE_NAME).map(Cookie::getValue)
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();
}

getCookie 함수를 정의해서 HttpServletRequest req 에서 getCookies로 쿠키들을 가져온다음 사전에 정의한 JWT_COOKIE_NAME과 일치하는 name을 가진 쿠키를 찾는다

토큰 제거

HttpSupport.getCookie(req, JWT_COOKIE_NAME)
                .ifPresent(cookie -> HttpSupport.removeCookie(cookie, res));

...

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);
}

HttpSupport에 정한 getCookie함수로 쿠키를 먼저 가져온다.

그다음 removeCookie로 제거하는데 단순하게 기존에 있던 JWT_COOKIE_NAME와 같은 이름으로 10초 안에 만료되며 value가 빈칸인 쿠키를 만들고 addCookie를 해서 덮어씌운다

토큰 유효성 검사

@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
    );
}

...

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

...

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtClaim {
    String value() default "";
}

먼저 @Authenticated를 정의해주고 testApi에 걸어준다.

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());
    }
}

...

public boolean isUsable(String token) {
        return checkJwt(token);
    }

private boolean checkJwt(String token) {
    try {
        Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
        Date expiration = claims.getBody().getExpiration();

        return System.currentTimeMillis() <= expiration.getTime();
    } catch (Exception e) {
        log.info("Fail to check web token");
        log.debug("Fail to check web token", e);
        return false;
    }
}

@Authenticated 를 걸어주게 되면 JwtInterceptor 를 거치면서 preHandle 에서 인증 타겟으로 인식한다. 그러면 header와 cookie를 가져온다.

그다음 헤더와 쿠키를 가지고 isUsable로 유효한지 확인한다. 유효성을 확인하는 방법은 jwt토큰에 저장된 만료일자를 가지고 현재시간과 비교해서 시간이 지나게 되면 만료되는 형식이다.

@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!!!"));
}

...

@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;
    }
}

@JwtClaim 어노테이션으로 본인이 필요한 파라미터에 걸어주게 되면 토큰을 분석해서 payload에 있는 데이터를 가져온다. 만약 데이터가 없거나 유효하지 않으면 exception을 던진다.

profile
Since 2020.11.02 / BackEnd Developer / SSAFY 3기

0개의 댓글