가수면

Spring Security와 JWT 본문

Java

Spring Security와 JWT

니비앙 2024. 1. 9. 03:08

Spring Security는 Spring Security Filter Chain를 통해 모든 요청을 인터셉트하게 된다.

때문에 Spring Security Filter Chain을 프로젝트에 맞게 커스텀 설정해서 사용해야한다.

 

본 글에서는 jjwt를 이용해 JWT를 다루는 방법을 정리한다.

Spring Security Filter 설정

Spring Security가 업데이트되면서 기존 WebSecurityConfigurerAdapter 클래스를 extends 하고 configure를 오버라이드하던 방식에서 SecurityFilterChain를 사용하는 방식으로 바뀜

@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {

    private final UserRepository userRepository;
    @Value("${jwt.secret-key}")	// 시크릿 키 환경 변수 이름
    private String secretKey;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .authorizeHttpRequests(
                    auth -> auth
                            // 서버 상태 확인을 위한 루트 url에 인증 예외 처리
                            .requestMatchers(
                                    "/"
                            ).permitAll()
                            // 토큰 없이 접근 가능한 url에 인증 예외 처리
                            .requestMatchers(
                                    HttpMethod.POST,
                                    "/user/signup",
                                    "/user/login"
                            ).permitAll()
                            .anyRequest()
                            .authenticated())
                // JWT 토큰 사용 시 서버 상태를 사용하지 않으므로 세션 비활성
                .sessionManagement(
                    session ->
                            session.sessionCreationPolicy(
                                    SessionCreationPolicy.STATELESS))
                // JWT 토큰 사용 시 csrf에 대한 대비가 어느정도 되므로 csrf 비활성
                .csrf(csrf -> csrf.disable())
                // 헤더의 토큰 인증에 대해 필터링하도록 설정
                .addFilterBefore(new JwtTokenFilter(userRepository, secretKey), UsernamePasswordAuthenticationFilter.class)
                // 인증 에러 시 에러 상태 및 응답 설정
                .exceptionHandling((exceptionHandling) ->
                        exceptionHandling
                                .accessDeniedPage("/errors/access-denied")
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint()));

        return http.build();
    }

    // 회원가입 시 비밀번호 암호화하기 위한 메소드
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

에러를 어떻게 처리할 것인지에 대한 클래스 생성

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json");
        response.setStatus(에러 상태 코드); 
        response.getWriter().write(응답 모양);
    }
}

JWT 토큰 생성 및 인증

jjwt가 버전업되면서 메소드들이 많이 바뀌었다.

Deprecated된 목록들은 아래 문서들을 참고해 교체하였다.

https://javadoc.io/doc/io.jsonwebtoken/jjwt-api/latest/deprecated-list.html

 

deprecated list - jjwt-api 0.12.3 javadoc

Latest version of io.jsonwebtoken:jjwt-api https://javadoc.io/doc/io.jsonwebtoken/jjwt-api Current version 0.12.3 https://javadoc.io/doc/io.jsonwebtoken/jjwt-api/0.12.3 package-list path (used for javadoc generation -link option) https://javadoc.io/doc/io.

javadoc.io

https://github.com/jwtk/jjwt?tab=readme-ov-file#jwt-read

 

GitHub - jwtk/jjwt: Java JWT: JSON Web Token for Java and Android

Java JWT: JSON Web Token for Java and Android. Contribute to jwtk/jjwt development by creating an account on GitHub.

github.com

 

아래 예시에서는 토큰을 생성할 때 아이디와 비밀번호를 payload로 설정해 생성하고, 인증 시 토큰을 파싱해 아이디와 비밀번호가 db에 있는 정보인지, 토큰이 만료가 되었는지를 확인한다.

그리고 해당 작업은 Spring Security Filter Chain에서 설정했던 예외 경로를 제외한 모든 url에서

.addFilterBefore(new JwtTokenFilter(userRepository, secretKey), UsernamePasswordAuthenticationFilter.class)

위 코드 라인으로 인해 헤더의 토큰을 검증하는 절차를 거치게 되는 예시가 되겠다.

@Slf4j
public class JwtTokenUtils {

    // JWT 토큰 파싱해 얻은 정보를 DB 데이터와 비교 (토큰 인증 로직)
    public static User validatedUser(UserRepository userRepository, String username, String password) {
        User user = userRepository.findByUsername(username).orElseThrow(() ->
                new TodoExceptionHandler(ErrorCode.USER_NOT_FOUND, String.format("%s is not founded", username)));

        if (!password.equals(user.getPassword())) {
            throw new TodoExceptionHandler(ErrorCode.INVALID_INFO, "Invalid password");
        }

        return user;
    }

    // 파싱한 토큰의 정보 얻기 위한 로직 (토큰 인증 로직)
    public static String getUsername(String token, String key) {
        return extractClaims(token, key).get("username", String.class);
    }
    public static String getPassword(String token, String key) {
        return extractClaims(token, key).get("password", String.class);
    }
    public static boolean isExpired(String token, String key) {
        Date expiredDate = extractClaims(token, key).getExpiration();
        return expiredDate.before(new Date());
    }

    // JWT 토큰 파싱 로직 (토큰 인증 로직)
    public static Claims extractClaims(String token, String key) {
        return Jwts.parser().verifyWith((SecretKey) getKey(key))
                .build().parseSignedClaims(token).getPayload();
    }

    // JWT 토큰 생성
    public static String generateToken(String username, String encodedPassword, String key, long expiredTimeMs) {

        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("password", encodedPassword);

        long nowMillis = System.currentTimeMillis();

        return Jwts.builder()
                .claims(claims)
                .issuedAt(new Date(nowMillis))
                .expiration(new Date(nowMillis + expiredTimeMs))
                .signWith(getKey(key))
                .compact();
    }

    // JWT 토큰 생성에 필요한 시크릿 키 생성 (key는 256bit 이상이어야 한다.)
    private static Key getKey(String key) {
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {	// 모든 요청에 대해 필터링하므로 OncePerRequestFilter를 extends 함

    private final UserRepository userRepository;
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        
        // 헤더 토큰 유무에 대한 유효성 검사
        if(header == null || !header.startsWith("Bearer ")) {
            log.error("Header is null or invalid");
            filterChain.doFilter(request, response);
            return;
        }

        try {
            final String token = header.split(" ")[1].trim();

            // 토큰 만료에 대한 유효성 검사
            if(JwtTokenUtils.isExpired(token, secretKey)) {
                log.error("Token is expired");
                filterChain.doFilter(request, response);
                return;
            }

            String username = JwtTokenUtils.getUsername(token, secretKey);
            String password = JwtTokenUtils.getPassword(token, secretKey);

            // 토큰에 들어있는 아이디와 비밀번호가 db에 있는지 확인
            User user = JwtTokenUtils.validatedUser(userRepository, username, password);

            // 인증된 사용자 정보를 Spring Security 컨텍스트에 저장 (인자 = 아이디, 비밀번호, 권한)
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    user, null, List.of(new SimpleGrantedAuthority(user.getRole().toString())));

            // 인증 요청의 세부 정보를 저장. 인증 객체에 HTTP 요청과 관련된 추가적인 컨텍스트를 제공
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            // 인증된 사용자 정보를 Spring Security 컨텍스트에 저장. 이후 요청에 대해서는 인증을 요구하지만, 실제 인증 로직이 실행되지 않게 함
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (RuntimeException error) {
            log.error("Error occurs while validating, {}", error.toString());
            filterChain.doFilter(request, response);
            return;
        }

        filterChain.doFilter(request, response);
    }
}

 

UserDetails 설정

사용자의 인증과 권한 부여 과정에서 필요한 정보를 UserDto에서 추출할 수 있도록 구현해줘야함.

불필요한 설정들도 오버라이드하기 위해선 필요

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserDto implements UserDetails {	// UserDetails의 설정들 오버라이드

...

    // 유저 권한
    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.toString()));
    }
    
    // 삭제되지만 않았으면 가능하도록
    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return removedAt == null;
    }
    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return removedAt == null;
    }
    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return removedAt == null;
    }
    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return removedAt == null;
    }
}

 

CSRF

CSRF 토큰 얻는 법

Spring Security는 CSRF 공격에 대비해 POST, PUT 요청 등에 CSRF 토큰이 필요하도록 자동 설정이 되어있음.

그렇기에 요청을 주고받을 수 있도록 CSRF 토큰을 얻어야 한다.

    @GetMapping("/csrf-token")
    public CsrfToken retrieveCsrfToken(HttpServletRequest request) {
        return (CsrfToken) request.getAttribute("_csrf");
    }

이렇게 얻은 토큰은 헤더에 X-CSRF-TOKEN로 실어서 보내면 된다.

 

SameSite 속성 설정

쿠키가 다른 사이트에서 시작된 요청에는 전송되지 않도록 하여 CSRF 공격에 대비할 수 있음

// application.properties

server.servlet.session.cookie.same-site=strict

 

Spring Security Filter 기타 설정들

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class BasicAuthenticationSecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // OPTIONS 요청을 제외(인증API에 필요)한 모든 요청에 인증 필요
        http.authorizeHttpRequests(
                auth -> auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll().anyRequest().authenticated()
        );
        // 팝업으로 뜨도록 설정
        http.httpBasic(withDefaults());
        // HTML 프레임 사용 해제 옵션 해제
        http.headers(headers -> headers.frameOptions(frameOptionsConfig-> frameOptionsConfig.disable()));
        // Jwt를 위한 OAuth2 리소스 서버 설정
        http.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
        // 소셜 로그인을 위한 OAuth2 설정
        http.oauth2Login(withDefaults());
        
        return http.build();
    }

}

 

메소드 보안 설정

아래 방법들 모두 중복 적용 가능

1. @PreAuthorize, @PostAuthorize어노테이션 사용 (권장)

1) 설정 클래스에 @EnableMethodSecurity 어노테이션 추가

@Configuration
@EnableMethodSecurity
public class BasicAuthenticationSecurityConfiguration {

2) 메소드에 접근 조건 추가

    @GetMapping("/users/{username}/todos")
    @PreAuthorize("hasRole('USER') and #username == authentication.name")
    @PostAuthorize("returnObject.username == 'yhhnnmm'")
    public Todo retrieveTodosForSpecificUser(@PathVariable String username) {
        return TODOS_LIST.get(0);
    }

2. JSR-250 어노테이션 사용

1) @EnableMethodSecurity 어노테이션에 설정 추가

@Configuration
@EnableMethodSecurity(jsr250Enabled = true)
public class BasicAuthenticationSecurityConfiguration {

2) 메소드에 접근 조건 추가

@GetMapping("/users/{username}/todos")
@RolesAllowed({"ADMIN", "USER"})

3. @Secured 어노테이션 사용

1) @EnableMethodSecurity 어노테이션에 설정 추가

@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class BasicAuthenticationSecurityConfiguration {

2) 메소드에 접근 조건 추가

    @GetMapping("/users/{username}/todos")
    @Secured({"ROLE_ADMIN", "ROLE_USER"})

OAuth2를 이용한 JWT

JWT 토큰이 유효한지 디코딩하는 작업

1. RSA키 쌍 만들기

2. RSA 키 객체 만들기

3. JWKSource (JSON Web Key source) 만들기

4. 디코딩을 위한 RSA 공개키 만들기

JWT 인코딩 메소드 만들기

// Spring Security 설정 클래스

// 1. RSA키 쌍 만들기
    @Bean
    public KeyPair keyPair() {
        try {
            var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            return keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }
    
// 2. RSA 키 객체 만들기
    @Bean
    public RSAKey rsaKey(KeyPair keyPair) {

        return new RSAKey
                .Builder((RSAPublicKey)keyPair.getPublic())
                .privateKey(keyPair.getPrivate())
                .keyID(UUID.randomUUID().toString())
                .build();
    }
    
// 3. JWKSource (JSON Web Key source) 만들기
    @Bean
    public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
        var jwkSet = new JWKSet(rsaKey);

        return (jwkSelector, context) ->  jwkSelector.select(jwkSet);

    }
    
// 4. 디코딩을 위한 RSA 공개키 만들기
    @Bean
    public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
        return NimbusJwtDecoder
                .withPublicKey(rsaKey.toRSAPublicKey())
                .build();

    }
    
// JWT 인코딩 메소드 만들기
    @Bean
    public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
        return new NimbusJwtEncoder(jwkSource);
    }

JWT 토큰화하기

@RestController
public class JwtAuthenticationResource {

    private JwtEncoder jwtEncoder;

    public JwtAuthenticationResource(JwtEncoder jwtEncoder) {
        this.jwtEncoder = jwtEncoder;
    }

    @PostMapping("/authenticate")
    public JwtRespose authenticate(Authentication authentication) {
        return new JwtRespose(createToken(authentication));
    }

    private String createToken(Authentication authentication) {
        var claims = JwtClaimsSet.builder()
                .issuer("self")
                .issuedAt(Instant.now())
                .expiresAt(Instant.now().plusSeconds(60 * 30))  // 만료시간 설정
                .subject(authentication.getName())
                .claim("scope", createScope(authentication))
                .build();

        return jwtEncoder.encode(JwtEncoderParameters.from(claims))
                .getTokenValue();
    }

    private String createScope(Authentication authentication) {
        return authentication.getAuthorities().stream()
                .map(a -> a.getAuthority())
                .collect(Collectors.joining(" "));
    }

}

record JwtRespose(String token) {}

 

'Java' 카테고리의 다른 글

Junit5  (0) 2024.02.17
스프링 부트 서버에 https 설정  (0) 2024.01.23
[Spring Boot] 심화  (0) 2023.12.28
HATEOAS  (1) 2023.12.26
Swagger  (0) 2023.12.26
Comments