JWT 이해하고 쓰자

무심코·2022년 12월 8일
2

토큰 기반 인증의 이해

토큰 기반 인증을 사용하게된 이유

토큰 기반 인증을 사용하게된 이유를 이해하려면 먼저 기존의 사용했던 방식(어떤 기술이던지 그 탄생 이유를 알기위해서는 그 전 기술의 특징과 장단점을 파악하면 된다)을 알아보아야 한다.

기존 사용했던 인증 방식은 서버 기반 인증으로 유저들의 정보등을 서버단에서 저장하고 다뤘어야 하는 방식이었다. 이처럼 서버에 정보를 저장하는 방식을 *세션이라고 하고 아래 그림은 로그인시 세션을 활용하여 인증을 하는 과정을 도식적으로 보여주고 있다. 아래와 같은 방식은 서버에 상태(state)를 담아두고 있으므로 stateful하다고도 표현한다.

이러한 방식에는 문제점이 존재했는데 첫째 유저의 정보가 너무 많아지면 서버단에서 유저의 정보를 저장중이던 RAM이나 DB에 과부하가 생기게 되는 문제와 둘째 더 많은 트래픽을 감당해내기 위해 서버를 확장 시 기존 저장하고 있던 유저의 정보를 확장 적용하기가 어렵다는 문제가 있다.

문제점 1. 유저의 정보가 너무 많아지면 서버단에서 유저의 정보를 저장중이던 RAM이나 DB에 과부하가 생김
문제점 2. 더 많은 트래픽을 감당해내기 위해 서버를 확장 시 기존 저장하고 있던 유저의 정보를 확장 적용하기가 어려움

그러면 더이상 서버에 저장하지 말고 각자 저장하라고 하자

세션을 사용한 기존 인증 방식은 위와 같은 문제는 웹, 앱의 사용자가 늘어나고 서비스의 크기가 확장될수록 그 문제가 더 커지게 되고 이는 다른 종류의 인증 방식을 고민할 수 밖에 없게 한다.

그래서 등장하게 된 것이 토큰 기반의 인증 방식이다.

토큰 기반 인증은 서버 내에 유저 정보등을 저장하는 것이 아닌 클라이언트가 직접 자신에 해당하는 정보를 저장하는 방식이다. 세션 기반 인증에서는 상태(state)를 저장해놓는다 하여 stateful하다라는 표현을 사용했던 것과 대비되게 토큰 기반 인증에서는 상태(state)가 저장되지 않는다하여 stateless하다라는 표현을 사용한다.

다음은 로그인시 토큰 기반 방식을 사용하는 과정을 도식적으로 나타낸 그림이고 서버에서 토큰을 생성하여 클라이언트에게 응답해주면 클라이언트 측에서 해당 토큰을 저장하여 인증이 필요할 때 서버 측으로 요청 정보와 함께 전달하는 것을 알 수 있다. 웹 서버에서는 다음과 같이 토큰을 저장할 때 HTTP Request Header에 토큰값을 포함시켜 전달한다.

이렇게 토큰 기반 인증을 사용하면 좋은 점

stateless for scalability

위에서 계속해서 언급했지만 기존의 세션 기반 인증 방식에 비교하여 무상태적인 특징을 가져 확장성이 뛰어나고 서버 저장 공간 과부하가 없다는 장점이 있다.

만약 세션 기반 인증을 사용 시 초기 유저정보 세션을 서버측에 저장 이후 서버를 여러대로 확장하여 이후에 들어온 요청을 분산하는 작업을 수행한다면 초기 저장된 유저정보 세션은 해당 서버에만 요청을 보내도록 설정해야하여 의도했던 확장성의 손해를 보게된다.

허나 토큰 기반 인증을 사용하게 되면 서버를 어느 시점에 얼마만큼 확장하게 되더라도 특정 서버가 아닌 어느 서버로든 접근하면 되므로 위와 같은 문제는 발생하지 않는다!

보안

클라이언트가 서버에 요청을 보낼 때 쿠키를 사용하지 않으므로 쿠키를 사용하면서 발생하는 보안상의 문제를 없앨 수 있다.(but 토큰을 사용하면서 발생하는 보안상의 문제도 존재하므로 이를 대비해줘야한다.)

JWT (JSON Web Token)

JWT?

JWT란 JSON Web Token의 줄임말로 JSON 포맷을 이용하여 사용자에 대한 속성(위에서의 유저 정보)을 저장하는 Claim 기반의 Web Token이다. 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달해준다.

다음은 로그인시 전체적인 JWT 전달 로직을 도식적으로 나타낸 그림이다. 먼저 로컬 스토리지(HTML5에서 추가된 저장소로 간단한 Key-Value를 저장할 수 있다. 데이터는 영구적으로 저장되고 windows 전역 객체의 LocalStorage라는 컬렉션을 통해 저장, 조회가 이루어진다) 에 값이 있는지 여부를 확인하고 있다면 해당 값을 통해 로그인 하고 없다면 서버에 요청하여 JWT을 발행 받는다. 이후 로컬 스토리지와 static 변수에 JWT를 저장한다. 로컬 스토리지에만 저장하면 HTTP를 헤더에 담아서 보내는 과정에서 로컬 스토리지를 계속 불러오게 되고 이는 오버헤드를 유발한다. 즉 이를 방지하기 위해 static 변수에도 JWT를 저장시킨다.

++ 실제 서비스의 경우에는 로그아웃 시, 사용했던 토큰을 blacklist라는 DB 테이블에 넣어 해당 토큰의 접근을 막는 작업을 해주어야 한다.

JWT 구조

JWT = Header + Payload + Signature

→ JSON 형태인 각 부분은 Base64로 인코딩 되어 표현되고 구분자 '.'를 사용하여 구분된다. 이때 Base64로 인코딩된 문자열은 인코딩 전 같은 JSON 형태에 대해 항상 같은 인코딩 문자열을 반환(Header+Payload 부분)하므로(Signature 부분은 매번 변한다) 비밀번호와 같은 주요 정보를 인코딩시켜 보내게 된다면 보안에 매우 취약하다.

토큰의 헤더는 algtyp 두 가지 정보로 구성된다. alg는 헤더(Header)를 암호화 하는 것이 아니고, Signature를 해싱하기 위한 알고리즘을 지정하는 것이다.

  • alg: 알고리즘 방식을 지정하며, 서명(Signature) 및 토큰 검증에 사용 ex) HS256(SHA256) 또는 RSA
  • typ: 토큰의 타입을 지정 ex) JWT

Payload

PayLoad(페이로드)

토큰의 페이로드에는 토큰에서 사용할 정보의 조각들인 클레임(Claim)이 담겨 있다. 클레임은 총 3가지로 나누어지며, Json(Key/Value) 형태로 다수의 정보를 넣을 수 있다.

Claim 1. 등록된 클레임(Registered Claim)

등록된 클레임은 토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터들로, 모두 선택적으로 작성이 가능하며 사용할 것을 권장한다. 또한 JWT를 간결하게 하기 위해 key는 모두 길이 3의 String이다. 여기서 subject로는 unique한 값을 사용하는데, 사용자 이메일을 주로 사용한다.

  • iss: 토큰 발급자(issuer)
  • sub: 토큰 제목(subject)
  • aud: 토큰 대상자(audience)
  • exp: 토큰 만료 시간(expiration), NumericDate 형식으로 되어 있어야 함 ex) 1480849147370
  • nbf: 토큰 활성 날짜(not before), 이 날이 지나기 전의 토큰은 활성화되지 않음
  • iat: 토큰 발급 시간(issued at), 토큰 발급 이후의 경과 시간을 알 수 있음
  • jti: JWT 토큰 식별자(JWT ID), 중복 방지를 위해 사용하며, 일회용 토큰(Access Token) 등에 사용

Claim 2. 공개 클레임(Public Claim)

공개 클레임은 사용자 정의 클레임으로, 공개용 정보를 위해 사용된다. 충돌 방지를 위해 URI 포맷을 이용하며한다.

Claim 3. 비공개 클레임(Private Claim)

비공개 클레임은 사용자 정의 클레임으로, 서버와 클라이언트 사이에 임의로 지정한 정보를 저장한다. 아래의 예시와 같다.

{
    "token_type": access
}

Signature

서명(Signature)은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다. 

서명(Signature)은 위에서 만든 헤더(Header)와 페이로드(Payload)의 값을 각각 BASE64로 인코딩하고, 인코딩한 값을 비밀 키를 이용해 헤더(Header)에서 정의한 알고리즘으로 해싱을 하고, 이 값을 다시 BASE64로 인코딩하여 생성한다.

  1. 헤더(Header) & 페이로드(Payload) 인코딩 (by BASE64)
  2. 인코딩한 값을 해싱 (by 비밀 키 + Header의 "alg" 알고리즘)
  3. 해싱한 값을 다시 인코딩 (by BASE64)
  4. Signature 완성!

JWT Process

  1. 사용자가 id와 password를 입력하여 로그인을 시도
  2. 서버는 요청을 확인하고 secret key를 통해 Access token을 발급
  3. JWT 토큰을 클라이언트에 전달
  4. 클라이언트에서 API 을 요청할때 클라이언트가 Authorization header에 Access token을 담아서 보냄
  5. 서버는 JWT Signature를 체크하고 Payload로부터 사용자 정보를 확인해 데이터를 반환
  6. 클라이언트의 로그인 정보를 서버 메모리에 저장하지 않기 때문에 토큰기반 인증 메커니즘을 제공

JWT in Spring-boot

준비 객체

WebSecurityConfig.java
Secret.java
JwtServiceOwner.java

0. build.gradle implementation추가

// Security, Authentication
implementation('org.springframework.boot:spring-boot-starter-security')
implementation(group: 'io.jsonwebtoken', name: 'jjwt', version: '0.7.0')
implementation('io.jsonwebtoken:jjwt:0.9.0')

1. WebSecurityConfig.java

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration      // 스프링 설정 클래스를 선언하는 어노테이션
@EnableWebSecurity  // SpringSecurity 사용을 위한 어노테이션, 기본적으로 CSRF 활성화
// SpringSecurity란, Spring기반의 애플리케이션의 보안(인증, 권한, 인가 등)을 담당하는 Spring 하위 프레임워크
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
		/**
     * SpringSecurity 설정
     */
@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();  // CSRF 비활성화,
        // REST API 서버는 stateless하게 개발하기 때문에 사용자 정보를 Session에 저장 안함
        // jwt 토큰을 Cookie에 저장하지 않는다면, CSRF에 어느정도는 안전.
    }
}

2. Secret.java

public class Secret {
    public static StringJWT_SECRET_KEY= "...";
    public static StringUSER_INFO_PASSWORD_KEY= "...";
}

3. JwtServiceOwner.java

3-1. JWT 발급

Jwts.builder()

.setHeaderParam() : Header값("alg", "typ") 설정한다

.setClaims() : Payload에 담길 Claim값들을 설정한다.

.setSubject() : 토큰의 용도를 명시한다.

.setIssuesAt() : 토큰 생성 시간을 설정한다.

.setExpiration() : 토큰 만료 시간을 설정한다.

.signWith() : 어떤 알고리즘과 키값으로 sign할지(Header와 Payload를 인코딩+해싱) 설정한다.

.compact(); : 토큰을 생성한다.

3-2. HTTP Request 정보 가져오기

RequestContextHolder : Spring에서 전역으로 Request에 대한 정보를 가져오고자 할 때 사용하는 유틸성 클래스이다. 주로, Controller가 아닌 Business Layer 등에서 Request 객체를 참고하려 할 때 사용한다. Request Param이라던지 UserAgent 라던지 매번 method의 call param으로 넘기기가 애매할 때 주로 쓰인다. 동작 방식은 static한 ThreadLocal에 값을 Write/Read 하는 방식이다. 위에서 같은 스레드에서는 값을 꺼내쓸 수 있다고 말한 이유도 이러한 맥락이다. 그러나, 다른 스레드(new Thread, 혹은 executor를 사용한 ThreadPool에서의 참조 등) 에서는 RequestContextHolder 의 Request값을 꺼내 쓸 수 없다. 왜냐면 새로운 스레드를 생성하는 순간 DispatcherServlet 의 범위에서 벗어나서 새로운 스레드가 생성되기 때문이다.

.currentRequestAttributes() or .getRequestAttributes() : 현재 스레드에 바인딩된 RequestAttributes를 가져온다. 두 메소드의 차이는 RequestAttributes가 없을때 .current 의 경우 예외 발생시키고 .get 의 경우 NULL을 반환한다는 차이가 있다.

→ 이때 RequestContextHolder.currentRequestAttributes() 만으로는 Attribute만 얻어올 수 있으므로 아래와 같이 Wrapping 해준다.

((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())

.getRequest() : Exposes the native [HttpServletRequest](https://docs.oracle.com/javaee/7/api/javax/servlet/http/HttpServletRequest.html?is-external=true) that we're wrapping.

ServletRequestAttributes (Spring Framework 5.3.13 API)

.getHeader(java.lang.String name) : Returns the value of the specified request header as a String.

HttpServletRequest (Java EE 6 )

3-3. Jwts

Jwts.builder() : Returns a new [JwtBuilder](http://javadox.com/io.jsonwebtoken/jjwt/0.4/io/jsonwebtoken/JwtBuilder.html) instance that can be configured and then used to create JWT compact serialized strings.

Jwts.parser() : Returns a new [JwtParser](http://javadox.com/io.jsonwebtoken/jjwt/0.4/io/jsonwebtoken/JwtParser.html) instance that can be configured and then used to parse JWT strings.

Jwts (JSON Web Token support for the JVM 0.4 API) - Javadoc Extreme


import com.umc.hugo.config.BaseException;
import com.umc.hugo.config.BaseResponseStatus;
import com.umc.hugo.config.secret.Secret;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Date;

@Service
public class JwtServiceOwner {

    /*
    JWT 생성
    @param ownerIdx
    @return String
     */
    public String createJwt(int ownerIdx){
        Date now = new Date();
        return Jwts.builder()
                .setHeaderParam("type","jwt") //Header 설정부분
                .claim("ownerIdx",ownerIdx) //Claim 설정부분
                .setIssuedAt(now) //생성일 설정부분
                .setExpiration(new Date(System.currentTimeMillis()+1*(1000*60*60*24*365))) //토큰 만료 시간 설정(여기서 설정한 시간은 365일)
                .signWith(SignatureAlgorithm.HS256, Secret.JWT_SECRET_KEY) //HS256과 JWT_SECRET_KEY로 sign
                .compact(); //토큰 생성
    }

    /*
    Header에서 X-ACCESS-TOKEN 으로 JWT 추출
    @return String
     */
    public String getJwt(){
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        return request.getHeader("X-ACCESS-TOKEN");
    }

    public int getOwnerIdx() throws BaseException {
        //1. JWT 추출
        String accessToken = getJwt();
        if(accessToken == null || accessToken.length() == 0){
            throw new BaseException(BaseResponseStatus.EMPTY_JWT);
        }

        // 2. JWT parsing
        Jws<Claims> claims;
        try{
            claims = Jwts.parser()
                    .setSigningKey(Secret.JWT_SECRET_KEY) //Set Key
                    .parseClaimsJws(accessToken); //위 Key를 가지고 파싱 및 검증 과정
        } catch (Exception ignored) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        }

        // 3. ownerIdx 추출
        return claims.getBody().get("ownerIdx",Integer.class);  // jwt 에서 ownerIdx를 추출합니다.
    }
}

사용 방법(in 로그인)

1. 로그인시 JWT 발급 ( createJwt() in Provider )

로그인시 클라이언트가 보내준 정보(유저 ID와 PW)가 일치한다면 createJwt()를 사용하여 JWT를 발급하고 클라이언트에게 보내는 Response 메세지의 해당 JWT를 포함시켜 보내준다.

// In OwnerProvider
...
// 로그인(password 검사)
public PostLoginRes logIn(PostLoginReq postLoginReq) throws BaseException {
    Owner owner = ownerDao.getPwdByEmail(postLoginReq);
    String password;
    try {
        password = new AES128(Secret.USER_INFO_PASSWORD_KEY).decrypt(owner.getPassword()); // 암호화
        // 회원가입할 때 비밀번호가 암호화되어 저장되었기 떄문에 로그인을 할때도 암호화된 값끼리 비교를 해야합니다.
    } catch (Exception ignored) {
        throw new BaseException(PASSWORD_DECRYPTION_ERROR);
    }

    if (postLoginReq.getPassword().equals(password)) { //비말번호가 일치한다면 ownerIdx를 가져온다.
        int ownerIdx = ownerDao.getPwdByEmail(postLoginReq).getOwnerIdx();

        String jwt = jwtServiceOwner.createJwt(ownerIdx); // JWT 발급!

/* jwtServiceOwner.createJwt()
		public String createJwt(int ownerIdx){
        Date now = new Date();
        return Jwts.builder()
                .setHeaderParam("type","jwt")
                .claim("ownerIdx",ownerIdx)
                .setIssuedAt(now)
                .setExpiration(new Date(System.currentTimeMillis()+1*(1000*60*60*24*365)))
                .signWith(SignatureAlgorithm.HS256, Secret.JWT_SECRET_KEY)
                .compact();
    }
*/

        return new PostLoginRes(ownerIdx,jwt); // Response 정보로 JWT를 Client에게 넘겨준다

    } else { // 비밀번호가 다르다면 에러메세지를 출력한다.
        throw new BaseException(FAILED_TO_LOGIN);
    }
}
...

2. Clinet에게 발급된 JWT와 비교하여 인증

2-1. HTTP Request Header에 JWT 포함

Client는 로그인 이후 JWT 인증이 필요한 API 사용시 로그인시 발급받은 JWT를 HTTP Request Header에 같이 포함시켜 전달한다.

2-2. JWT 비교() ( getOwnerIdx() in Controller)

Clinet의 Request Body에 포함되어 온 JWT를 디코딩하여 원래 JWT를 만든 값을 도출해내고 해당 값을 비교대상과 비교한다.

아래의 예시에서는 처음 JWT를 발급시 ownerIdx로 발급한 경우로 사용자가 PathVariable로 보낸 ownerIdx와 Request Body에 포함되어 온 JWT를 parsing하여 얻어낸 ownerIdx를 비교하여 권한 체크를 진행한다.

[ 과정 도식화 ]

  1. Controller : jwtServiceOwner.getOwnerIdx() 호출
  2. JwtServiceOwner : getJwt() 호출
  3. JwtServiceOwner : HTTP Request Header로 부터 "X-ACCESS-TOKEN" 즉 JWT 가져옴
  4. JwtServiceOwner : JWT parsing 과정
  5. JwtServiceOwner : parsing한 JWT에서 원하는 데이터 추출
  6. Controller : JWT를 파싱하여 얻어온 ownerIdx와 클라이언트가 PathVariable로 보낸 ownerIdx를 같은지 비교
// In OwnerController
// 1. owner 이름 변경
...
@ResponseBody
@PatchMapping("/name/{ownerIdx}")
public BaseResponse<String> modifyOwnerName(@PathVariable("ownerIdx") int ownerIdx, @RequestBody Owner owner) {
    try {
        //jwt에서 idx 추출.
        int ownerIdxByJwt = jwtServiceOwner.getOwnerIdx(); // [1] HTTP Request Body에 JWT에서 ownerIdx 추출

/* jwtServiceOwner.getOwnerIdx()
public String getJwt(){ // [3] getJwt()를 통해서 HTTP Request Body에 포함되어온 "X-ACCESS-TOKEN"을 받는다.
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        return request.getHeader("X-ACCESS-TOKEN");
    }

public int getOwnerIdx() throws BaseException {
        //1. JWT 추출
        String accessToken = getJwt(); // [2] accessToken에 JWT 저장
        if(accessToken == null || accessToken.length() == 0){
            throw new BaseException(BaseResponseStatus.EMPTY_JWT);
        }

        // 2. JWT parsing 과정
        Jws<Claims> claims;
        try{ // [4]
            claims = Jwts.parser()
                    .setSigningKey(Secret.JWT_SECRET_KEY)
                    .parseClaimsJws(accessToken);
        } catch (Exception ignored) {
            throw new BaseException(BaseResponseStatus.INVALID_JWT);
        }

        // 3. ownerIdx 추출
        return claims.getBody().get("ownerIdx",Integer.class);// [5] 파싱한 jwt 에서 ownerIdx를 추출
    }
*/

        // [6] ownerIdx와 접근한 owner가 같은지 확인
        if(ownerIdx != ownerIdxByJwt){ 
            return new BaseResponse<>(INVALID_USER_JWT);
        }

        //같다면 유저네임 변경
        PatchOwnerReq patchOwnerReq = new PatchOwnerReq(ownerIdx, owner.getName());
        ownerService.modifyOwnerName(patchOwnerReq);

        String result = "회원이름이 수정되었습니다.";
        return new BaseResponse<>(result);
    } catch (BaseException exception) {
        return new BaseResponse<>((exception.getStatus()));
    }
}
...

[ 참고 자료 ]

[JWT] 토큰(Token) 기반 인증에 대한 소개

[Server] JWT(Json Web Token)란?

JWT (JSON Web Token) 이해하기와 활용 방안 - Opennaru, Inc.

profile
지나치지 않기 위하여

0개의 댓글