JWT(Json Web Token)

merci·2023년 3월 20일
0

Rest Api 프로젝트

목록 보기
6/6
post-thumbnail

basic 인증

REST 아키텍처를 사용하는 웹은 로그인을 제외하면 상태를 유지해야 할 이유가 없다.
이러한 stateless한 특징을 가진 웹에서 인증을 구현하는 가장 간단한 방법은 http 요청시에 아이디와 비밀번호를 같이 보내는것 -> basic 인증

요청헤더의 Authorization: 부분에 인코딩된 아이디, 패스워드를 보냄
-> 아이디, 패스워드가 디코딩되면 보안에 취약함, 반드시 https 와 함께 사용해야한다

이 방법의 단점은 서비스를 이용할 때마다 인증을 하기 때문에 인증 서버에 과부하( 스케일 문제 ) - 단일 장애점

토큰 인증

토큰은 사용자를 구별할 수 있는 문자열
최초 로그인시 서버가 만들어 응답한다. 클라이언트는 요청시 토큰을 함께 보낸다.
요청헤더에 Authorization: Bearer 토큰문자열이 있다.
보안적으로 좀 더 안전, 토큰 유효시간을 관리 / 임의의 로그아웃 기능
토큰 사용으로도 스케일 문제를 완벽하게 해결할 수 없다


JWT

JWT 는 헤더 / 페이로드 / 시그니쳐로 구성

헤더

typ - 헤더의 타입
alg - 알고리즘, 토큰의 서명을 발행하는데 사용된 해시 알고리즘

페이로드

sub - subject 토큰의 주인, 경우에 따라 유일한 식별자가 됨
iss - issuer 토큰 발행 주체
iat - issued at 토큰이 발행된 날짜 시간
exp - 토큰 만료시간
id - 토큰의

시그니처

issuer 가 발행한 서명 , 토큰의 유효성 검사에 사용

전자서명(시그니처)

헤더와 페이로드를 비밀키(서버만 알고 있음)로 해시함수에 돌려 암호화한 값
서버는 헤더, 페이로드, 전자서명을 인코딩(Base64) 후 응답헤더에 JWT를 담아 보낸다.
클라이언트는 요청시 요청헤더에 JWT를 함께 보내고 서버는 디코딩해서 헤더, 페이로드, 전자서명으로 분리한다.
헤더와 페이로드를 서버의 비밀키로 해시함수를 돌려 새로운 전자서명을 발급한다.
요청헤더에 있던 전자서명과 방금 서버에서 만든 전자서명을 비교해 토큰의 유효성 검사를 진행한다.
(헤더나, 페이로드가 변경되면 해시 함수 알고리즘에 의해 다른 전자서명이 나온다)

즉, 헤더와 페이로드가 변경되지 않았으면 동일한 전자서명이 도출되므로 동일한 사용자라 판단한다.
하지만 토큰도 훔쳐갈수 있으므로 https을 통해서 통신해야 한다.


테스트코드

테스트코드를 만들어 보자

public class JwtTest {
	// 헤더와 페이로드가 동일해도 다른 시크릿키를 이용하면 결과가 달라진다 
    // 헤더+페이로드 (시크릿키) -> 시그니처
    // ABC          (메타코딩) -> 1313AB
    // ABC          (시크)     -> 5335KD
    
    @Test
    public void createJwt_test() throws Exception{
        // given
        String jwt = JWT.create().withSubject("토큰제목")
                .withExpiresAt(new Date(System.currentTimeMillis()+1000*60*60*24*7)) // 만료시간 - 일주일
                .withClaim("role","guest") // 권한설정 ex guest, manager,...
                .sign(Algorithm.HMAC512("메타코딩")); // 알고리즘이 들어가는 위치 // HMAC -> 해시함수 + 대칭키
        System.out.println("테스트 : "+ jwt);
        // eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiLthqDtgbDsoJzrqqkiLCJyb2xlIjoiZ3Vlc3QiLCJleHAiOjE2Nzk4OTQ5NDd9.uDnJDntD9l2HqOOuOcgdGXHl79micf7baXhcjjDc8K7HaEJI3iEMwOf3ip-nthdny-Ds_XesnAgDTBfbsYuoYA
    }
}

출력된 토큰을 복사후 jwt.io 로 가서 입력

base64로 만들어져서 인코딩/디코딩이 가능하다

보통 토큰을 열게 되면 아이디(id)와 권한정보(role)가 들어있다.

// 헤더
{
  "typ": "JWT",
  "alg": "HS512"
}
// base64로 인코딩한 헤더
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9
// 페이로드
{
  "sub": "토큰제목",
  "id": 1,  // 아이디 정보는 있어야 구분
  "role": "guest",
  "exp": 1679894947
}
// base64로 인코딩한 페이로드
eyJzdWIiOiLthqDtgbDsoJzrqqkiLCJyb2xlIjoiZ3Vlc3QiLCJleHAiOjE2Nzk4OTQ5NDd9
 // 시그니쳐 (암호화 방식)
 // 헤더와 페이로드를 HS512암호화(메타코딩)한 결과 
  HMACSHA512( 
    base64UrlEncode(header) + "." + 
    base64UrlEncode(payload),
    your-256-bit-secret
  )

헤더와 페이로드는 base64로 디코딩이 되기 때문에 중요한 정보를 넣으면 안된다.

secret base64 encoded 를 체크하게 되면 헤더와 페이로드를 지정한 암호화방식과 비밀키로 암호화한 결과를 보여준다.

// HMAC512 으로 암호화한 결과
JZ3FXuN0z0BHyiUq8urhcvfSzTixLqpl46_yOEL5kfR2Szm8Hi6_xfvl8sBxFtDB6l-iNJHCu85yaIYgIJmWCQ

base64로 인코딩된 A(헤더), B(페이로드)와 암호화된 C를 묶어서 A+B+C를 JWT라고 함
다시 확인하면 . 으로 3개가 끊겨있는 것을 확인할 수 있음

토큰의 검증 방법

토큰의 검증을 필터에서 처리한다면

클라이언트가 요청시 첨부한 헤더와 페이로드를 서버가 가지고 있는 비밀키를 이용해서 해쉬함수를 돌리게 되는데 이때 나오게된 전자서명과 클라이언트가 헤더에 보낸 전자서명이 일치한다면 인증이 된다.

jwt는 세션을 대신하기 때문에 stateless 프로토콜에서 구현된다. ( 상태 관리 X )
(세션은 stateless한 http를 stateful하게 만들어주는 기술)

서버는 비밀키를 가지고 있지만 세션이나 DB에 저장하지는 않는다. ( 보통 환경변수에 저장 )

토큰은 필터에서 한번 체크하고 내부적으로는 다시 검증하지 않는게 좋다.
인증이 되었을때 세션을 잠시 생성하고 인터셉터로 추가적인 권한처리를 하면 좋다.
토큰으로는 인증을 처리하고(필터) 토큰내부의 id나 role정보로 권한을 체크한다.(인터셉터)

필터는 DB에 접근할 수 있지만 권장하지 않기 때문에 DB가 필요한 권한처리를 하지 않는다.
임시적으로 유효한 세션을 생성해 이용하면 DB에 쿼리를 날릴필요가 없어 IO가 발생하지 않아 오버헤드를 줄일 수 있다.

세션의 단점은 서버가 추가되었을때 다른 서버에는 세션이 없기 때문에 세션관리가 힘들어진다.
이럴 경우에는 REDIS를 이용해서 세션서버를 따로 만들어도 되고 토큰을 이용해서 관리해도 좋다.

OAuth와 함께 이용한다면 ?

( 카카오를 예로 들어 보면 )

클라이언트가 카카오로그인(OAuth)을 통해 코드를 받고 코드를 서버에 준다.
서버는 코드를 카카오에 던져서 카카오의 코드가 맞는지 확인한다.
카카오는 자신이 발행한 코드임을 증명하는 토큰을 발행해준다.
서버는 지급받은 토큰을 이용해서 카카오에 회원정보를 요청한다.
카카오는 스코프에 맞는 회원정보를 리턴해주고 서버는 리턴받은 정보를 가지고 자동로그인을 만들어 세션을 만든다.

여기서 상태가 있는 서버를 만든다면 JSESSIONID을 이용해서 세션을 주고 받으면 되고 서버가 추가되면 REDIS를 이용해서 세션을 관리한다.

두번째로 상태가 없는 서버를 만든다면 서버만의 토큰을 발행해 클라이언트와 주고 받으면서 인증을 처리하면 된다. 서버에서는 임시 세션을 만들어서 id와 role을 잠시동안 관리한다.
또한 톰캣에서는 stateless설정을 통해서 JSESSIONID를 비활성화 할 수도 있다.

OAuth를 이용하면 카카오 토큰을 이용해서 로그인을 하지만 이때 사용된 카카오 토큰은 카카오 서버에서 유저정보를 가져오기 위한 수단일뿐 가져온 유저정보로 로그인을 한 뒤 우리 서버만의 토큰을 다시 발행해서 인증처리를 해야한다.

어플을 만든다고 가정해보자.

프론트에서는 OAuth를 이용해서 카카오로그인을 할텐데 이때는 라이브러리가 바로 토큰을 받아버린다.
하지만 카카오의 토큰이므로 프론트에서는 받은 토큰을 서버로 다시 보내야한다.
서버에서는 카카오에 토큰이 맞는지 검증을 해야한다. 이때 검증하지 않는 실수를 하면 안된다.

토큰과 신뢰는 거리가 멀다.
토큰이라고 모두 신뢰할 수 있는게 아니기 때문에 토큰이 정상적인지 검증하는 과정이 반드시 필요하다.

카카오가 주는 토큰은 Access토큰이고 개발자가 서비스하는 서버는JWT토큰을 만들어야 한다.
JWT토큰을 만드는 즉시 카카오 토큰은 의미가 없으니 버린다.

구현하기

테스트 코드 작성

public class JwtTest {

    @Test
    public void verifyJwt_test() throws Exception {
        // given
        String jwt = JWT.create().withSubject("토큰제목") 
                .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7)) // 일주일
                .withClaim("id", 1)
                .withClaim("role", "guest") // 권한
                .sign(Algorithm.HMAC512("메타코딩")); // 알고리즘 / HMAC - 해시함수 + 대칭키       
        // when
        try {
            DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("메타코딩"))
                        .build().verify(jwt);
            int id = decodedJWT.getClaim("id").asInt();
            String role = decodedJWT.getClaim("role").asString();
            System.out.println(id);
            System.out.println(role);
        } catch (SignatureVerificationException e) {
            System.out.println("검증 실패 - 시그니처 오류" + e.getMessage()); // 위조됨
        } catch (TokenExpiredException e){
            System.out.println("토큰 만료"+ e.getMessage());
        }
    }
}

verify() 클라이언트의 요청헤더로 들어온 토큰을 해쉬함수에 넣어 만든 토큰을 비교해 검증하는 메소드다.
파라미터에 클라이언트가 요청시에 넘겨준 토큰을 넣는데 토큰은 헤더 + 페이로드 + 시그니쳐로 구성되어 있으므로 서버가 가지고 있는 비밀키로 헤더와 페이로드를 해시함수로 변환했을때 클라이언트가 가지고온 전자서명과 일치하면 검증이 된다.
비밀키는 서버만 알고 있으므로 처음 발행해준 토큰과 일치하지 않으면 검증은 실패한다.
검증 실패시 발생하는 익셉션을 처리해서 어떤 이유로 검증을 실패했는지 분류하고 있다.

실제 구현하기

테스트된 코드를 가져와서 클래스를 만든다.

public class JwtProvider {
    private static final String SUBJECT = "jwtstudy";
    private static final int EXP = 1000 * 60 * 60; // 1시간
    public static final String TOKEN_PREFIX = "Bearer "; // 스페이스 1칸 필요
    public static final String HEADER = "Authorization";
    private static final String SECRET = "메타코딩"; // 학습용으로 여기 적음, 보안상 적으면 안됨

    public static String create(User user) {
        // given
        String jwt = JWT.create().withSubject(SUBJECT)
                .withExpiresAt(new Date(System.currentTimeMillis() + EXP))
                .withClaim("id", user.getId())
                .withClaim("role", user.getRole()) 
                .sign(Algorithm.HMAC512(SECRET));
        return TOKEN_PREFIX + jwt;
    }

    public static DecodedJWT verify(String jwt) throws SignatureVerificationException, TokenExpiredException {
        // when
        DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET))
                .build().verify(jwt);
        return decodedJWT;
    }
}

jpa를 이용한다.

@NoArgsConstructor(access = AccessLevel.PROTECTED) // new 생성자를 만들지 못하게 설정
@Getter
@Table(name = "user_tb")
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String username;
    private String password;
    private String email;
    private String role;
    @CreationTimestamp
    private Timestamp createdAt;

    @Builder
    public User(Integer id, String username, String password, String email, String role, Timestamp createdAt) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
        this.createdAt = createdAt;
    }

로그인 쿼리 작성

public interface UserRepository extends JpaRepository<User, Integer>{
    @Query("select u from User u where u.username = :username and u.password = :password")
    Optional<User> findByUsernameAndPassword(
        @Param("username") String username,
        @Param("password") String password
    );
}

DB없이 구현하는 방법 - @Bean을 만들어서 서버 실행시 더미 데이터를 h2 메모리에 넣는다.

@SpringBootApplication
public class JwtstudyApplication {

	@Bean
	CommandLineRunner initDatabase(UserRepository userRepository){ 
    	// IoC 에 등록할때 레파지토리가 이미 IoC에 있으므로 파라미터에 넣으면 된다
		return (args)->{
			userRepository.save(User.builder()
						  .username("ssar")
						  .password("1234")
						  .email("ssar@nate.com")
						  .role("user")
						  .build());
			userRepository.save(User.builder()
						  .username("admin")
						  .password("1234")
						  .email("admin@nate.com")
						  .role("admin")
						  .build());
		};
	}
	public static void main(String[] args) {
		SpringApplication.run(JwtstudyApplication.class, args);
	}
}

컨트롤러에서 로그인을 하면 토큰을 만들어 응답헤더에 담아서 리턴한다.

    @PostMapping("/login")
    public ResponseEntity<?> login(User user){
        Optional<User> userOP = userRepository.findByUsernameAndPassword(user.getUsername(), user.getPassword()); 
        // Optional이 붙으면 강제성이 부여됨 null 처리 필요
        if(userOP.isPresent()){ // 값이 존재하면
            String jwt = JwtProvider.create(userOP.get());
            return ResponseEntity.ok().header(JwtProvider.HEADER, jwt).body("로그인 성공"); // 계정 있으면 토큰 리턴
        }else{  
            return ResponseEntity.badRequest().build();
        }
        // 리턴된 토큰은 - Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqd3RzdHVkeSIsInJvbGUiOiJ1c2VyIiwiaWQiOjEsImV4cCI6MTY3OTMwNDQzOX0.MTX8lhEEaAMIGecvQefT3glAgV5Id-Z-pRaaTbUkx2qyeO7g951hFilu6NBcMzdG0xAUESp3REbxVHYF7_D04w
    }

토큰 검증하기

필터를 추가해서 필터에서 토큰을 검증해보자

로그인 클래스를 만들고

@Getter
public class LoginUser {
    private Integer id;
    private String role;

    @Builder
    public LoginUser(Integer id, String role) {
        this.id = id;
        this.role = role;
    }
}

토큰을 검증해서 익셉션이 발생하지 않으면 일치하는 토큰이므로 임시 세션을 생성한다.

public class JwtVerifyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
    					throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        String prefixJwt = req.getHeader(JwtProvider.HEADER); // Authorization
        String jwt = prefixJwt.replace(JwtProvider.TOKEN_PREFIX, ""); // Bearer 제거
        try {
            DecodedJWT decodedJWT = JwtProvider.verify(jwt);
            int id = decodedJWT.getClaim("id").asInt();
            String role = decodedJWT.getClaim("role").asString();
            // 내부에서 사용할 권한처리에 필요한 세션을 잠시 넣어줌   
            HttpSession session =  req.getSession();
            LoginUser loginUser = LoginUser.builder().id(id).role(role).build();
            session.setAttribute("loginUser", loginUser);
            chain.doFilter(req, resp);
        }catch (SignatureVerificationException sve){
            resp.setStatus(401);
            resp.setContentType("text/plain; charset=utf-8");
            resp.getWriter().println("로그인 다시해1");
        }catch (TokenExpiredException tee){
            resp.setStatus(401);
            resp.setContentType("text/plain; charset=utf-8");
            resp.getWriter().println("로그인 다시해2");
        }
    }
}

작성한 필터를 등록한다.

@Configuration
public class FilterRegisterConfig {
    
    @Bean 
    public FilterRegistrationBean<?> jwtVerifyFilterRegister(){
        FilterRegistrationBean<JwtVerifyFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new JwtVerifyFilter());
        registration.addUrlPatterns("/user/*"); // user로 시작하는 주소일때만 토큰 검사
        registration.setOrder(1);
        return registration;
    }
}

필터가 걸리는 주소를 요청한다면 토큰이 일치했을때 임시적으로 세션이 만들어졌으므로 컨트롤러에서 세션의 내부 데이터를 이용해서 권한처리를 할 수 있다.

    @GetMapping("/user")  // 먼저 필터에서 인증 필요
    public ResponseEntity<?> user(){ 
        // 권한처리, 이 사람이 이 게시글의 주인인지 ?
        LoginUser loginUser = (LoginUser) session.getAttribute("loginUser");
        if(loginUser.getId() == 1) {
            return ResponseEntity.ok().body("접근 성공");
        }else{
            return new ResponseEntity<>("접근 실패", HttpStatus.FORBIDDEN);
        }
    }
profile
작은것부터

0개의 댓글