근래 모바일 트렌드 중 하나는 로그아웃을 하지 않는 이상 로그인을 유지하는 것입니다. 일반적으로는 손쉽게 Session을 이용해서 클라이언트와 서버 통신 중 Stateless의 단점을 보완할 수 있었지만, 모바일의 특성상 자주 끊길 소지가 있습니다. 세션과 비슷한 역할을 하되, 계속해서 유지될 수 있는 기술을 찾다 보니 Token을 이용한 방식이 있었고, 그중 JWT를 사용하게 되었습니다.
JSON Web Token (JWT)은 JSON 객체로서 당사자 간에 안전하게 정보를 전송할 수 있는 작고 독립적인 방법을 정의하는 공개 표준 (RFC 7519)입니다. 이 정보는 디지털로 서명 되었기 때문에 신뢰할 수 있습니다. JWT는 암호 (HMAC 알고리즘 사용) 또는 RSA를 사용하는 공용 / 개인 키 쌍을 사용하여 서명을 할 수 있습니다. https://jwt.io/introduction/
그러나 대표적인 취약점은 발행된 토큰을 제거할 수 없습니다.(기간 만료 제외) 이러한 이유 때문에 Blacklist를 만들어 관리하기도 합니다. 그 외 세부적인 내용은 조대협님이 잘 정리해 놓으셨으니 http://bcho.tistory.com/999 를 참고하시면 될 것 같습니다.
JWT를 활용하여 구현한 방법에 대해서 설명하겠습니다.
먼저, 어플을 실행하게 될 때의 순서도입니다. JWT를 static 변수에도 저장한 이유는 HTTP 통신을 할 때마다 로컬 스토리지(내장 DB)에서 데이터를 가져오는 것은 오버헤드라 판단하였고, 메모리에 올렸습니다.
로그인 이후 HTTP 통신을 할 경우 어플에서는 메모리에 올라가있는 JWT를 HTTP 헤더에 담아서 보냅니다. 서버에서는 Interceptor, Filter, AOP 중 하나를 이용해서 허가 된 JWT인지를 확인하게 합니다.
마지막으로 로그아웃을 할 경우 로컬스토리지에 들어있는 JWT 데이터를 제거해줍니다.
저는 블랙리스트라는 개념을 구현하지 않았지만, 실서비스 개발을 했었다면, 로그아웃 시 사용하던 토큰을 blacklist라는 DB테이블에 넣어 해당 토큰의 접근을 막았을 것 같습니다.
지금부터는 구현한 코드를 보여드리겠습니다. 먼저 개발 환경은 Spring Boot 2.0.0 M3, lombok, gradle입니다.
저는 jjwt 라이브러리를 사용하였고, gradle을 이용해서 디펜던시를 설정했습니다.. Maven을 이용하시는 독자님은 아래 사이트를 참고하여 디펜던시를 설정해주세요.
http://mvnrepository.com/artifact/io.jsonwebtoken/jjwt/0.7.0
dependencies { compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.7.0' }
1. 최초 로그인 시 작동하는 서버 코드입니다.
@RestController @RequestMapping("/member") public class MemberController { @Autowired private MemberService memberService; @Autowired private JwtService jwtService; @PostMapping(value="/signin") public Result signin(String email, String password, HttpServletResponse response){ Result result = Result.successInstance(); MemberMaster loginMember = memberService.signin(email, password); String token = jwtService.createMember(loginMember); response.setHeader("Authorization", token); result.setData(loginMember); return result; } }
아이디와 비밀번호를 받은 뒤 signin 비즈니스를 통하여 회원정보를 가져옵니다. 회원정보를 이용해서 JWT를 만들고 응답헤더에 JWT를 담아서 보냅니다.
JwtService의 구현체입니다.
@Slf4j @Service("jwtService") public class JwtServiceImpl implements JwtService{ private static final String SALT = "luvookSecret"; @Override public <T> String create(String key, T data, String subject){ String jwt = Jwts.builder() .setHeaderParam("typ", "JWT") .setHeaderParam("regDate", System.currentTimeMillis()) .setSubject(subject) .claim(key, data) .signWith(SignatureAlgorithm.HS256, this.generateKey()) .compact(); return jwt; } private byte[] generateKey(){ byte[] key = null; try { key = SALT.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { if(log.isInfoEnabled()){ e.printStackTrace(); }else{ log.error("Making JWT Key Error ::: {}", e.getMessage()); } } return key; } }
jjwt를 이용하여 JWT를 만드는 코드입니다. JWT의 헤더, 클래임, 암호 등의 필요한 정보를 넣고 직렬화(compact())시켜줍니다. 단순하게 Session처럼 정보를 넣어놓고 빼쓰기 위해서는 claim에 데이터를 넣으시면 됩니다. JWT안의 claim을 가져오는 방법은 잠시 후 설명해 드리겠습니다.
2. HTTP 통신할 때 인터셉터를 활용하여 유효한 토큰인지 확인하는 코드입니다.
먼저, Interceptor를 사용하기 위해 HandlerInterceptor를 구현한 클래스를 만듭니다. (Spring Boot 2이하의 버전은 HandlerInterceptorAdapter를 상속받아주세요 -> extends HandlerInterceptorAdapter)
@Component public class JwtInterceptor implements HandlerInterceptor{ private static final String HEADER_AUTH = "Authorization"; @Autowired private JwtService jwtService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { final String token = request.getHeader(HEADER_AUTH); if(token != null && jwtService.isUsable(token)){ return true; }else{ throw new UnauthorizedException(); } } }
HTTP 헤더에서 JWT를 가져온 후 유효하면 true를 리턴하고, 그렇지 않으면 예외를 발생시킵니다.
@Slf4j @Service("jwtService") public class JwtServiceImpl implements JwtService{ @Override public boolean isUsable(String jwt) { try{ Jws<claims> claims = Jwts.parser() .setSigningKey(this.generateKey()) .parseClaimsJws(jwt); return true; }catch (Exception e) { throw new UnauthorizedException(); } } }
claim으로 변환도중 예외가 발생하면 유효하지 않은 토큰으로 판단하고, 예외를 핸들링 해줍니다.
변환도중 발생하는 에러는 아래의 5가지입니다.
1) ExpiredJwtException : JWT를 생성할 때 지정한 유효기간 초과할 때.
2) UnsupportedJwtException : 예상하는 형식과 일치하지 않는 특정 형식이나 구성의 JWT일 때
3) MalformedJwtException : JWT가 올바르게 구성되지 않았을 때
4) SignatureException : JWT의 기존 서명을 확인하지 못했을 때
5) IllegalArgumentException
그리고 에러를 핸들링 하기위해 만든 예외입니다.
public class UnauthorizedException extends RuntimeException{ private static final long serialVersionUID = -2238030302650813813L; public UnauthorizedException() { super("계정 권한이 유효하지 않습니다.\n다시 로그인을 해주세요."); } }
마지막으로 JwtInterceptor를 스프링에 등록해줍니다.
@Configuration public class WebConfig implements WebMvcConfigurer { private static final String[] EXCLUDE_PATHS = { "/member/**", "/error/**" }; @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns(EXCLUDE_PATHS); } }
Interceptor등록을 위해서 @Configuration작성과 함께 WebMvcConfigurer를 구현한 클래스를 생성합니다. 그리고 addInterceptors()에 기존에 만들었던 JwtInterceptor를 등록하고, 허용하거나 배제할 URI를 작성합니다. 일반적으로는 토큰이 필요하지 않은 경우를 배제하면 될 것 같습니다. 저의 경우에는 로그인, 회원가입 같은 서비스와 에러핸들링 서비스가 이에 해당되어 배제했습니다.
3. 마지막으로 JWT에 넣어놓은 데이터를 가져오는 코드입니다.
@Slf4j @Service("jwtService") public class JwtServiceImpl implements JwtService{ @Override public Map<String, Object> get(String key) { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); String jwt = request.getHeader("Authorization"); Jws<Claims> claims = null; try { claims = Jwts.parser() .setSigningKey(SALT.getBytes("UTF-8")) .parseClaimsJws(jwt); } catch (Exception e) { throw new UnauthorizedException(); } @SuppressWarnings("unchecked") Map<String, Object> value = (LinkedHashMap<String, Object>)claims.getBody().get(key); return value; } }
위의 코드를 간단하게 표현하자면 다음과 같습니다. HTTP Header -> JWT -> Claim -> Key -> Value.
이상으로 luVook라는 프로젝트를 진행하면서 사용한 JWT에 대해 글을 써봤습니다. 전체 소스를 보시려면 https://github.com/viviennes7/luvook를 확인해주세요. com.ms.luvook.common쪽을 확인하시면 될 것 같습니다.
혹시 자료들과 관련해서 문의사항이나 오류가 있을 경우 댓글남겨주시면 감사하겠습니다. 긴글읽어주셔서 감사합니다.
'Spring' 카테고리의 다른 글
AOP에 걸린 Method의 Parameter 이름 가져오기 (4) | 2018.06.19 |
---|---|
Spring Boot 1으로 Todo List를 만들어 보자 (0) | 2018.06.02 |
[MSA] #6 Spring Cloud Netflix (3) | 2018.04.19 |
스프링 빈은 Thread-safe 할까? (3) | 2017.10.15 |
Spring 5.0.0 레퍼런스 (The IoC container) [작업중] (0) | 2017.10.02 |