JWT
- Json Web Token의 약자로 클라이언트와 서버간 인증을 사용할 때 사용한다.
- Server에 사용자 정보를 보관하지 않고 stateless한 서버를 만들기 위해 Client에 사용자 정보를 저장하는데 데이터를 암호화 하여 저장하기 위해 사용
- Header와 Payload를 사용해 Signature를 계산하기에 내용이 변조되지 않았는지 확인 가능
- Header, Payload, Signature로 구성이 된며 '.'로구분된다.
Header : 암호화 알고리즘 및 토큰 type 저장
Payload : 토큰에 넣을 데이터 저장
Signature : 헤더의 인코딩 값과 기타 정보를 Secret Key로 암호화한 정보
JWT 동작 절차
- 인증을 위해 클라이언트에서 ID, PW를 서버에 전달한다.
- 서버는 ID와 PW를 확인하여 인증 정보를 JWT로 만들어 클라이언트에 보낸다.
- 클라이언트는 JWT를 Cookie, Session 등에 저장한다.
- 인증정보가 필요한 API 호출 시 JWT를 보내 인증을 한다.
- 서버는 받은 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에 등록해주면 사용할 수 있다.
'spring' 카테고리의 다른 글
Spring - Kafka 연동하기 (0) | 2021.12.04 |
---|---|
Spring에서 Http Request 보내기 - Feign (0) | 2021.09.19 |
Spring Data Event Handler (0) | 2021.06.19 |
Custom HandlerMethodArgumentResolver (0) | 2021.06.08 |
테스트 코드 커버리지 확인하기 (jacoco) tutorial (0) | 2021.05.01 |
댓글