1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

 

 

마실가실의 숨겨진 비밀..

한 번 로그인 한 고객님은.. 서버를 내리기 전까지 로그아웃할 수 없습니다.

 

이제는 보내드리려고 합니다.

안녕히 가세요, 고객님..

 

 

 

JWT, Token 방식으로 Session과 동작 과정을 비교해 볼 수 있습니다.

로그인

세션 방식

  * 서버는 세션 DB에 사용자의 정보를 저장 → 생성된 세션 ID를 (일반적으로) 쿠키에 넣어서 클라이언트에 반환
JWT 방식

  * 서버는 사용자의 정보 가운데 민감 정보가 아닌 정보로 토큰을 생성해 클라이언트에 반환

 

로그인 이후 요청

세션 방식

  * 클라이언트는 세션 ID를 포함한 쿠키를 서버에 전달 → 서버는 쿠키에서 세션 ID 추출 → 세션 DB에서 해당 아이디에 해당하는 레코드가 존재하는지 조회
JWT 방식

  * 서버는 서명 부분을 확인해 해당 토큰이 조작되었는지 판별 → 조작되지 않았다면 토큰으로부터 사용자의 아이디와 같이 식별할 수 있는 정보를 꺼내서 사용

 

JWT와 로그아웃

순수 JWT 방식을 사용할 때, 로그아웃은 클라이언트 측에서 쿠키나 로컬 스토리지에서 JWT 토큰을 삭제하거나 무효화하는 방식으로 구현할 수 있습니다. 그러나, 토큰이 유출될 경우 피해를 막을 수 있는 방법이 없다는 한계가 존재합니다.

그래서 토큰 유출에 대비해 Access Token의 유효 기간은 짧게, Refresh Token으로 Access Token을 재발급해주는 방법을 대안으로 사용합니다. 이 과정을 위해 Refresh Token은 서버에 저장되어야 하며, 이 때 데이터 베이스를 필요로 합니다. 그래서 Redis를 설정하고자 합니다.

 

또한 반대로, 로그아웃 시에는 Refresh Token으로 Access Token을 발급받을 수 없게 해야합니다. 따라서 Refresh Token용 데이터 베이스를 사용하되, 로그아웃한 사용자의 Refresh Token을 데이터베이스에 저장하여 해당 데이터 베이스에 정보가 있을 경우 Access Token 발급을 거절할 수 있도록 합니다. 이를 블랙리스트 방식이라 합니다.

 

왜 Redis인가

1. Redis는 메모리 기반의 NoSQL 데이터베이스로, 기존의 관계형 데이터베이스(MySQL 등)보다 훨씬 빠르게 데이터를 처리할 수 있음

  * 디스크 기반 데이터베이스이며, 많은 트래픽이 발생할 경우 성능에 문제가 생길 수 있음

2. 데이터에 TTL(Time-To-Live)을 설정할 수 있어, 만료된 JWT 토큰을 자동으로 삭제할 수 있음

  * 기존 RDB에 저장 시, 주기적으로 삭제해야하는 번거러움 발생

 

Springboot에 Redis 설정

1. build.gradle

implementation 'org.springframework.boot:spring-boot-starter

 

2. application.yml

spring:
  redis:
    pool:
      min-idle: 0
      max-idle: 8
      max-active: 8
    port: 6379
    host: 127.0.0.1

 

3. RedisProperties.java

@Component
@ConfigurationProperties(prefix = "spring.redis")
// application.yml 또는 application.properties 파일에서 spring.redis로 시작하는 설정 값을 이 클래스의 필드에 바인딩
@Getter @Setter
public class RedisProperties {
    private int port;
    private String host;
}

 

4. RedisConfig.java

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {

    private final RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

 

5. RedisUtil.java

@Component
@RequiredArgsConstructor
public class RedisUtil {

    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisTemplate<String, Object> redisBlackListTemplate;

    public void set(String key, Object o, int minutes) {
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
        redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean delete(String key) {
        return Boolean.TRUE.equals(redisTemplate.delete(key));
    }

    public boolean hasKey(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    public void setBlackList(String key, Object o, int minutes) {
        redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
        redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object getBlackList(String key) {
        return redisBlackListTemplate.opsForValue().get(key);
    }

    public boolean deleteBlackList(String key) {
        return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
    }

    public boolean hasKeyBlackList(String key) {
        return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
    }
}

 

 

+ 블랙 리스트 외 다른 로그아웃 방법

프론트에서 토큰 삭제

* storage에 있는 토큰을 삭제하는 방법

* 장점: 간단

* 단점: 토큰이 유출되었을 경우, 보안상 위험

 

 

++ 추가적인 보완책

IP 주소나 기기 정보를 토큰에 포함시켜 토큰을 사용하는 클라이언트 신원 확인
다른 기기나 IP 주소에서 사용되는 것을 방지

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

[스프링 시큐리티] redis를 이용한 jwt 로그아웃 만들기

오늘은 redis를 이용해서 jwt 로그아웃 기능을 포스팅 해보겠습니다. 우선, 간단하게 redis를 왜 이용해야 하는지 고민해보겠습니다. 왜 redis를 사용할까? 바로 로그아웃을 요청한 access token이 만료

geunzrial.tistory.com

 

 

[우테코] JWT 방식에서 로그아웃, Refresh Token 만들기(1): JWT의 Stateless한 특징을 최대한 살리려면?

안녕! 우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다. 스탬프크러쉬 서비스의 소스 코드 바로가기 사장모드: stampcrush.site/admin 고객모드: stampcrush.site 💋 인트로 스탬프크러쉬는 사용

engineerinsight.tistory.com

 

 

대동덕지도 | Spring Boot에서 Spring Security + JWT로 로그아웃을 구현하자! (feat. Redis)

JWT 적용한 로그인은 구현 했는데 말입니다 ... 로그아웃은 어떻게 하지? JWT를 적용한 로그인 기능은 Access Token(AT), Refresh Token(RT)를 이용하여 구현해냈다. 참고: https://un-lazy-midnight.tistory.com/159 대동

un-lazy-midnight.tistory.com

 

 

JWT Login 시, accessToken이 null

Environment
Language: Java
DB: MySQL

 

오류

private Claims parseClaims(ServletRequest request) {
    String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);
    if(accessToken == null)
        return null;

    Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));

    return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(accessToken)
            .getBody();
}

Header Type, Request Body의 데이터가 정확함에도 accessToken이 null

 

 

원인

@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws IOException, ServletException {
    // 1. Request Header에서 JWT 토큰 추출
    Authentication authentication = jwtTokenProvider.getAuthentication(request);

    // 2. 토큰 유효성 검사
    // 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
    if (authentication != null && jwtTokenProvider.isValidToken(request)) {
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    filterChain.doFilter(request, response);
    // 현재 필터가 요청을 처리한 후, 필터 체인의 다음 필터로 요청과 응답을 전달
}

 

SpringSecurity와 JWT가 동작하는 과정

  1. 애플리케이션 시작 → Spring Security의 초기화 및 설정 과정(Filter 등)
  2. API 호출 → JwtAuthenticationFilter 요청 처리
    필터 통과: 요청을 다음 필터로 전달
    필터 통과 X: 오류 반환
  3. 필터 체인을 모두 통과한 요청은 Controller로 전달
  4. Controller → UserService 호출
  5. UserService에서 AuthenticationManager은 UserDetailsService 호출
  6. AuthenticationManagerBuilder 동작
  7. AuthenticationManagerBuilder에서 사용자가 제공한 정보(이메일과 비밀번호)를 확인
    * 사용자가 입력한 이메일과 비밀번호를 담은 인증 토큰 생성
  8. AuthenticationManagerBuilder에서 authentificate() 호출하여 인증 시도
    내부적으로 CustomUserDetailsService의 loadUserByUsername() 호출
    *주어진 이메일로 데이터베이스에서 사용자를 찾아서 그 정보를 UserDetails 객체로 반환
  9. AuthenticationManagerBuilder에서 6과 7의 객체 비교
    * 인증 성공: Authentication 객체는 SecurityContext에 저장, 이후의 요청에서 사용자 정보를 참조할 수 있음
    * 인증 실패: BadCredentialsException 발생
  10. UserService에서 JwtTokenProvider의 generateToken() 호출
 
JwtAuthenticationFilter 처리 시, 모든 요청에 대해 getAuthentification() 동작

→ 로그인 시에는 token이 없어 Header에 token을 넣고 요청할 수 없음

→ accessToken이 null

 

⭐ Controller 처리 전에 오류가 발생, Controller에서 디버그해도 넘어오지 않음⭐

 
 

해결

@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws IOException, ServletException {
    String accessToken = SecurityUtils.resolveToken(request);

    if (accessToken != null && jwtTokenProvider.isValidToken(request)) {
        Authentication authentication = jwtTokenProvider.getAuthentication(request);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    filterChain.doFilter(request, response);
}

getAuthentification()이 아닌 토큰을 추출하는 메서드(resolveToken())를 수행

→ null이 아닐 때 getAuthentification() 수행

 

duplicateKeyException

Environment
Language: Java
DB: MySQL

 

오류

Caused by: java.lang.IllegalStateException: Duplicate key 400 BAD_REQUEST (attempted merging values DUPLICATED_EMAIL and NOT_EQUAL_PASSWORD)
	at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)
	at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$1(Collectors.java:182)

 

 

원인

DUPLICATED_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일 입니다."),
NOT_EQUAL_PASSWORD(HttpStatus.BAD_REQUEST,"입력한 비밀번호가 상이합니다."),

// HttpStatus -> ErrorCode 조회
private static final Map<HttpStatus, ErrorCode> BY_HTTPSTATUS =
        Stream.of(values()).collect(Collectors.toMap(ErrorCode::getHttpStatus, e -> e));

public static Optional<ErrorCode> valueOfHttpStatus(HttpStatus httpStatus){
    return Optional.ofNullable(BY_HTTPSTATUS.get(httpStatus));
}

 

  • toMap()
    • If the mapped keys contain duplicates (according to Object.equals(Object)), an IllegalStateException is thrown when the collection operation is performed. If the mapped keys might have duplicates, use toMap(Function, Function, BinaryOperator) instead.
    • HttpStatus로부터 Enum을 조회하려고 할 때, Map을 활용하는데 이 때 key 값(= HttpStatus)이 중복될 경우, duplicate관련 IllegalStateException이 발생할 수 있음

 

해결

// HttpStatus -> ErrorCode 조회
private static final Map<HttpStatus, ErrorCode> BY_HTTPSTATUS =
        Stream.of(values()).collect(Collectors.toMap(ErrorCode::getHttpStatus, e -> e, ((e1, e2) -> e1)));
        // Stream.of(values()).collect(Collectors.toMap(ErrorCode::getHttpStatus, e -> e, ((e1, e2) -> e2)));

public static Optional<ErrorCode> valueOfHttpStatus(HttpStatus httpStatus){
    return Optional.ofNullable(BY_HTTPSTATUS.get(httpStatus));
}

 

  • toMap(Function, Function, BinaryOperator)
    • BinaryOperator에서 먼저 put할 데이터를 사용할지 나중에 put한 데이터를 사용할지 추가

 

📚 참고 자료

 

[Error/Exception] java.lang.IllegalStateException: Duplicate key ‘key로 저장하려는 값’ (attempted merging values ~~)

에러 발생java.lang.IllegalStateException: **Duplicate key 'key로 저장하려는 값'** (attempted merging values ~~) 오늘도 만난 에러,, 뜯어봐야 알겠지만 대충 봐도 key 값이 중복되어 발생한 에러다.  에러 원인에

hoehen-flug.tistory.com

 

EmptyResultDataAccessException

Environment
Language: Java
DB: MySQL

 

오류

Caused by: jakarta.persistence.NoResultException: No result found for query [select u from User u where u.email = :email]
	at org.hibernate.query.spi.AbstractSelectionQuery.getSingleResult(AbstractSelectionQuery.java:476)
	at com.msgs.user.repository.UserRepository.findByEmail(UserRepository.java:19)

 

 

원인

public Optional<User> findByEmail(String email) {
    User user = em.createQuery("select u from User u where u.email = :email", User.class)
            .setParameter("email", email)
            .getSingleResult();
    return Optional.ofNullable(user);
}

 

  • getSingleResult()
  1. 결과가 없을 경우, NoResultException 예외 발생
  2. 결과가 2개 이상일 경우, NonUniqueResultException 예외 발생

 

해결

public Optional<User> findByEmail(String email) {
    List<User> users = em.createQuery("select u from User u where u.email = :email", User.class)
            .setParameter("email", email)
            .getResultList();
    if (users.isEmpty()) {
        return Optional.empty();
    } else {
        return Optional.of(users.get(0));
    }
}

 

  • getSingleResult() → getResultList()

 

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

Java Web Token, JWT.. 

 

마실가실에 JWT가 적용되어 있지만, 제가 담당한 게 아니라 잘 모르고 있었습니다.

 

이제 UI를 보면서 Controller를 수정하고 있는데, 마침 회원 가입의 마지막 장식을 JWT와 함께 해보고자 이 글을 작성했습니다.

 

Spring Security
스프링 기반 애플리케이션의 보안(인증과 권한)을 담당하는 프레임워크

 

*인증 절차를 거친 후 → 인가 절차를 진행

*인증과 인가를 위해 Principal을 아이디로, Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식 사용

 

1. Http Request 수신
사용자가 로그인 정보와 함께 인증 요청

2/3. AuthenticationFilter 동작
아이디와 비밀번호의 유효성 검사 실시 후 인증용 객체인 UsernamePasswordAuthenticationToken 반환
→ FIlter를 통해 AuthenticationToken을 AuthenticationManager로 전달

4. AuthenticationManager 동작
* Authentication Manager는 List형태로 Provider들을 갖고 있음
Token을 처리할 수 있는 Authentication Provider 선택 → 실제 인증을 할 AuthenticationProvider에게 인증용 객체를 다시 전달

5. AuthenticationProvider
실제 데이터베이스에서 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보 전달

6. UserDetails를 이용해 User객체에 대한 정보 탐색
loadUserByUsername()을 호출 → DB에 있는 사용자의 정보를 UserDetails 형으로 가져옴
* 만약 사용자가 존재하지 않으면 예외 발생
User 객체의 정보들을 UserDetails가 UserDetailsService(LoginService)로 전달

7. AuthenticaitonProvider들은 UserDetails를 넘겨받고 사용자 정보를 비교
* DB에서 가져온 이용자 정보와 화면에서 입력한 로그인 정보를 비교
→ 일치하면 Authentication 참조를 리턴 / 일치 하지 않으면 예외 호출

8/9. AuthenticationFilter에 Authentication 객체 반환

10. SecurityContext에 인증 객체를 설정
Authentication 객체를 Security Context에 저장

 

 

Spring Security, JWT 적용 과정은 다음과 같이 진화했습니다.

 

1. 초기 환경 설정

2. JWT WeakKeyException 오류 수정

3. 보안을 위한 환경 변수 설정

 

1. 초기 환경 설정

  • build.gradle
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-config'

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

  • User.java
@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseEntity {

   @Id @GeneratedValue
   @Column(name = "user_id")
   private Integer id;

   @Column(nullable = false, columnDefinition="char(1)")
   private String status;

   @Enumerated(EnumType.STRING)
   private LoginType loginType;

   @Column(nullable = false, unique = true, length = 50)
   private String email;

   @Column(nullable = false, unique = true, columnDefinition="char(11)")
   private String phone;

   @Column(length = 30)
   private String nickname;

   @Column(length = 30)
   private String password;

   private String imagePath;
}

 

  • CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    // UserDetailsService: Spring Security에서 유저 정보를 가져오는 Interface
    // 기본 Override Method: loadUserByUsername
    // loadUserByUsername: 유저 정보를 가져와 UserDetails로 return

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email).orElseThrow(()->
                new BusinessException(NOT_FOUND_MEMBER));

        //user의 role을 스프링시큐리티의 GrantedAuthority로 바꿔준다. 여러개의 role을 가질수 있으므로 Set
        Set<GrantedAuthority> authorities = Set.of(SecurityUtils.convertToAuthority(user.getRole().name()));

        return UserPrinciple.builder()
                .email(user.getEmail())
                .password(user.getPassword())
                .user(user)
                .build();
    }
}
  • UserPrinciple.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserPrinciple implements UserDetails {
    private String email;
    transient private String password; // 직렬화 과정에서 제외
    transient private User user; // 직렬화 과정에서 제외

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null; // 계정의 권한 목록
    }

    @Override
    public String getUsername() {
        return email; // 계정의 고유한 값 리턴
    }

    @Override
    public String getPassword() {
        return password; // 계정의 비밀번호 리턴
    }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return true;}

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() { return true; }
}

 

  • SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .cors(httpSecurityCorsConfigurer -> corsConfigurationSource())
            .authorizeRequests((auth) -> auth
                .requestMatchers("/api/v2/users/me").permitAll())
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
     /**
     * CSRF(Cross Site Request Forgery): 사이트간 위조 요청
     * csrf(): HTTP 요청에 대해 CSRF 토큰을 생성하고, 이 토큰을 쿠키에 저장
     * 모든 POST, PUT, DELETE 요청에 대해 CSRF 토큰이 함께 전송되는지 검증
     *
     * REST API를 이용한 서버라면, session 기반 인증과는 다르게 stateless(= 서버에 인증 정보를 보관하지 않음)
     * 사용자가 로그인 상태를 유지하고 있는 동안 다른 사이트에서 악의적인 요청을 보내는 것을 방지하는 데 유용한 CSRF 사용 필요성이 낮음
     * 대신, Client는 권한이 필요한 요청을 하기 위해서는 요청에 필요한 인증 정보를(OAuth2, jwt토큰 등)을 포함시켜야 함
     *
     * AbstractHttpConfigurer::disable
     * AbstractHttpConfigurer 클래스 안에 disable 메서드를 참조
     * */
    
    /**
     * CORS(Cross-Origin Resource Sharing): 웹 페이지가 자신과 다른 출처의 리소스에 접근할 때 발생하는 보안 정책
     * httpSecurityCorsConfigurer를 corsConfigurationSource()로 정의
     * */
    
    /**
     * authorizeRequests: 특정 URL 경로에 대한 접근 권한을 설정
     * requestMatchers("...").permitAll()은 "..." 엔드포인트에 대한 모든 요청을 허용
     * 즉, 인증되지 않은 사용자도 이 경로에 접근할 수 있음
     * */
    
    /**
     * addFilterBefore: JWT 인증 필터 추가
     * JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
     * */

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

 

 

  • JwtTokenProvider
@Slf4j
@Component // Spring이 이 클래스를 자동으로 스캔하고 빈으로 등록
@RequiredArgsConstructor
public class JwtTokenProvider {
    private static final String AUTHORITIES_KEY  = "roles";
    private static final String BEARER_TYPE = "Bearer";

    @Value("${jwt.secretKey}")
    private String JWT_SECRET;

    private final UserRepository userRepository;

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public TokenInfo generateToken(UserPrinciple auth) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + Duration.ofMinutes(1).toMillis());

        String authorites = auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));

        // Access Token 생성
        String accessToken = Jwts.builder()
                .setSubject(auth.getEmail())
                .claim(AUTHORITIES_KEY, authorites)
                .setExpiration(expiration)
                .signWith(secretKey)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(expiration)
                .signWith(secretKey)
                .compact();

        return TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(ServletRequest request) {
        //유저 정보 부분만 가져옴
        Claims claims = parseClaims(request);
        if(claims == null)
            throw new BusinessException(NOT_FOUND_MEMBER);

        if(claims.getSubject() == null)
            throw new BusinessException(NOT_FOUND_MEMBER);

        if (claims.get(AUTHORITIES_KEY) == null)
            throw new BusinessException(NOT_FOUND_AUTHORITY);


        // 클레임에서 권한 정보 가져오기
        Set<GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY)
                        .toString()
                        .split(","))
                        .map(SecurityUtils::convertToAuthority) // 스트림의 각 요소를 다른 형태로 변환하는 데 사용
                        .collect(Collectors.toSet());


        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails userDetails = UserPrinciple.builder()
                .email(claims.getSubject())
                .authorities(authorities)
                .build();

        return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
    }
    /**
     * UsernamePasswordAuthenticationToken
     * userDetails: 사용자의 정보(예: 이메일, 권한)를 담고 있는 객체
     * null: 이 위치에 보통은 비밀번호나 인증 토큰이 들어가지만, 여기서는 이미 인증이 된 상태이므로 필요하지 않아 null로 설정
     * authorities: 사용자가 가지고 있는 권한 목록
     * */

    // 토큰 정보를 검증하는 메서드
    public boolean isValidToken(ServletRequest request) {
        Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));

        String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);

        try {
            Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(accessToken);

            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            System.out.println("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            System.out.println("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            System.out.println("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalStateException e) {
            System.out.println("JWT 토큰이 잘못되었습니다.");
        }

        return false;
    }

    // 리퀘스트의 토큰에서 암호풀어 Claims 가져오기
    private Claims parseClaims(ServletRequest request) {
        String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);
        if(accessToken == null)
            return null;

        Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));

        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(accessToken)
                .getBody();
    }
}

 

  • SecurityUtils.java
public class SecurityUtils {

    //Token 값을 가져오는데 필요한 값들
    public static final String AUTH_HEADER = "authorization";
    public static final String AUTH_TOKEN_TYPE = "Bearer";
    public static final String AUTH_TOKEN_PREFIX = AUTH_TOKEN_TYPE + " "; // 한 칸 띄우는것을 넣어줘야함

    // Request Header에서 토큰 정보 추출
    public static String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTH_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AUTH_TOKEN_TYPE)) {
            return bearerToken.substring(7);
        }

        return null;
    }

}

 

 

  • JwtAuthentificationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    // 요청이 들어올 때마다 JWT 토큰을 검사하고 인증 정보를 설정
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final JwtTokenProvider jwtTokenProvider;

    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext에 저장하는 역할 수행
    @Override
    public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
        // 1. Request Header에서 JWT 토큰 추출
        String accessToken = SecurityUtils.resolveToken(request);
        
        // 2. 토큰 유효성 검사
        // 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
        if (accessToken != null && jwtTokenProvider.isValidToken(request)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(request);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
        // 현재 필터가 요청을 처리한 후, 필터 체인의 다음 필터로 요청과 응답을 전달
    }
}

 

 

2. WeakKeyException 오류 수정

The specified key byte array is 96 bits which is not secure enough for any JWT HMAC-HA algorithm.
The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS256 MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).

JWT(JSON Web Token)를 생성하거나 검증할 때 사용하는 서명 키의 크기가 HS256 알고리즘을 사용하기에 충분하지 않아서 발생한 것입니다.
HS256 알고리즘을 사용할 때는 최소 256비트 이상의 키가 필요합니다.
하지만 현재 사용 중인 키는 96비트로, 이는 충분히 안전하지 않다고 경고하고 있습니다.

 

  • JwtTokenProvider
@Component
public class JwtTokenProvider {
    private final Key secretKey;

    public JwtTokenProvider() {
        this.secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    }

    public String createToken(String subject){
        Date now = new Date();
        Date expiration = new Date(now.getTime() + Duration.ofDays(1).toMillis());

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("test")
                .setIssuedAt(now)
                .setExpiration(expiration)
                .setSubject(subject)
                .signWith(secretKey)
                .compact();
    }

    public Claims parseJwtToken(String token) {
        token = bearerRemove(token);
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
    }

    private String bearerRemove(String token) {
        return token.substring("Bearer ".length());
    }

}

 

SecretKey를 알고리즘에 맞게 생성해서 사용하면 됩니다.

 

+ 추가적으로, signWith(io.jsonwebtoken.SignatureAlgorithm, java.lang.String)'가 deprecated 되어 String값을 넣는 것이 아닌 Key값을 생성하고 서명을 진행하는 것으로 수정되었습니다.

 

 

하지만, 문제는 해당 방식으로 사용할 경우 애플리케이션이 시작될 때마다 새로운 키를 생성하기 때문에 토큰이 서버를 재시작할 때마다 유효하지 않게 될 수 있습니다.

운영 환경에서는 일반적으로 고정된 키를 사용하여 여러 서버 인스턴스 간에 일관된 JWT 검증이 가능하도록 해야 합니다.

분산된 시스템이나 로드 밸런싱된 환경에서는 동일한 비밀 키를 사용하여 여러 서버 인스턴스 간에 JWT 검증을 수행해야 합니다. 이 방식은 각 인스턴스마다 다른 키를 생성하게 되므로, 서로 다른 서버에서 생성된 JWT가 검증되지 않는 문제가 발생할 수 있습니다.

따라서, 환경 변수나 키 관리 도구를 사용해 키를 일관되게 관리하는 것이 좋습니다.

 

 

3. 환경 변수를 활용한 비밀 키 저장

  • application.yml
jwt:
  secretKey: ...

 

이 때, scretKey는 git bash나 리눅스에서 아래 명령어를 쳐서 나온 걸 사용할 수 있습니다.

openssl rand -hex 64

 

생성자 부분을 수정해줍니다.

private final Key secretKey;

public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
    this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}

 

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

[Spring Boot] JWT (JSON Web Token) 토큰 기반 인증

목차 1. 환경 Spring Boot 2.5.6 (Gradle) JDK 11(Java 11) IntelliJ Postman 2. JWT 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가(Authorization)를 위해 사용하는 토큰 Bearer Authentication (JWT 혹은 OAuth에

veneas.tistory.com

 

jwt secret key 만들기

리눅스에 openssl rand -hex 64 입력하면 된다 윈도우도 리눅스 켤 수 있다

blossoming-man.tistory.com

 

[JWT] Spring Boot 환경에서 JWT(Json Web Token)생성 하기

첫번째의 JWT는 JWT에 대한 간단한 설명을 정리했고 2번째는 Spring Boot 환경에서 JWT를 직접 생성해보고자 한다. 사실 JWT 생성은 https://jwt.io/ JWT.IO JSON Web Tokens are an open, industry standard RFC 7519 method for r

erjuer.tistory.com

 

[SpringBoot] Spring Security란?

대부분의 시스템에서는 회원의 관리를 하고 있고, 그에 따른 인증(Authentication)과 인가(Authorization)에 대한 처리를 해주어야 한다. Spring에서는 Spring Security라는 별도의 프레임워크에서 관련된 기능

mangkyu.tistory.com

 

🔒 Spring Security 구조, 흐름 그리고 역할 알아보기 🌱

스프링 시큐리티는 인증 (Authentication) ,권한(Authorize) 부여 및 보호 기능을 제공하는 프레임워크다.Java / Java EE 프레임워크개발을 하면서 보안 분야는 시간이 많이 소요되는 활동들 중 하나다. Sprin

velog.io

 

[백엔드] JSON Web Token (JWT) 설정 및 properties 설정 -> 포스트맨 테스트

JWT 란? JWT(Json Web Token)는 말그대로 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격 JWT 토큰 웹에서 보통 Authorization HTTP 헤더를 Bearer 으로 설정하여 클라이언트에서 서버로 전송 서버에서는 토

euni8917.tistory.com