Spring Security - jwtk/jjwt를 활용한 JWT 토큰 발급 ( OAuth-0 )

TopOfTheHead·2026년 5월 26일

Spring OAuth

목록 보기
8/12

사전 설정

  • Spring Starter 정의


  • JWT 라이브러리의존성 정의
    jwt.io->libraries
    JWT를 공식적으로 생성할 수 있는 라이브러리 모음

    jwtk/jjwt 사용
    Spring OAuth 사용 시 nimbus-jose-jwt를 사용하는게 좋다.
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
  • application.yml 설정
    application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-name: Google
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET_KEY}
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
            scope:
              - email
          naver:
            client-name: Naver
            client-id: ${NAVER_CLIENT_ID}
            client-secret: ${NAVER_CLIENT_SECRET_KEY}
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
            scope:
              - email
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            user-name-attribute: response
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
  config:
    import: optional:file:.env.dev[.properties]
custom:
  redis:
    host: ${REDIS_HOST}
    port: ${REDIS_PORT}
  rootuser:
    email: ${INITIAL_ADMIN_EMAIL}
    password: ${INITIAL_ADMIN_PASSWORD}
    username: ${INITIAL_ADMIN_USER_NAME}
  jwt:
  	specification:
      issuer: ${TOKEN_ISSUER}
      audience: ${TOKEN_AUDIANCE}
    redirection:
      baseUrl: ${FRONTEND_BASE_URL}
    secrets:
      appkey: ${SECURITY_TOKEN_KEY}
    validations:
      access: ${ACCESS_TOKEN_TIME}
      refresh: ${REFRESH_TOKEN_TIME}
  • JWT 토큰 생성 시 활용되는 Secret Key 정의
    。해당 Secret Key를 기반으로 RSA 알고리즘 기반으로 토큰 서명을 수행

    Key 설정 시 매우 큰 Byte를 가진 문자열이어야한다.
    비대칭키 ( RS256 , ES256 ) 등은 최소 2048bit의 사용을 권장하므로 KeyPairGenerator에서 문자열 key 크기2048 bit = 8 Byte으로 생성하도록 설정.

    레인보우 테이블에 의해 하나씩 브루트포스 되는것을 막고자 UUID 2개를 생성 및 조합 후 Base64인코딩문자열SecretKey로 설정
    UUID Generator, Base64 Encoder

    。외부로 탈취되면 안되므로, application.ymlplaceholder로 등록하는 환경변수 ( .env.dev )로 저장
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# Kafka 관련 설정
KAFKA_BOOTSTRAP_SERVER=localhost:9092
# 기본 루트 유저
INITIAL_ADMIN_EMAIL=wjdtn747@naver.com
INITIAL_ADMIN_PASSWORD=wjdtn3902
INITIAL_ADMIN_USER_NAME=RootUser
# JWT 관련 설정
TOKEN_ISSUER=MTVS-STRATA
TOKEN_AUDIANCE=STRATA-USER
FRONTEND_BASE_URL=http://localhost:3000
SECURITY_TOKEN_KEY=MDE5ZTRkZTItZjczZi03MWZkLThiZDctNDU2YTJmYWQ0NDFjLTAxOWU0ZGUyLWY3M2YtNzk5NC04MjY3LTA1MmQ2Zjg0MDJhZg==
ACCESS_TOKEN_TIME=1800000
REFRESH_TOKEN_TIME=604800000
# OAUTH2 관련 설정
GOOGLE_CLIENT_ID=구글계정ID
GOOGLE_CLIENT_SECRET_KEY=구글SecretKey
NAVER_CLIENT_ID=네이버계정ID
NAVER_CLIENT_SECRET_KEY=네이버SecretKey

UUID 2개를 Base64인코딩문자열Key로 설정
어플리케이션에는 @Value 또는 @ConfiguationProperties로 가져올 수 있음.

  • yml 파일 내 저장된 Properties객체로 가져오기
    @ConfigurationProperties로 설정
    중첩 클래스 설정 시 불변성 보장 가능
@Getter
@ConfigurationProperties(prefix = "custom.jwt")
@RequiredArgsConstructor
public class JwtProperties {
    private final Specification specification;
    private final Redirection redirection;
    private final Validations validations;
    private final Secrets secrets;
    @RequiredArgsConstructor
    @Getter
    public static class Specification{
        private final String issuer;
        private final String audience;
    }
    @RequiredArgsConstructor
    @Getter
    public static class Redirection{
        private final String baseUrl;
    }
    @RequiredArgsConstructor
    @Getter
    public static class Secrets{
        private final String appkey;
    }
    @RequiredArgsConstructor
    @Getter
    public static class Validations{
        private final Long access;
        private final Long refresh;
    }
}
  • Role 저장용 Enum 정의
@Getter
@RequiredArgsConstructor
public enum UserRole {
    // 쇼핑몰의 경우 Enum 필드 안에 할인률 등의 속성을 추가해서 다양하게 활용 가능
    PLAYER("ROLE_PLAYER"),
    ADMIN("ROLE_ADMIN"),
  	EMPTY("EMPTY");
    private final String value;
}

EMPTYRefresh TokenRole 지정용도
Refresh Token재발급 용도 토큰으로서 사용자 정보를 필요로 하지 않으므로.

  • 도메인 정의
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Users extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    //
    @Column(length = 50, nullable = false)
    private String email;
    //
    @Column(length = 50, nullable = false)
    private String displayName;
    //
    @Column(nullable = false, length = 20)
    private String provider;
    //
    @Column(length = 100, nullable = false)
    private String password;
    //
    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private UserRole role;
    //
    @Builder
    public Users(
            String email,
            String displayName,
            String password,
            UserRole role
    ){
        this.email = email;
        this.displayName = displayName;
        this.password = password;
        this.role = role;
        this.provider = extractProvider(email);
    }
    public String extractProvider(String email){
        String[] s1 = email.split("@");
        String[] s2 = s1[1].split("\\.");
        return s2[0];
    }
}

provider : OAuth Provider명 ( google, naver, ... )

초기 Security Configuration 정의
JWT 토큰을 사용하여 세션을 사용하지 않으므로. CSRF 비허용세션 관리 전략 : STATELESS 설정
▶ 단. 다른 출처에서 접속 시 CORS는 허용해야한다.

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final OauthSuccessHandler oauthSuccessHandler;
    private final JwtFilter jwtFilter;
//
    @Value("${custom.baseUrl}")
    public String BASE_URL;
//
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .httpBasic(basic -> basic.disable())
                .formLogin(formLogin -> formLogin.disable())
                .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
                .sessionManagement(session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .oauth2Login(
                        oauth -> oauth.successHandler(oauthSuccessHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        // 개발 환경에서 필요한거 추후 운영에서 빼야됨
                        .requestMatchers("/h2-console/**", "/api/files/**" , "/api/actuator/**", "/v3/**" ).permitAll()
//
                        // Preflight Request 허용
                        .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
//
                        // 로그인 / 회원가입은 익명 사용자만 가능
                        .requestMatchers(HttpMethod.GET, EndPoints.GET_ANONYMOUS).anonymous()
                        .requestMatchers(HttpMethod.POST, EndPoints.POST_ANONYMOUS).anonymous()
//
                        .requestMatchers(HttpMethod.GET, EndPoints.GET_ADMIN_AUTHENTICATED).hasAnyRole(Role.SUPER_ADMIN.name(), Role.ADMIN.name())
                        .requestMatchers(HttpMethod.GET, EndPoints.GET_AUTHENTICATED).authenticated()
//
                        .requestMatchers(HttpMethod.POST, EndPoints.POST_PERMIT_ALL).permitAll()
                        .requestMatchers(HttpMethod.POST, EndPoints.POST_ADMIN_AUTHENTICATED).hasAnyRole(Role.SUPER_ADMIN.name(), Role.ADMIN.name())
                        .requestMatchers(HttpMethod.POST, EndPoints.POST_AUTHENTICATED).authenticated()
//
                        .requestMatchers(HttpMethod.PUT, EndPoints.PUT_ADMIN_AUTHENTICATED).hasAnyRole(Role.SUPER_ADMIN.name(), Role.ADMIN.name())
                        .requestMatchers(HttpMethod.PUT, EndPoints.PUT_AUTHENTICATED).authenticated()
//
                        .requestMatchers(HttpMethod.PATCH, EndPoints.PATCH_ADMIN_AUTHENTICATED).hasAnyRole(Role.SUPER_ADMIN.name(), Role.ADMIN.name())
                        .requestMatchers(HttpMethod.PATCH, EndPoints.PATCH_AUTHENTICATED).authenticated()
//
                        .requestMatchers(HttpMethod.DELETE, EndPoints.DELETE_ADMIN_AUTHENTICATED).hasAnyRole(Role.SUPER_ADMIN.name(), Role.ADMIN.name())
                        .requestMatchers(HttpMethod.DELETE, EndPoints.DELETE_AUTHENTICATED).authenticated()
//
                        .anyRequest().denyAll()
                )
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
//
//
     // CORS 관련 설정
    @Bean
    public WebMvcConfigurer corsConfigurer(){
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                        .allowedOrigins(BASE_URL)
                        .allowedMethods("*")
                        .allowedHeaders("*")
                        .allowCredentials(true)
                        .exposedHeaders("Authorization")
                        .maxAge(3600);
            }
        };
    }
//
    static public class EndPoints {
        public static final String[] GET_ANONYMOUS = { "/api/users/login", "/api/users/oauth2/authorization/**", "/oauth2/authorization/**" } ;
        public static final String[] GET_AUTHENTICATED = { "/api/users/**", "/api/posts/**", "/api/requests/**",
                "/api/categories/**", "/api/files/**", "/api/messages/**"  } ;
        public static final String[] GET_ADMIN_AUTHENTICATED = { "/api/admin/**"};
//
        public static final String[] POST_ANONYMOUS = { "/api/users/login", "/api/users/signup"} ;
        public static final String[] POST_PERMIT_ALL = { "/api/users/refresh"  };
        public static final String[] POST_AUTHENTICATED = { "/api/users/**", "/api/posts/**", "/api/requests/**",
                 "/api/files/**", "/api/messages/**", "/api/feedback/**"  };
        public static final String[] POST_ADMIN_AUTHENTICATED = { "/api/categories/**", "/api/admin/**"};public static final String[] PUT_AUTHENTICATED = { "/api/users/**", "/api/posts/**", "/api/requests/**",
                 "/api/files/**", "/api/messages/**", "/api/feedback/**"  };
        public static final String[] PUT_ADMIN_AUTHENTICATED = { "/api/admin/**"} ;
//
        public static final String[] PATCH_AUTHENTICATED = { "/api/users/**", "/api/posts/**", "/api/requests/**",
                "/api/categories/**", "/api/files/**", "/api/messages/**", "/api/feedback/**"  };
        public static final String[] PATCH_ADMIN_AUTHENTICATED = { "/api/categories/**", "/api/admin/**"} ;
//
        public static final String[] DELETE_AUTHENTICATED = { "/api/users/**", "/api/posts/**", "/api/requests/**",
                "/api/files/**", "/api/messages/**", "/api/feedback/**"  };
        public static final String[] DELETE_ADMIN_AUTHENTICATED = { "/api/admin/**"} ;
    }
}

토큰 발급 기능 구현

  • Refresh / Access Token을 포함할 수 있는 DTO 정의
public record KeyPair(
        String accessToken,
        String refreshToken
) {
}
  • 토큰Claims 정보를 포함하는 DTO 정의
    JwtFilter에서 AuthenticationToken을 위한 UserDetails 구현체 생성에 필요한 데이터를 포함하도록 설정
public record TokenBody(
        Long playerId,
        String email,
        UserRole role
) {
}
  • Secret Key 생성
    application.yml에 정의된 Secret Key를 등록
    @Configuration Class의존성 주입 후 가져옴
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenProvider {
    private final JwtProperties jwtProperties;
    private SecretKey getSecretKey(){
        return Keys.hmacShaKeyFor(jwtProperties.getSecrets().getAppkey().getBytes());
    }
  	//
  • 토큰 발급 메서드
    Jwts 클래스빌더 패턴을 활용해서 토큰문자열로 생성
    빌더 패턴을 통해 header / payload / signature 를 설정하여 JWT 토큰 생성
private String issueToken(
            Long id,
            String email,
            UserRole role,
            Long validTime,
            TokenType tokenType
    ){
        return Jwts.builder()
                .claim("id", id.toString())
                .claim("email", email)
                .claim("role", role.toString())
                .subject(tokenType.getValue())
                .issuer(jwtProperties.getSpecifications().getIssuer())
                .issuedAt(new Date())
                .issuedAt(new Date(new Date().getTime() + validTime))
                .signWith(getSecretKey())
                .compact();
    }

payload에 포함되는 sub / iat / exp 등의 claims을 포함

Jwts.claim("Key", "Value") : Payload커스텀 Key : Value를 추가
payload"role" : "역할"로 추가됨

비대칭키 암호화 방식에서 Secret Key를 통해 생성될 JWT 토큰서명을 수행
JWT 토큰

  • Access Token / Refresh Token 발급 메서드
    Refresh Token갱신 용도 토큰으로서, 내부 Payload 정보가 필요 없다.
    Access Token에 비해 많은 정보를 포함하지 않도록 설정.

    。각각 application.yml에 설정된 수명시간을 기준으로 수명을 설정
public KeyPair issueKeyPair(
            Long id,
            String email,
            UserRole role
    ){
        return new KeyPair(
               issueToken(
                    id,
                    email,
                    role,
                    jwtProperties.getValidations().getAccess(),
                    TokenType.ACCESS_TOKEN
               ),
                issueToken(
                    id,
                    email,
                    UserRole.EMPTY,
                    jwtProperties.getValidations().getRefresh(),
                    TokenType.REFRESH_TOKEN
                )
        );
    }
    public String issueRefreshToken(
            Long id
    ){
        return issueToken(
                id,
                null,
                UserRole.EMPTY,
                jwtProperties.getValidations().getRefresh(),
                TokenType.REFRESH_TOKEN
        );
    }

Refresh Token식별용도ID 외 많은 정보를 갖지 않도록 설정.

  • 토큰 검증Claims 추출 메서드 정의
    JwtParser 객체를 생성 후 parseSignedClaims(토큰)을 통해 검증Claims 반환을 수행
public boolean validate(String token){
        try {
            Jwts.parser()
                    .verifyWith(getSecretKey())
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch(ExpiredJwtException e){
            throw new CustomException(ErrorCode.EXPIRED_TOKEN);
        } catch(MalformedJwtException e){
            throw new CustomException(ErrorCode.ABNORMAL_TOKEN);
        } catch(JwtException e){
            throw new CustomException(ErrorCode.ERROR_FROM_TOKEN);
        }
    }
public Jws<Claims> parseClaims(String token){
        return Jwts.parser()
                .verifyWith(getSecretKey())
                .build()
                .parseSignedClaims(token);
    }

▶ 두 메서드역할이 다르지만, 같은 parseSignedClaims(토큰)를 공유

    • Jwts.parser().verifyWith(SecretKey객체).build() :
      Secret Key를 기반으로 JwtParser 객체 생성
      parser : 분석기

    • JwtParser객체.parseSignedClaims(토큰) :
      JWT 토큰이 포함하는 Jws<Claims>를 반환하는 메서드

      。 해당 로직을 수행하는 도중 만료 여부Secret Key를 통해 서명 검증 등의 토큰 검증이 수행
      ▶ 주로 토큰 검증 용도로 사용


      jjwt 라이브러리 관련 예외
    • JwtException :
      JWT 처리 과정에서 발생하는 모든 예외최상위 예외 클래스

    • ExpiredJwtException :
      JWTexp현재 시간보다 이전인 경우 발생하는 예외

    • MalformedJwtException :
      JWT 형식이 올바르지 않은 경우 발생하는 예외
      Header.Payload.Signature 구조가 아닌 경우
      Base64 디코딩이 불가능한 경우
      토큰 문자열이 손상된 경우


  • Jws<Claims>에서 데이터를 추출하여 DTO로 생성해서 반환하는 메서드 작성
    。위에서 정의한 parseClaims()를 통해 토큰에서 Jws<Claims>를 추출

    JwtFilter에 필요한 데이터를 포함하는 DTO토큰으로부터 데이터 추출 및 입력
public TokenBody parseJwt(String token){
        Jws<Claims> claimsJws = parseClaims(token);
        Object email = claimsJws.getPayload().get("email");
        Object role = claimsJws.getPayload().get("role");
        return new TokenBody(
                Long.parseLong(claimsJws.getPayload().get("id").toString()),
                (Strings.isNotBlank(email.toString()))? email.toString() : "",
                (Strings.isNotBlank(role.toString()))? UserRole.valueOf(role.toString()) : UserRole.PLAYER
        );
    }
  • 최종본
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenProvider {
    private final JwtProperties jwtProperties;
    private SecretKey getSecretKey(){
        return Keys.hmacShaKeyFor(
                jwtProperties
                        .getSecrets()
                        .getAppkey()
                        .getBytes()
        );
    }
    private String issueToken(
            Long id,
            String email,
            UserRole role,
            Long validTime,
            TokenType tokenType
    ){
        return Jwts.builder()
                .claim("id", id.toString())
                .claim("email", email)
                .claim("role", role.toString())
                .subject(tokenType.getValue())
                .issuer(jwtProperties.getSpecifications().getIssuer())
                .issuedAt(new Date())
                .issuedAt(new Date(new Date().getTime() + validTime))
                .signWith(getSecretKey())
                .compact();
    }
    public KeyPair issueKeyPair(
            Long id,
            String email,
            UserRole role
    ){
        return new KeyPair(
               issueToken(
                    id,
                    email,
                    role,
                    jwtProperties.getValidations().getAccess(),
                    TokenType.ACCESS_TOKEN
               ),
                issueToken(
                    id,
                    null,
                    null,
                    jwtProperties.getValidations().getRefresh(),
                    TokenType.REFRESH_TOKEN
                )
        );
    }
    public String issueRefreshToken(
            Long id
    ){
        return issueToken(
                id,
                null,
                null,
                jwtProperties.getValidations().getRefresh(),
                TokenType.REFRESH_TOKEN
        );
    }
    public boolean validate(String token){
        try {
            Jwts.parser()
                    .verifyWith(getSecretKey())
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (ExpiredJwtException e){
            throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
        } catch (MalformedJwtException e){
            throw new BusinessException(ErrorCode.ABNORMAL_TOKEN);
        } catch (JwtException e){
            throw new BusinessException(ErrorCode.TOKEN_ERROR);
        }
    }
    public Jws<Claims> parseClaims(String token){
        return Jwts.parser()
                .verifyWith(getSecretKey())
                .build()
                .parseSignedClaims(token);
    }
    public TokenBody parseJwt(String token){
        Jws<Claims> claimsJws = parseClaims(token);
        Object email = claimsJws.getPayload().get("email");
        Object role = claimsJws.getPayload().get("role");
        return new TokenBody(
                Long.parseLong(claimsJws.getPayload().get("id").toString()),
                (Strings.isNotBlank(email.toString()))? email.toString() : "",
                (Strings.isNotBlank(role.toString()))? UserRole.valueOf(role.toString()) : UserRole.PLAYER
        );
    }
}
profile
공부기록 블로그

0개의 댓글