ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 스프링 부트에서 JWT 사용하기
    Server/Spring REST API 2020. 8. 24. 02:22
    728x90
    반응형

     

    JWT란?

    [블로그 링크 예정]

     

     

    스프링부트에서 로그인을 하였을 때 Access Token을 발급해주는 것과 사용자가 헤더에 토큰을 담아 보냈을 때 접근가능한 사용자인지 아닌지를 체크하는 예제를 진행해보려 한다.

     

    먼저 스프링 프로젝트를 Maven으로 만든 후에 아래의 의존성과 설정을 추가해주자.

     

    pom.xml

    <!-- JWT -->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.4.0</version>
    </dependency>

     

    application.properties 내용 추가

    # 토크 발급자
    JWT.ISSUER=TEST
    # JWT 키 (여러 문자가 섞일수록 안전하다)
    JWT.SECRET=SeCrEtKeY4HaShInG
    • ISSUER : 토큰 발급자
    • SECRET : 토큰 해쉬 키 값, 여러 문자가 섞일수록 안전하지만 암호화 시간이 오래걸린다.(노출하면 안되는 값이기 때문에 gitignore 적용을 꼭 하자)

     

     

    먼저 Service 계층에 JWT 토큰을 만들어주는 클래스를 만들자.

    import com.auth0.jwt.JWT;
    import com.auth0.jwt.JWTCreator;
    import com.auth0.jwt.algorithms.Algorithm;
    import com.auth0.jwt.exceptions.JWTCreationException;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Service;
    
    @Slf4j
    @Service
    public class JwtService {
    
        @Value("${JWT.ISSUER}")
        private String ISSUER;
    
        @Value("${JWT.SECRET}")
        private String SECRET;
    
        /**
         * 토큰 생성
         *
         * @param userIdx 토큰에 담길 로그인한 사용자의 회원 고유 IDX
         * @return 토큰
         */
    
        public String create(final int userIdx) {
            try {
                JWTCreator.Builder b = JWT.create();
                // 토큰 발급자
                b.withIssuer(ISSUER);
                // 토큰 payload 작성, key - value 형식, 객체도 가능
                b.withClaim("userIdx", userIdx);
                // 토큰 만료날짜 지정
                b.withExpiresAt(expiresAt());
                return b.sign(Algorithm.HMAC256(SECRET));
            } catch (JWTCreationException jwtCreationException) {
                log.info(jwtCreationException.getLocalizedMessage());
            }
            return null;
        }
        
        private Date expiresAt()  {
            Calendar cal = Calendar.getInstance();
            cal.setTime(new Date());
            // 한달 24 * 31
            cal.add(Calendar.HOUR, 744);
            return cal.getTime();
        }
    
        public static class TokenRes {
            private String token;
    
            public TokenRes() {
    
            }
    
            public TokenRes(final String token) {
                this.token = token;
            }
    
            public String getToken() {
                return token;
            }
    
            public void setToken(String token) {
                this.token = token;
            }
        }
    }
    

    위의 코드를 하나씩 정리해보자. Token을 담을 내부 클래스도 존재하게 구현하고 application.properties에 있는 ISSUER와 SECRET을 @Value 어노테이션을 이용해서 가져온다.

     

    @Value("${JWT.ISSUER}")
    private String ISSUER;
    
    @Value("${JWT.SECRET}")
    private String SECRET;

    위와 같이 사용하려면 해당 필드를 가진 클래스가 Bean으로 등록이 되어 있어야 스프링에서 application.properties에 있는 값을 가져올 수 있게 된다. 

     

    public String create(final int userIdx) {
        try {
            JWTCreator.Builder b = JWT.create();
            b.withIssuer(ISSUER);
            b.withClaim("userIdx", userIdx);
            return b.sign(Algorithm.HMAC256(SECRET));
        } catch (JWTCreationException jwtCreationException) {
            log.info(jwtCreationException.getLocalizedMessage());
        }
        return null;
    }

    그리고 위의 코드를 통해서 만들어진 토큰이 return 되는데 토큰을 만드는 과정이 궁금하여 내부 코드를 뜯어보니 아래와 같다.  

    public abstract class JWT {
    
        /**
         * Returns a Json Web Token builder used to create and sign tokens
         *
         * @return a token builder.
         */
        public static JWTCreator.Builder create() {
            return JWTCreator.init();
        }
    }

    위와 같이 JWT 클래스는 static이면서 return 타입이 JWTCreator.Builder인 create() 메소드를 가지고 있다. JWTCreator 클래스의 메소드도 궁금하여 내부코드를 뜯어보았다.

     

    public final class JWTCreator {
    
        private final Algorithm algorithm;
        private final String headerJson;
        private final String payloadJson;
    
        private JWTCreator(Algorithm algorithm, Map<String, Object> headerClaims, Map<String, Object> payloadClaims) throws JWTCreationException {
            this.algorithm = algorithm;
            try {
                ObjectMapper mapper = new ObjectMapper();
                SimpleModule module = new SimpleModule();
                module.addSerializer(ClaimsHolder.class, new PayloadSerializer());
                mapper.registerModule(module);
                mapper.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true);
                headerJson = mapper.writeValueAsString(headerClaims);
                payloadJson = mapper.writeValueAsString(new ClaimsHolder(payloadClaims));
            } catch (JsonProcessingException e) {
                throw new JWTCreationException("Some of the Claims couldn't be converted to a valid JSON format.", e);
            }
        }
    
        static JWTCreator.Builder init() {
            return new Builder();
        }
    
        public static class Builder {
            private final Map<String, Object> payloadClaims;
            private Map<String, Object> headerClaims;
    
            Builder() {
                this.payloadClaims = new HashMap<>();
                this.headerClaims = new HashMap<>();
            }
            
            public String sign(Algorithm algorithm) throws IllegalArgumentException, JWTCreationException {
                if (algorithm == null) {
                    throw new IllegalArgumentException("The Algorithm cannot be null.");
                }
                headerClaims.put(PublicClaims.ALGORITHM, algorithm.getName());
                headerClaims.put(PublicClaims.TYPE, "JWT");
                String signingKeyId = algorithm.getSigningKeyId();
                if (signingKeyId != null) {
                    withKeyId(signingKeyId);
                }
                return new JWTCreator(algorithm, headerClaims, payloadClaims).sign();
            }
        }
    
        private String sign() throws SignatureGenerationException {
            String header = Base64.encodeBase64URLSafeString(headerJson.getBytes(StandardCharsets.UTF_8));
            String payload = Base64.encodeBase64URLSafeString(payloadJson.getBytes(StandardCharsets.UTF_8));
            String content = String.format("%s.%s", header, payload);
    
            byte[] signatureBytes = algorithm.sign(content.getBytes(StandardCharsets.UTF_8));
            String signature = Base64.encodeBase64URLSafeString((signatureBytes));
    
            return String.format("%s.%s", content, signature);
        }
    }

    내부 코드의 일부를 가져오면 위와 같다. JWT에 들어가야 할 정보들을 담는 클래스이다. 예를들면 Header에 담을 Algorithm, Type, Payload에 담을 토큰 발급자, 토큰 유효시간 등이다. 

    b.withIssuer(ISSUER);
    b.withClaim("userIdx", userIdx);

    위와 같이 JWTCreator 클래스 내부에 정의된 메소드를 이용하여 Header, Payload를 채워 토큰을 채우게 된다. JWT 추상클래스 안에 create 메소드의 내부코드를 보면 아래와 같다.

     

    public Builder withIssuer(String issuer) {
        addClaim(PublicClaims.ISSUER, issuer);
        return this;
    }
    
    public Builder withClaim(String name, Integer value) throws IllegalArgumentException {
        assertNonNull(name);
        addClaim(name, value);
        return this;
    }

    그리고 JWTService 클래스에서 JWTCreator.Builder 클래스 메소드인 sign을 아래와 같이 호출한다.

     

    b.sign(Algorithm.HMAC256(SECRET))

    JWTCreator.Builder 클래스의 sign 메소드는 아래와 같고 여기서 사용할 Algorithrm, Header에 담을 정보, payload 정보를 JWTCreator 클래스의 sign 메소드를 호출하여 토큰을 만들게 된다. 

     

    public String sign(Algorithm algorithm) throws IllegalArgumentException, JWTCreationException {
       if (algorithm == null) {
            throw new IllegalArgumentException("The Algorithm cannot be null.");
       }
       headerClaims.put(PublicClaims.ALGORITHM, algorithm.getName());
       headerClaims.put(PublicClaims.TYPE, "JWT");
       String signingKeyId = algorithm.getSigningKeyId();
       if (signingKeyId != null) {
           withKeyId(signingKeyId);
       }
       return new JWTCreator(algorithm, headerClaims, payloadClaims).sign();
    }

    이제 토큰을 만드는 과정을 알았으니 실제 클라이언트에서 Access Token을 만들어 응답을 보내보자.

     

    AuthService

    @Service
    public class AuthService {
    
        private final UserMapper userMapper;
        private final JwtService jwtService;
        private final PasswordEncoder passwordEncoder;
    
        // 의존성 주입
        public AuthService(UserMapper userMapper, JwtService jwtService, PasswordEncoder passwordEncoder) {
            this.userMapper = userMapper;
            this.jwtService = jwtService;
            this.passwordEncoder = passwordEncoder;
        }
    
        // 로그인
        public DefaultRes<JwtService.TokenRes> signIn(final SignInModel signInModel) {
            final User user = userMapper.findById(signInModel.getId());
    
            // 회원 정보가 존재하지 않거나, 아이디가 틀렸음
            if (user == null) {
                return DefaultRes.res(StatusCode.BAD_REQUEST, ResponseMessage.LOGIN_FAIL);
            }
    
            // 로그인 성공
            if (passwordEncoder.matches(signInModel.getPassword(), user.getPassword())) {
                // 토큰 생성
                final JwtService.TokenRes tokenDto = new JwtService.TokenRes(jwtService.create(user.getUserIdx()));
                return DefaultRes.res(StatusCode.OK, ResponseMessage.LOGIN_SUCCESS, tokenDto);
            }
    
            // 비밀번호가 틀렸을 때
            return DefaultRes.res(StatusCode.BAD_REQUEST, ResponseMessage.LOGIN_FAIL);
        }
    }

    위의 코드를 간단하게 설명하자면 DefaultRes는 클라이언트에게 응답을 보내기 위해 StatusCode, ResponseMessage, Data를 담은 클래스이고, PasswordEncoder는 비밀번호를 암호화하는 인터페이스이다. 그렇지만 지금 집중적으로 다루는 내용은 JWT 이기 때문에 다른 내용에 대한 설명은 생략하겠다.

     

    집중적으로 봐야할 부분은 로그인 성공했을 때의 경우이다.

    // 로그인 성공
    if (passwordEncoder.matches(signInModel.getPassword(), user.getPassword())) {
        // 토큰 생성
        final JwtService.TokenRes tokenDto = new JwtService.TokenRes(jwtService.create(user.getUserIdx()));
        return DefaultRes.res(StatusCode.OK, ResponseMessage.LOGIN_SUCCESS, tokenDto);
    }

    여기서 아까 위에서 설명했던 create() 메소드를 호출하여 토큰을 받아와 클라이언트에게 토큰을 반환해준다. 

     

     

    LoginController

    @Slf4j
    @RestController
    public class LoginController {
    
        // 실패시 사용
        private static final DefaultRes FAIL_DEFAULT_RES = new DefaultRes(StatusCode.INTERNAL_SERVER_ERROR, ResponseMessage.INTERNAL_SERVER_ERROR);
    
        private final AuthService authService;
    
        // 생성자 의존성 주입
        public LoginController(AuthService authService) {
            this.authService = authService;
        }
    
        @PostMapping("/user/signIn")
        public ResponseEntity signIn(@RequestBody SignInModel signInModel) {
            try {
                return new ResponseEntity(authService.signIn(signInModel), HttpStatus.OK);
            } catch (Exception e) {
                log.error(e.getMessage());
                return new ResponseEntity<>(FAIL_DEFAULT_RES, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        }
    }

    그리고 위의 코드는 Controller이다. Controller 계층과 Service 계층을 분리하여 코드를 작성된 상태이다. 

     

     

    PostMan 테스트

    위와 같이 로그인을 했을 때 토큰이 응답으로 오는 것을 확인할 수 있다.

     

     

    Access Token docode 하기

    import com.auth0.jwt.JWT;
    import com.auth0.jwt.JWTVerifier;
    import com.auth0.jwt.algorithms.Algorithm;
    import com.auth0.jwt.exceptions.JWTVerificationException;
    import com.auth0.jwt.interfaces.DecodedJWT;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Service;
    
    import static com.auth0.jwt.JWT.require;
    
    @Slf4j
    @Service
    public class JwtService {
    
    
        /**
         * 토큰 해독
         *
         * @param token 토큰
         * @return 로그인한 사용자의 회원 고유 IDX
         */
    
        public TOKEN decode(final String token) {
            try {
                // 토큰 해독 객체 생성
                final JWTVerifier jwtVerifier = require(Algorithm.HMAC256(SECRET)).withIssuer(ISSUER).build();
                // 토큰 검증
                DecodedJWT decodedJWT = jwtVerifier.verify(token);
                // 토큰 payload 반환, 정상적인 토큰이라면 토큰 사용자 고유 ID, 아니라면 -1
                return new TOKEN(decodedJWT.getClaim("userIdx").asLong().intValue());
            } catch (JWTVerificationException jve) {
                log.error(jve.getMessage());
            } catch (Exception e) {
                log.error(e.getMessage());
            }
            return new TOKEN();
        }
    }
    

     

    토큰을 decode 하는 메소드를 JWTService 클래스 안에 추가해주자. 그리고 decode 메소드의 코드를 분석해보자

    final JWTVerifier jwtVerifier = require(Algorithm.HMAC256(SECRET)).withIssuer(ISSUER).build();

    먼저 이 부분의 require 메소드가 무엇인지 부터 확인해보자.

     

    public static Verification require(Algorithm algorithm) {
        return JWTVerifier.init(algorithm);
    }

    require 메소드는 JWT 클래스 안에 존재하고 내부 코드는 위와 같이 되어있다. 그리고 return 타입에 JWTVerfier 클래스가 존재하는데 그게 무엇인지 내부 코드를 뜯어보니 아래와 같다.

     

    public final class JWTVerifier {
        private final Algorithm algorithm;
        final Map<String, Object> claims;
        private final Clock clock;
    
        JWTVerifier(Algorithm algorithm, Map<String, Object> claims, Clock clock) {
            this.algorithm = algorithm;
            this.claims = Collections.unmodifiableMap(claims);
            this.clock = clock;
        }
    
        static Verification init(Algorithm algorithm) throws IllegalArgumentException {
            return new BaseVerification(algorithm);
        }
        
        public static class BaseVerification implements Verification {
            private final Algorithm algorithm;
            private final Map<String, Object> claims;
            private long defaultLeeway;
    
            BaseVerification(Algorithm algorithm) throws IllegalArgumentException {
                if (algorithm == null) {
                    throw new IllegalArgumentException("The Algorithm cannot be null.");
                }
    
                this.algorithm = algorithm;
                this.claims = new HashMap<>();
                this.defaultLeeway = 0;
            }
            
            @Override
        	public Verification withIssuer(String issuer) {
            	requireClaim(PublicClaims.ISSUER, issuer);
            	return this;
        	}
    
            @Override
            public JWTVerifier build() {
                return this.build(new ClockImpl());
            }
    
            public JWTVerifier build(Clock clock) {
                addLeewayToDateClaims();
                return new JWTVerifier(algorithm, claims, clock);
            }
        }
    }
    

    위의 JWTVerifier 클래스 안에 init() 메소드를 통해서 내부 클래스인 BaseVerification 클래스의 객체를 만들고, Builder 패턴을 사용하여 메소드 체인닝 기법으로 함수를 호출한다. 그래서 마지막에 build() 메소드를 호출하면 JWTVerifier 객체를 만들어 return 한다. 이렇게 토큰 해독 객체를 만들었으니 그 다음에는 토큰 해독을 해야한다. 그 방법은 verfiy 메소드를 이용하는 것이다.

    DecodedJWT decodedJWT = jwtVerifier.verify(token);

    JWTVerifier 클래스의 verify 메소드 내부 코드를 보면 아래와 같다.

     

    public DecodedJWT verify(String token) throws JWTVerificationException {
        DecodedJWT jwt = JWT.decode(token);
        verifyAlgorithm(jwt, algorithm);
        algorithm.verify(jwt);
        verifyClaims(jwt, claims);
        return jwt;
    }

    여기서 JWT 클래스의 decode 메소드를 호출한다. 

     

    public static DecodedJWT decode(String token) throws JWTDecodeException {
        return new JWTDecoder(token);
    }

    그라면 위와 같이 DecodedJWT 인터페이스를 구현하고 있는 JWTDecoder 클래스의 객체를 만들어 반환해준다. 이 과정에서 토큰의 알고리즘 or 시크릿 키가 잘못되었다면 catch 문에서 에러에 걸리게 된다. 

     

    // 토큰 payload 반환, 정상적인 토큰이라면 토큰 사용자 고유 ID, 아니라면 -1
    return new TOKEN(decodedJWT.getClaim("userIdx").asLong().intValue());

    올바른 토큰이라면 위의 토큰 객체를 return 하게 되는데 여기서 getClaim() 메소드의 들어갈 Key 값은 토큰을 만들 때 Payload에 넣었던 키 값을 사용하여야 한다. 만약 키 값이 틀리다면 Null이 되어 에러가 발생하여 catch문에서 걸리게 된다. 

     

    이렇게 스프링부트에서 토큰을 생성하고 해독하는 과정에 대해 정리해보았다.

     

     

    반응형

    댓글

Designed by Tistory.