(2024년 6월 기준) 10분 안에 JWT 인증 구현하기 (Spring Boot 3)

(2024년 6월 기준) 10분 안에 JWT 인증 구현하기 (Spring Boot 3)

스프링 시큐리티를 사용하지 않고 구현할 수 있습니다. (시큐리티는 이후 추가해도 됨.)

JWT 인증은?

분산 환경에서 서버가 구분됨에 따라, 서버 자원인 '세션'을 통한 로그인 구현을 보완하거나 대체할 기술이 필요합니다. JWT는 그중 가장 널리 사용되고, 인프라 종속적이지 않으며, 추가적인 내부 통신의 빈도를 낮추어 빠른 인가를 할 수 있는 장점이 있는 인증 및 인가 수단입니다. 따라서 JWT를 통한 인증 방식이 유행하고 있습니다.

토큰의 구성 = header.payload.signature

JWT는 웹의 여러 환경에서 사용할 수 있도록 호환성이 좋은 base64라는 문자열로 작성되며, 점(.)을 통해 header, payload, signature라는 영역으로 나뉩니다. Base64 자체는 암호화가 아니라 데이터 전송에 유용한 인코딩 방식이며, header와 payload는 암호화되지 않은 채 공개된 정보입니다. 누구나 이 정보를 읽고 수정할 수 있습니다. 단, 수정하는 경우 서버는 이 정보가 신뢰할 수 없는 정보임을 알 수 있습니다. 그 이유는 다음 단락에 설명하는 시그니처(signature)가 있기 때문입니다.

Signature(시그니처)는 서명이라고 해석할 수 있으며, 해싱을 통해 값을 갈아 넣은 정보입니다. 해싱은 위·변조 방지에 자주 사용되는 기술이며, 이 signature를 생성하는 해싱에 필요한 추가적인 시크릿 키는 오직 서버에서만 알고 있습니다. 따라서 헤더와 페이로드를 수정하려거든, 서버만 알고 있는 시크릿 키를 알아야 서명을 새로 생성할 수 있습니다.

클라이언트는 로그인 시 서버로부터 이 토큰을 받고, 인가가 필요한 API 이용할 때 토큰을 요청에 담아 서버로 보냅니다. 서버가 헤더와 페이로드, 그리고 시그니처로 조합된 토큰을 받으면, 자신이 알고 있는 시크릿 키와 함께 헤더 및 페이로드를 다시 해싱합니다. 해싱 결과를 사용자가 보낸 시그니처와 비교하여 일치하는지 확인합니다. 이렇게 조합된 토큰과 방식을 JWT(JSON Web Token)라고 하며, 이 토큰을 활용한 인증 및 인가 방식을 JWT 인증이라고 합니다. (JWT 인증을 줄여 JWT로 표현하기도 합니다.)

JWT 구성 속성(Configuration Properties)

Custom YAML Properties

스프링 부트 애플리케이션을 생성할 때, 프로그램에 넣어 줄 설정 값을 소스 코드에 직접 작성하지 않고, .properties 또는 YAML(.yaml, .yml) 설정 파일로 구분해서 작성할 수 있습니다.

다음 경로에, 다음처럼 폴더와 파일을 잘 생성해 줍니다.

  • src/main/resources

    • app/jwt/jwt.yml

    • app/jwt/jwt-local.yml

    • 그 외 필요하다면 jwt-dev.yml 등 각 프로파일에 사용할 파일을 추가합니다.

jwt.yml

환경변수를 인식시키기 위해 ${환경변수명}을 사용했습니다. Active profile을 설정하는 과정을 잠시 후 소개하겠지만, 관련 설정을 건너뛰려면 jwt-local.yml과 동일한 방식으로 작성하세요.

app.jwt:
  secret: ${JWT_SECRET} # Active profile 설정을 하지 않는다면 jwt-local.yml과 동일하게 작성
  max-age: 1_800 # [sec]

jwt-local.yml

환경변수가 없는 경우 기본값을 인식시키기 위해 ${환경변수명:기본값}을 사용했습니다.

⭐️ 참고

Secret key 생성 시 다음 규칙을 따릅니다.

  • 충분히 긴 문자열을 사용합니다.

  • Base64 포맷을 따라야 합니다.

app.jwt:
  secret: ${JWT_SECRET:cNHwPX4whgN9PbJ9XPVs/wsLPu7jcDtDM98n32ZMDAwJR/6p/xEuQhr6vFp61a1R}
  max-age: 1_800 # jwt.yml과 중복되는 내용은 생략할 수 있습니다.

Application.yaml에 등록

spring.config.import=classpath:app/jwt/jwt.yml을 등록합니다. application.properties는 목록 작성 시 콤마(,)로 구분합니다.

spring:
  config:
    import:
      - classpath:app/jwt/jwt.yml # "classpath:"는 생략 가능

스프링 Configuration Properties 빈

적절한 패키지를 생성하고, 다음처럼 record를 작성합니다. (불변 객체 생성을 위한 클래스)

  • (필수 속성) 기본적으로 모든 필드는 필수 속성으로 취급됩니다. (따로 조치를 추가하지 않습니다.)

  • (선택적 속성) 필수 속성이 아닌 경우 기본값을 넣어 줍니다. (if null)

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;

/**
 * @param secret JWT 시크릿 키
 * @param maxAge JWT Expiration (unit: seconds)
 */
@ConfigurationProperties("app.jwt") // 설정 파일에서 reference를 정합니다.
@ConfigurationPropertiesBinding // 설정 파일과 값이 연결되도록 합니다.
public record DemoJwtProperties(
        String secret,
        Long maxAge
) {
    public DemoJwtProperties {
        // this 사용 X (콤팩트 생성자)
        if (maxAge == null) {
            maxAge = 1_800L;
        }
    }
}

Configuration Properties 빈 스캔

스프링 부트 메인 애플리케이션에 애노테이션을 추가합니다.

@SpringBootApplication
@ConfigurationPropertiesScan // added
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

멀티모듈인 경우 basePackages = "..." 등이 필요합니다.
(만약 com.example이 모든 모듈의 공통 베이스 패키지라면, com.example로 작성합니다.)

Active Profile 추가

인텔리제이 기준으로 작성하겠습니다. 이클립스 기반 IDE 등 다른 개발 환경에서는 따로 검색해 보세요.

인텔리제이에서 Run / Debug Configurations를 띄울 수 있는 메뉴를 찾습니다. (실행 버튼 옆)

인텔리제이 얼티밋 버전이라면 다음처럼 할 수 있습니다.

인텔리제이 커뮤니티 버전이라면 다음처럼 하면 됩니다. 작성할 내용은 캡처 아래에 있습니다.

-Dspring.profiles.active=local

JJWT 의존성 라이브러리 추가

현 시점 기준 최소 0.12.6 버전 이상을 적용하는 것이 좋습니다.

// build.gradle
dependencies {
    // jjwt
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
  • 가장 기반이 되는 모듈이 jjwt-api입니다. 우리는 이 모듈을 소스 코드에 사용합니다.

  • 그것에 대한 구현이 jjwt-impl이며, 런타임에 주입될 수 있도록 runtimeOnly로 작성합니다.

  • Jackson은 스프링의 기본 메시지 컨버터로 등록되어 있는 라이브러리입니다. 우리는 Jackson용 바인딩을 위해 jjwt-jackson 라이브러리를 runtimeOnly로 추가합니다.

JWT Provider 작성

JWT provider는 JWT를 생성하는 기능을 담당합니다. 사용자 정보에 의존하지 않는 것이 좋습니다. 지금은 JWT 인증에 사용하고 있지만, JWT를 생성하는 기능 자체는 오직 인증용으로만 사용되는 것이 아니기 때문입니다.

따라서 JWT provider는 JWT를 생성하는 역할을 할 뿐, 인증과 관련된 기능을 포함하지 않습니다.

JWT Provier 인터페이스

JJWT에 의존하지 않는 인터페이스를 하나 작성합니다. 우리는 이 인터페이스를 사용할 것입니다.

public interface JwtProvider {
    String generateToken(String subject, Map<String, ?> payload);
}

JWT Provier 구현 클래스

JJWT에 의존하는 구현 클래스를 만들어 빈으로 등록합니다. @Component 애노테이션을 통해 스프링 컨텍스트로 관리되는 빈이 됩니다. 따라서 생성자에 다른 빈을 받을 수 있습니다.

  • 생성자에는 우리가 작성한 DemoJwtProperties 빈을 받습니다.

  • 그것을 원하는 필드의 값 생성에 활용합니다.

  • 일부 기능은 private 함수로 리팩토링을 해 두었습니다.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class DemoJwtProvider implements JwtProvider {

    private final Key secretKey;
    private final long maxAge;

    public DemoJwtProvider(DemoJwtProperties demoJwtProperties) {
        secretKey = generateSecretKey(demoJwtProperties.secret());
        maxAge = demoJwtProperties.maxAge();
    }

    @Override
    public String generateToken(String subject, Map<String, ?> payload) {
        // header, payload, signature -> base64
        Claims claims = generateClaims(subject, payload);

        return Jwts.builder()
                .signWith(secretKey)
                .claims(claims)
                .compact();
    }

    // DemoJwtProperties 파일에 작성할 수도 있지만, 속성 파일에 외부 기술에 대한 종속성을 만들지 않기 위해 여기서 작성.
    private Key generateSecretKey(String secret) {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    private Claims generateClaims(String subject, Map<String, ?> payload) {
        Date now = new Date();
        Date expirationAt = new Date(now.getTime() + maxAge);

        return Jwts.claims()
                .subject(subject)
                .issuedAt(now)
                .expiration(expirationAt)
                .add(payloadAsMap)
                .build();
    }
}

JWT Provier 사용

이제 JWT Provier를 사용할 준비가 완료되었습니다. 이제부터 간단하게 JWT를 생성할 수 있습니다.

인증에 사용하기 위하여 인증 서비스 클래스에서 빈으로 주입받습니다. 이때 타입은 JwtProvider여야 합니다. (DemoJwtProvider면 안 됨.)

사용은 다음 예시처럼 할 수 있습니다.

Map<String, Object> payload = Map.of("authority", "USER");

String accessToken =
        jwtProvider.generateToken(username, payload);

인증 서비스에서 액세스 토큰 발행

토큰 객체 생성

@Builder
public record JwtAuthToken(
    String accessToken,
    String refreshToken
) {}

인터페이스

public interface SignInUseCase {
    JwtAuthenticationToken signIn(String username, String rawPassword);
}

인증 서비스

@Service
public AuthenticationService implements SignInUseCase {

    // ...
    private final JwtProvider jwtProvider; // (interface를 타입으로 사용)

    public AuthenticationService(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public JwtAuthenticationToken signIn(String username, String rawPassword) {
        // ... 인증에 필요한 작업 수행

        // 토큰 발행 (JWT)
        Map<String, Object> payload = Map.of("authority", "USER");
        String accessToken = jwtProvider.generateToken(username, payload);

        return JwtAuthToken.builder()
                .accessToken(accessToken)
                .refreshToken("this_is_an_example_refresh_token")
                .build();
    }

리프레시 토큰 발행

리프레시토큰은 서버에서 제어권을 갖는 데이터입니다. 서버에서 제어하기 때문에 서버 상태에 의존하지 않기 위한 JWT 방식으로 생성할 필요가 없으며, 보안상 좋은 알고리즘을 통해 랜덤으로 생성하는 것을 권장하고 있습니다.

SecureRandom을 통한 랜덤 문자열

java.security.SecureRandom은 자바 API에 포함된 클래스입니다. 암호학적으로 안전한 랜덤 값을 생성할 수 있습니다. SecureRandom 필드에 위임하여 다음처럼 유틸리티 클래스를 작성합니다.

이때도 다음 유틸리티 함수가 임의의 문자열을 생성하는 데에 유용한 유틸리티일 뿐, 로그인 기능과 직접 관련이 되는 클래스는 아님을 인식하고, 인증 기능과 관련한 정보에 의존하지 않도록 구현합니다.

import lombok.Builder;

import java.security.SecureRandom;
import java.util.Base64;

public final class DemoStringSecuredRandom {
    private DemoStringSecuredRandom() {}

    private static final SecureRandom RANDOM;
    private static final char[] CHAR_SET_62;
    private static final Base64.Encoder BASE64_ENCODER;

    private static final int CHAR_SET_LENGTH = 62;
    private static final int SUGGESTED_SEED_LENGTH = 64;
    private static final int STRONG_SEED_LENGTH = 128;

    static {
        // getInstanceStrong()을 사용하지 않은 이유: 시스템 설정에 의존하기 때문(SecureRandom).
        RANDOM = new SecureRandom();
        CHAR_SET_62 = new char[CHAR_SET_LENGTH];
        BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding();

        // A-Z
        for (int i = 0; i < 26; i++) {
            CHAR_SET_62[i] = (char) (i + 0x41);
        }
        // a-z
        for (int i = 26; i < 52; i++) {
            CHAR_SET_62[i] = (char) (i + 0x61);
        }
        // 0~9
        for (int i = 52; i < CHAR_SET_LENGTH; i++) {
            CHAR_SET_62[i] = (char) (i - 4);
        }
    }

    public static String nextString(int length) {
        return nextString(length, SUGGESTED_SEED_LENGTH);
    }

    @Builder(
            builderClassName = "NextStringBuilder",
            builderMethodName = "withParameters",
            buildMethodName = "generate"
    )
    public static String nextString(int length, int seedLength) {
        StringBuilder stringBuilder = new StringBuilder(length);
        setSeed(seedLength);

        for (int i = 0; i < length; i++) {
            char ch = CHAR_SET_62[RANDOM.nextInt(CHAR_SET_LENGTH)];
            stringBuilder.append(ch);
        }

        return stringBuilder.toString();
    }

    public static String nextStringStrong(int length) {
        return nextString(length, STRONG_SEED_LENGTH);
    }

    public static String toBase64(String string) {
        return BASE64_ENCODER.encodeToString(string.getBytes());
    }

    public static String nextBase64(int byteLength) {
        return nextBase64(byteLength, SUGGESTED_SEED_LENGTH);
    }

    public static String nextBase64(int byteLength, int seedLength) {
        setSeed(seedLength);
        byte[] bytes = new byte[byteLength];

        RANDOM.nextBytes(bytes);
        return BASE64_ENCODER.encodeToString(bytes);
    }

    public static String nextBase64Strong(int byteLength) {
        return nextBase64(byteLength, STRONG_SEED_LENGTH);
    }

    private static void setSeed(int seedLength) {
        byte[] seed = RANDOM.generateSeed(seedLength);
        RANDOM.setSeed(seed);
    }
}

리프레시토큰 발행

@Override
public JwtAuthenticationToken signIn(String username, String rawPassword) {
    // ... 인증에 필요한 작업 수행 (비밀번호 확인, 계정 status 확인 등)

    // 토큰 발행 (JWT)
    Map<String, Object> payload = Map.of("authority", "USER");
    String accessToken = jwtProvider.generateToken(username, payload);
    String refreshToken = DemoStringSecuredRandom.nextBase64Strong(32);

    return JwtAuthToken.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
}

Controller

Request/Response DTO

public final class AuthDto {
    @Builder
    public record SignInRequest(
            @NotBlank
            @Pattern(regexp = "^[a-z]+[a-z0-9]{3,30}$")
            String username,
            @NotBlank
            @Pattern(regexp = "(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,100}$")
            String password
    ) {}

    @Builder
    public record SignInResponse(
            @JsonProperty("access_token") // with JACKSON
            // @SerializedName("access_token") // with GSON
            String accessToken
    ) {}
}

이 구현은 예시입니다.

@RestController
public final class AuthApi {
    private final SignInUseCase signInUseCase;

    public AuthApi(SignInUseCase signInUseCase) {
        this.signInUseCase = signInUseCase;
    }

    @PostMapping("/sign-in")
    public SignInResponse signIn(@RequestBody @Valid SignInRequest body) {
        JwtAuthToken authToken = signInUseCase.signIn(
                body.username(),
                body.password()
        );

        String accessToken = authToken.accessToken();
        String refreshToken = authToken.refreshToken();

        // (쿠키에 refresh token 담기. HTTP Only 옵션.)

        // 액세스 토큰 반환
        return SignInResponse.builder()
                .accessToken(accessToken)
                .build();
    }
}

QnA

Q. static 메서드로 된 유틸리티 클래스를 작성하지 않고, @Component 애노테이션을 사용하여 빈으로 등록한 이유가 무엇입니까?

(답변)

Q. Record로 작성한 properties 객체 안에 메서드를 추가하여, String key를 java.security.Key로 변환하는 메서드를 작성할 수 있어 보입니다. 지금 방식이 나은 점은 무엇입니까?

(답변)

Q. JwtProvider 인터페이스를 만들지 않고, 바로 클래스를 만들어서 그것에 의존하도록 하여도 되는데 왜 인터페이스를 추가했습니까?

(답변)