Spring

Json Web Token 사용법 및 정리 (jwt)

읽히는 블로그 2024. 12. 5. 16:35

▤ 목차

     

    이전에 세션이나 jwt에 대해 정리해둔게 있는데  최근 jwt를 사용하면 잊어버린 개념들을 정리할 겸 올린다.

     

    2024.06.03 - [web( jsp, servlet )/servlet] - 세션과 쿠키

     

    세션과 쿠키

    ▤ 목차" data-ke-type="html">HTML 삽입미리보기할 수 없는 소스   ✔ Authentication(인증) vs Authorization(인가)👏 한줄 정리인증은 로그인 과정과 같이 신원을 확인하는 과정을 말한다.인가는 로그인을

    hi-hahahoho.tistory.com

     

    2024.06.09 - [web( jsp, servlet )/jsp] - JWT란?

     

    JWT란?

    ▤ 목차" data-ke-type="html">HTML 삽입미리보기할 수 없는 소스   https://hi-hahahoho.tistory.com/76세션과 쿠키에 대해 정리했었다. 그 연장선인 jwt를 정리해보고자 한다.세션과 마찬가지로 HTTP 기반이기에

    hi-hahahoho.tistory.com

     

     

    ✔ 들어가기

    인터넷에서 다양한 서비스를 사용할때, 우리는 로그인 후 특정 작업을 할 수 있는 권한을 부여받는다.

    이때 권한을 부여받은 상태 즉, 로그인 상태를 안전하게 유지하는 방법으로 인증과 인가가 있다.

    이 과정에서 중요한 기술 중 하나가 JWT이다.

     

    ⌨ 인증과 인가

    인증 : 사용자의 신원을 확인하는 과정

     예) 비밀번호, 생체 인식... 등 "누구신가요?"

     

    인가 : 인증된 사용자가 특정 서비스에 접근할 수 있는 권한을 부여하는 과정

     예) 로그인 안한 사람(로그인 화면만 보임) / 로그인 한 사람(프로그램 사용 가능) 등 "어떤 권한이 있는 사람이죠?"

     

     

    💻 알고 들어가자

    [대전제] 클라이언트가 요청을 해야 서버가 대답한다. 서버가 먼저 대답하는 경우는 없다.

    [대전제] 클라이언트는 기본적으로 상태를 저장하지 않는다. HTTP는 상태 비저장 프로토콜이다.
                  통신이 한번 이뤄지고 나면 연결은 바로 끊어진다.

    [대전제] 서버는 클라이언트를 신뢰하면 안되고 클라이언트가 보낸 요청은 항상 검증해야한다.

     

    • 쿠키 : 클라이언트가 처음 요청을 보내고 서버가 클라이언트에게 응답으로 전송하는 작은 데이터 파일
      • 클라이언트는 쿠키를 자동으로 서버에 보낸다.
    • 세션 : 서버 측에 저장되는 데이터, 클라이언트가 요청할때마다 세션 ID를 통해 해당 데이터를 식별
      • 세션은 서버에 상태를 저장하고 유지하기 때문에 서버 부하가 증가 할 수 있다.
    • 토큰 : 클라이언트와 서버 간의 인증을 위해 사용되는 문자열.
      • 서버는 인증된 사용자에게 토큰을 발급하고, 클라이언트는 이 토큰을 사용하여 서버에 요청을 보낸다. 

     

     


     

     

     

    ✔JWT (json web token)

    실생활에서 예시를 들자면 토큰은 출입증 카드이다.

     

    ⌨ 기본 개념

    JWT는 웹 애플리케이션에서 사용자의 인증 상태를 유지하기 위한 방식이다. 위의 개념 중에서는 인가에 조금더 집중된 개념이라고 생각하면 된다.

     

    우리는 보통 로그인 후, 서버에 세션을 저장하거나 쿠키에 인증 정보를 담아서 로그인 상태를 유지한다.

    세션의 단점으로 서버에 부하가 있다. 이를 해결하기 위해 나온 개념인 JWT는 서버가 아닌 클라이언트에 인증 정보를 저장하기 때문에 서버 부하를 줄이고 확장성을 높일 수 있다.

     

    💳 출입증 카드랑 토큰

    [ 인증 ]

    - 출입 카드 -

    회사에서 직원 등록을 하면 발급받는 출입카드를 받는다.

    이 카드는 회사의 직원임을 증명하는 역할을 한다.

    카드 자체에는 직원 정보가 포함되어 있다.

     

    - 토큰 -

    서버는 사용자가 인증된 후 토큰을 발급한다.

    토큰에 사용자의 정보와 권한이 포함되어 있다.

     

    [ 인가 ]

    - 출입 카드 -

    1 ) 출입카드를 받으면 자신이 허락받은 공간은 갈 수 있다.

    하지만 높은 층은 올라 갈 권한이 없기에 높은 층엔 올라 갈 수 없다.

     

    2) 출입 카드는 보안이 강화된 카드이다. 이 정보는 암호화되어 외부에서 볼 수 없도록 보호된다.

     

    3) 퇴사를 하면 해당 출입 카드가 무효화되어 사용할 수 없다.

     

    - 토큰 -

    1) 사용자가 특정 리소스를 요청할 때, 서버는 토큰을 검증하여 해당 사용자가 그 리소스를 이용할 수 있는 권한이 있는 확인한다.

     

    2) 토큰에도 서명이 포함되어 있어, 누군가 토큰을 변조할 수 없다. 이 서명을 통해 토큰이 위변조되지 않았는지 검증할 수 있다.

     

    3) 유효기간이 지난 JWT 토큰도 마찬가지로 만료되어 더 이상 사용할 수 없다.

     

    🔶 JWT의 구조

     

     

    ✏️헤더(Header)

    토큰의 유형과 서명 알고리즘을 지정하는 정보를 담고있다.

    ✏️ 페이로드(Payload)

    사용자의 정보를 담고 있는 부분이다.

    예를 들어, 사용자 ID나 이메일과 같은 정보를 포함할 수 있다.

    기본적으로 인코딩된 형태로 저장된다. 서명된 값으로 보호된다.

    페이로드에 포함되는 정보는 클레임 (Claims) 이라고 부른다.

     

    1. 등록된 클레임(Registered Claims): 사전에 정의된 클레임으로, iss(발급자), exp(만료 시간), sub(주제) 등이 있다.
    2. 공개 클레임(Public Claims): 애플리케이션 간에 공유할 수 있는 임의의 클레임
    3. 비공개 클레임(Private Claims): 애플리케이션 간에 특정 목적을 위해 사용되는 임의의 클레임.
      보통 클라이언트와 서버 간의 정보 교환에 사용된다.

     

    ✏️ 서명(Signature): 헤더와 페이로드를 비밀 키를 사용해 암호화한 값으로, 토큰의 무결성을 검증하는 데 사용된다.

     

     

     

    더보기

    1) 클라이언트가 로그인 요청

    먼저, 사용자가 웹 애플리케이션에 로그인합니다. 로그인 정보(ID, PW)를 서버에 전달하면, 서버는 해당 정보를 검증한 후 JWT를 발급합니다.

    2) JWT 토큰 생성

    서버는 사용자의 정보를 바탕으로 JWT 토큰을 생성합니다. 이 토큰은 암호화된 서명을 포함하여 안전하게 클라이언트에게 전달됩니다.

    3) JWT 토큰을 클라이언트에 전달

    서버는 생성된 JWT 토큰을 HTTP 응답의 쿠키나 헤더에 담아서 클라이언트에게 전달합니다. 클라이언트는 이 토큰을 로컬 스토리지나 쿠키에 저장해두고 이후의 요청에서 이 토큰을 서버에 전송합니다.

    4) 클라이언트가 서버에 요청할 때 JWT 토큰 전송

    클라이언트가 서버에 API 요청을 보낼 때, 이 JWT 토큰을 HTTP 헤더에 포함하여 전송합니다. 서버는 이 토큰을 검증하여 유효한 사용자임을 확인한 후 요청을 처리합니다.

     

    👏 대칭키와 비대칭키  JWT

    JWT에서 대칭키는 주로 HMAC 알고리즘을 사용하여 서명을 생성할 때 사용된다.
    이 경우, 서버는 비밀 키(shared secret)를 알고 있고, 클라이언트는 그 비밀 키를 사용해 서명을 생성하거나 검증할 수 있다.

    • 대칭키 방식: HMAC을 사용하여 JWT 서명을 생성하고 검증한다.
    • 비대칭키 방식: RSA 또는 ECDSA와 같은 알고리즘을 사용하여 공개키와 비공개키로 JWT 서명을 생성하고 검증한다.

     


     

     

     

    ✔ 코드로 설명

    • 이번 프로젝트는 JWT+ security filter( JwtAuthenticationFilter )를 사용했다.
    • JWT는 대칭키 방식인 HMAC-SHA256 알고리즘을 사용했다.
    • 토큰은 cookie에 넣기로 했다.

     

    ⌨ JWT

    secretKey는 생성할때 사용하는 비밀 키이다. 서버와 클라이언트가 공유해야한다. 

    🙉토큰 생성

    public String generateToken(String username) {
            return Jwts.builder()
                    .setSubject(username)  // 사용자 정보 (subject)
                    .setIssuedAt(new Date())  // 토큰 발급 시간
                    .setExpiration(new Date(System.currentTimeMillis() + 86400000))  // 1일 후 만료
                    .signWith(SignatureAlgorithm.HS256, secretKey)  // 서명
                    .compact();
        }

     

    이런 식으로 생성을 한다. 나는 비밀키를 SHA256 알고리즘에 맞는 형식으로 감쌌다.

    new SecretKeySpec(secretKey.getBytes(), "HmacSHA256")

     

     

     

     

    🙉토큰 검증

    public boolean validateToken(String token, String username) {
            return (username.equals(extractUsername(token)) && !isTokenExpired(token));
        }

     

    해당 토큰이 유효한지 검사하는 메서드이다.

    나는 JwtAuthenticationFilter인 필터를 사용해서 매 요청마다 토큰을 확인할 것이다.

    그러니 해당 검증하는 메서드에서 claim의 정보를 추출해서 자신이 해당 클래임을 설정하는 것이 좋다.

    이와 같이 subject, issuer, expiration을  검증해주는 과정도 넣어 토큰 검증을 강화했다.

     

    🙉토큰을 필터에 적용하기 ( JwtAuthenticationFilter )

    @Component
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        @Autowired
        private JwtUtils jwtUtils;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, javax.servlet.http.HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            // Authorization 헤더에서 토큰 추출
            String token = getTokenFromRequest(request);
            if (token != null && jwtUtils.validateToken(token, jwtUtils.extractUsername(token))) {
                // 유효한 토큰이라면, 사용자를 인증
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        jwtUtils.extractUsername(token), null, null);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            filterChain.doFilter(request, response);  // 필터 체인 진행
        }
    
        private String getTokenFromRequest(HttpServletRequest request) {
            String header = request.getHeader("Authorization");
            if (header != null && header.startsWith("Bearer ")) {
                return header.substring(7);
            }
            return null;
        }
    }

     

    위의 코드는 권한 정보가 필요 없거나, 권한을 설정할 필요가 없는 간단한 경우에 사용하는 코드이다.

     

    나는 사용자의 권한 정보를 바탕으로 접근을 제어해야하기때문에 getAuthentication() 을 따로 빼서 만들었다.

     

    권한 정보가 포함된 Authentication 객체는 Spring Security에서 제공하는 접근 제어 기능을 활용할 수 있게 도와준다.

     

    💻 Security에 filter 설정하기

    필터에 jwt 검증 코드를 넣고 검증하는 필터도 만들었다.

    필터가 Security에 적용될 수 있도록 필터 체인에 추가해주기로 한다.

    WebSecurityConfigurerAdapter를 상속을 받는 방법과 안받는방법이 있다.

    이는 spring5 버전 이상에서 높은 자유도를 제공한다.

    이번 프로젝트에서 상속을 받지 않고 직접 @Bean으로 만들었다.

     

    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        private final JwtTokenProvider jwtTokenProvider;
    
        public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
            this.jwtTokenProvider = jwtTokenProvider;
        }
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/login", "/register").permitAll()  // 인증없이 허용해야하는 엔드포인트
                .anyRequest().authenticated()  // 그 외 요청은 인증된 사용자만 접근
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);  // JWT 필터 추가
            return http.build();
        }
    }

     

     

     

    👏 중요

    인증없이 접근해야하는 엔드포인트를 Spring Security에만 열어두어도 필터는 여전히 적용하기때문에

    필터에서도 인증을 건너뛰어야하는 엔트포인트를 지정해야한다.

     

     

    private boolean isExcluded(String requestURI, String httpMethod) {
            // 인증이 필요없는 경로
            return requestURI.equals("/api/users/login") 
                    requestURI.equals("/api/users/logout") ||
                    (httpMethod.equals("POST") && requestURI.matches("^/api/users/\\d+/receivers$"));
        }

     

    이렇게 필터에서 지정해준다.

     


     

     

    ✔ 토큰을 사용해보자.

    ⌨ 로그인시 토큰 발급

    한번 인증된 유저에게 토큰을 발급한다.

    지금까지 토큰을 만들고 각 요청마다 토큰을 검증하는 코드를 만들었다.

    이제 로그인 한 유저에게 토큰을 발급해보자.

     

    @RestController
    public Class Controller{
    	@Autowired
        private JwtUtils jwt;
        
        @PostMapping("/login")
        public ResponseEntity<String> login(@RequestBody LoginRequest loginRequest) {
            // 실제 비즈니스 로직에서는 ID/PW 검증 필요함
            if ("testuser".equals(loginRequest.getUsername()) && "password".equals(loginRequest.getPassword())) {
                String token = jwtUtils.generateToken(loginRequest.getUsername());
                return ResponseEntity.ok(token);  // 생성된 JWT 반환
            } else {
                return ResponseEntity.status(401).body("Invalid credentials");
            }
        }
    }

     

    서버에서 사용자 정보를 검증한 후 JWT 토큰을 발급하여 클라이언트에게 반환하는 코드를 작성해주면 된다.

     

    아래 로직은 내가 사용한 코드의 일부이다. 서비스 단에서 로그인 완료후 해당 유저가 있는지 확인한 후, 토큰을 생성하고 쿠키에 넣었다.

    메서드 명은 다른데, 위의 코드에서 generateToken()이다.

     

     

     

    🪙 토큰 사용하기 

    • 여기에서 내가 간과한부분이 나온다. 그동안 클라이언트에서 받아온 값으로 유저의 정보를 RUD했다면
      토큰을 사용할 때는 토큰에서 정보를 뽑아 RUD 를 진행해야한다.
      위의 대전제를 잊어버린 것 ( 서버는 클라이언트를 신뢰하면 안되고 클라이언트가 보낸 요청은 항상 검증해야한다. )

     

    이런 식으로 토큰에서 가져온 값이랑 클라이언트에서 가져온 userId는 한번 인증하는 정도로만 사용해야한다.

     

    public Integer getAuthenticatedUser() {
        // SecurityContextHolder에서 현재 인증된 사용자의 정보 가져오기
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    
        // 인증되지 않은 경우 예외
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new AccessDeniedException("인증되지 않은 사용자입니다.");
        }
    
        // JWT에서 사용자 정보는 Authentication 객체에 저장됩니다.
        // 보통 JWT를 사용할 경우 Principal에 사용자 정보가 들어갑니다.
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
        
        return userDetails.getId();  // 예를 들어 CustomUserDetails에서 getId() 메서드로 사용자 ID를 가져옵니다.
    }

     

    프로젝트를 하면서 CustomUserDetails 클래스를 따로 만들었는데, 따로 만들지 않은 경우,

    Principal에 사용자 정보에 들어간다.

    Spring Security에서 Authentication 객체의 Principal에는 인증된 사용자의 정보가 들어간다.

    일반적으로, **CustomUserDetails**와 같은 클래스를 별도로 만들지 않고 기본적으로 Spring Security에서 제공하는 사용자 정보 클래스를 사용할 수 있다.

     

     

    나는 이렇게 반복적으로 인증된 사용자 정보를 가져오는 코드가 필요하기 때문에

    위와같이 메서드를 따로 뺐다.


     

     

    😊 보충

    1. 비밀 키의 역할

    JWT는 서명을 사용해 토큰이 변조되지 않았는지 검증한다.

    비밀 키(secretKey)는 서명 생성에 사용되는 핵심적인 값이다.
    ( 대칭키 암호화 방식이기 때문에 비밀 키를 알고 있는 서버만 서명을 생성하고 검증 )

    클라이언트는 이 서명을 검증할 수 없지만, 서버는 클라이언트가 보낸 토큰이 변조되지 않았는지 서명을 통해 확인할 수 있다.

     

    2. 왜 AccessDeniedException을 사용?

     

    AccessDeniedException은 Spring Security에서 주로 인증된 사용자가 해당 리소스에 접근할 권한이 없을 때 발생하는 예외이다.

    인증되지 않은 사용자가 요청을 보냈을 때 적절하게 차단하고 보안을 강화하기 위해서 사용했다.

     

    이 과정에서 가장 많이 보는 에러는 http 403이나 401 상태 코드이다.

    더보기

    주요 차이점 요약

     

    401 Unauthorized 인증되지 않음 - 요청에 인증이 필요하지만 제공되지 않거나 잘못된 인증 정보가 제공됨 인증이 필요한 리소스에 인증이 되지 않은 사용자가 접근 시
    403 Forbidden 금지됨 - 인증은 되었으나 권한이 없음 인증은 완료되었으나 요청한 리소스에 대한 권한이 없음

     

     

    • 401 Unauthorized인증이 필요하거나 인증 정보가 잘못된 경우에 사용되며, 클라이언트는 인증 정보를 다시 제공해야 합니다.
    • 403 Forbidden인증은 되지만 리소스에 대한 접근 권한이 없는 경우에 사용되며, 클라이언트는 권한을 얻지 않는 한 해당 리소스를 접근할 수 없습니다.

     

     

    3. 왜 토큰을 헤더가 아닌 쿠키에 넣었나?

    사실 장단점이 너무 명확했다.

     

    장단점은 이 정도인데, 다른 것보다

     

    이 부분에서이다.

     

    쿠키로 결정한 이유는, XSS보다 CSRF 공격이 더 까다롭기 때문이다.  

    private void addJwtToCookie(HttpServletResponse response, String jwtToken) {
        Cookie cookie = new Cookie("JWT", jwtToken);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);    // HTTPS에서만 (SSL인증)
        cookie.setPath("/");
        cookie.setMaxAge(3600);    // 1시간
        cookie.setDomain(".com");  // 쿠키가 적용될 도메인
        response.addCookie(cookie);
    }

     

    이렇게 쿠키를 생성할때 조건을 넣어주면 XSS 공격을 어느정도 막아줄 수 있기 때문이다.