관리 메뉴

거니의 velog

(29) 스프링 시큐리티와 API 서버 3 본문

SpringBoot_React 풀스택 프로젝트

(29) 스프링 시큐리티와 API 서버 3

Unlimited00 2024. 3. 6. 11:57

3. JWT 문자열 생성

* API 서버는 API 호출에 대해서 상태를 유지하지도 않고, 세션이나 쿠키를 사용할 수도 없기 때문에 API 호출 시에 인증된 사용자를 확인하는 방법으로 JWT(JSON Web Token) 문자열과 같은 문자열 토큰 기반의 인증 방식을 사용한다.

* JWT는 '헤더 + 페이로드 + 서명'으로 구성된 문자열이지만 서명과 유효시간을 지정할 수 있기 때문에 이를 이용해서 사용자의 인증 정보를 활용하는 경우가 많다. API 서버는 사용자의 상태를 유지하지 않기 때문에 JWT와 같은 토큰을 매번 같이 전송해서 인증을 확인하는데 이러한 용도로 사용하는 토큰을 흔히 Access Token이라고 한다(Access Token을 JWT로 만들어야 한다는 규칙은 없지만, 여러 장점<만료시간이나 암호화 등> 이 있어서 많이 사용된다).

* Access Token 은 API 서버 호출 시에 사용되기 때문에 토큰의 유효 시간을 짧게 지정하는데 이를 나중에 갱신하기 위한 별도의 토큰을 사용하기도 한다. 이를 Refresh Token 이라고 하는데, 최근 외부 API 연동 시에 인증을 처리하는 방식으로 사용하는 OAuth2 등에서 많이 사용한다(이에 대해서는 소셜 로그인을 처리할 때 조금 더 설명한다).

* JWT 구성은 아래 사이트를 이용해서 구현한다(버전에 따라 구현 방식에 차이가 있으므로 가능하면 포스트와 동일한 버전의 라이브러리를 활용하는 것을 권장한다).

https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api

* buld.gradle 파일에 라이브러리를 추가한다.

dependencies {
	(...)
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

(1) JWT 문자열 생성과 검증

* JWT 문자열 생성과 검증은 별도의 클래스를 생성해서 처리한다. 프로젝트 내 util 패키지를 구성하고 JWTUtil 클래스와 예외 처리를 위한 CustomJWTException 클래스를 작성한다.

package com.unlimited.mallapi.util;

/**
 * 사용자 정의 JWT 예외 클래스
 * JWT (JSON Web Token)와 관련된 예외를 처리하기 위한 사용자 정의 예외 클래스입니다.
 */
public class CustomJWTException extends RuntimeException {

    /**
     * 사용자 정의 JWT 예외 생성자
     * 
     * @param msg 예외 메시지
     */
    public CustomJWTException(String msg) {
        super(msg);
    }

}

* JWTUtil은 JWT 문자열 생성을 위해서 generateToken(), 그리고 검증을 위해서 validateToken() 메서드를 작성한다. 생성 시에 필요한 암호키를 지정하는데 길이가 짧으면 문제가 생기므로 길이가 30 이상의 문자열을 지정하는 것이 좋다.

* 터미널에 다음 명령어를 입력하여 랜덤한 키를 생성해 보자.

$ openssl rand -hex 64

package com.unlimited.mallapi.util;

import java.util.*;
import java.time.ZonedDateTime;
import javax.crypto.SecretKey;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.InvalidClaimException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;

import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTUtil {

    // 터미널로 생성한 랜덤키를 여기에 지정
    private static String key = "127ec3c8fad79aab343486444c83b6dbc8ecc1418b9df198e1633f31b70f4f110d27a4ecdf62fecfe7eedd4525ba7b7ad88499a253c3130b7a56dea64a0dec77";

    /**
     * JWT 토큰 생성 메소드
     *
     * @param valueMap 토큰에 포함될 클레임 정보를 담은 Map 객체
     * @param min      토큰의 유효 기간(분)
     * @return 생성된 JWT 토큰 문자열
     */
    public static String generateToken(Map<String, Object> valueMap, int min) {

        SecretKey key = null;

        try {
            key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage());
        }

        String jwtStr = Jwts.builder()
                .setHeader(Map.of("typ", "JWT"))
                .setClaims(valueMap)
                .setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
                .setExpiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant()))
                .signWith(key)
                .compact();

        return jwtStr;
    }

    /**
     * JWT 토큰 유효성 검사 및 클레임 추출 메소드
     *
     * @param token 검사할 JWT 토큰 문자열
     * @return 유효성 검사를 통과한 경우 클레임 정보를 담은 Map 객체
     * @throws CustomJWTException JWT 유효성 검사 중 발생한 사용자 정의 예외
     */
    public static Map<String, Object> validateToken(String token) {

        Map<String, Object> claim = null;

        try {
            SecretKey key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
            claim = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token) // 파싱 및 검증, 실패 시 에러
                    .getBody();
        } catch (MalformedJwtException malformedJwtException) {
            throw new CustomJWTException("MalFormed");
        } catch (ExpiredJwtException expiredJwtException) {
            throw new CustomJWTException("Expired");
        } catch (InvalidClaimException invalidClaimException) {
            throw new CustomJWTException("Invalid");
        } catch (JwtException jwtException) {
            throw new CustomJWTException("JWTError");
        } catch (Exception e) {
            throw new CustomJWTException("Error");
        }

        return claim;
    }
}

* 로그인 성공 시에 동작하는 APILoginSuccessHandler에서는 JWTUtil을 이용해서 Access Token과 Refresh Token 생성을 추가한다. 예제에서는 10분간 유효한 Access Token과 24시간 유효한 Refresh Token을 생성한다.

package com.unlimited.mallapi.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.google.gson.Gson;
import com.unlimited.mallapi.dto.MemberDTO;
import com.unlimited.mallapi.util.JWTUtil;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {

        // 로그인 성공 시 실행되는 메소드
        log.info("-------------------------------------------");
        log.info(authentication);
        log.info("-------------------------------------------");

        // 인증된 사용자 정보 추출
        MemberDTO memberDTO = (MemberDTO) authentication.getPrincipal();

        // 사용자의 클레임 정보 추출
        Map<String, Object> claims = memberDTO.getClaims();

        // Access Token과 Refresh Token에 대한 정보 생성
        String accessToken = JWTUtil.generateToken(claims, 10); // 10분
        String refreshToken = JWTUtil.generateToken(claims, 60 * 24); // 1시간

        claims.put("accessToken", accessToken);
        claims.put("refreshToken", refreshToken);

        // Gson 라이브러리를 사용하여 Map 객체를 JSON 문자열로 변환
        Gson gson = new Gson();
        String jsonStr = gson.toJson(claims);

        // HTTP 응답 헤더 및 바디 설정
        response.setContentType("application/json; charset=UTF-8");
        PrintWriter printWriter = response.getWriter();
        printWriter.println(jsonStr);
        printWriter.close();
    }

}

* Postman에서 정상적으로 로그인이 처리된다면 아래와 같이 AccessToken과 RefreshToken이 생성된 것을 확인할 수 있다.


[토큰의 검증]

* 생성된 토큰의 검증은 jwt.io 사이트에서 확인할 수 있다.

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

* 사이트 내에서 먼저, 오른쪽 하단에 서명 관련된 키를 입력하는데 JWTUtil에 있는 터미널로 생성한 랜덤키를 먼저 입력하는 것이 좋다.

* 생성된 JWT 문자열을 입력하면 JWT 문자열 내에 클레임(claims) 이라고 부르는 정보를 확인할 수 있다. 정상적인 경우 JSON 데이터로 만들어진 사용자 정보를 확인할 수 있다.

* 사용자는 서버에서 사용하는 암호화 키(key)를 모르기 때문에 유효시간이 지나면 다시 로그인을 해야만 올바른 JWT 문자열을 생성할 수 있다.