Spring

Spring boot환경에서 JWT 사용하기

AlwaysPr 2017. 9. 23. 17:36

근래 모바일 트렌드 중 하나는 로그아웃을 하지 않는 이상 로그인을 유지하는 것입니다. 일반적으로는 손쉽게 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쪽을 확인하시면 될 것 같습니다.

  

혹시 자료들과 관련해서 문의사항이나 오류가 있을 경우 댓글남겨주시면 감사하겠습니다. 긴글읽어주셔서 감사합니다.