PlayGround/마실가실 리팩토링

[1년 후 마실가실] Spring Security, JWT 공부

HJ0216 2024. 8. 11. 06:09

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