본문 바로가기
포스코x코딩온

[포스코x코딩온] 풀스택 부트캠프 4차 프로젝트 -3

by 김선지 2024. 3. 11.

주말동안 JWT 배워와서 적용해봤다.

 

 일단 기본적으로 JWT같은 경우는 Express에서 구현해봤었기 때문에 작동하는 구성방식은 대충 알고 있었는데 자바에서는 Spring Security가 대신해줄 수 있다는 란가 센세의 강의를 보면서 내 프로젝트에 적용했다.

 근데.. 이사람 자기는 강의마다 최신화가 굉장히 잘 된다고 한 것 치고는 deprecated된 함수들이 되게 많아서 하나씩 구글링하면서 적용했다.

다만 한가지 주의할 점이 이 사람은 embedded database인 H2를 이용했지만 나는 jpa를 이용해야 한다는 점이었다.

 

근데 sping boot가 복잡하긴 하지만 일단 매력적인게 설명은 잘되어있다. 이런식으로 User Entity가 미리 설정되어 있고 이걸 UserDetails을 implement하는 걸로 커스터마이징 할 수 있다는 것 같다. 그리고 이와 연동만 하면 spring security가 알아서 해준다는게 좀 좋았다.

(근데 이거 학원에서 알려줬으면 좋았을 것 같다.)

 

구글링 열심히 해서 적당히 버릴 건 버리고 넣을 건 넣은 결과 다음 코드가 탄생했다.

어차피 role은 필요 없을 것 같긴 한데 일단 전부 유저니까 하드코딩으로 넣었다. DB에 들어가지 않는 정보기 때문에 큰 낭비는 아닐 거라 생각했다.

package com.weatherable.weatherable.Service;

import com.weatherable.weatherable.Entity.AuthEntity;
import com.weatherable.weatherable.Entity.UserEntity;
import com.weatherable.weatherable.Repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Autowired
    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUserid(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUserid())
                .password(user.getPassword())
                .roles("USER")
                .build();

    }
}

 

이렇게 만들어주고 @Configuration에서 환경설정, 즉 제약조건을 걸어주면 된다.

 

package com.weatherable.weatherable.Config;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;


@Configuration
public class AuthenticationResource {

    @Bean
    SecurityFilterChain SecurityFilterChain(HttpSecurity http) throws Exception {
        // HTTP 요청에 대한 권한 부여 적용
        http.authorizeHttpRequests((requests) ->
                requests
                        .requestMatchers("/login", "/signup", "/refresh").permitAll()
                        .anyRequest().authenticated());

        // HTTP 세션에 사용할 정책을 STATELESS로 설정하기 (REST API에서 설정해야 함.)
        // 스프링 부트 기본 옵션에서는 세션을 이용해서 로그인 로그아웃을 설정함.
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));



        // csrf 사용 해제
        http.csrf(AbstractHttpConfigurer::disable);
        http.headers(headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin));
//        http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);



        // jwt
        http.oauth2ResourceServer((oauth2) -> oauth2
                .jwt(Customizer.withDefaults())
        );
        return http.build();
    }


    // 1. key pair 만들기
    @Bean
    public KeyPair keyPair() {
        try {
            var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            return keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }

    // 2. key pair 이용해서 RSA key Object 만들기
    @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)
    // / Create JWKSet (a new JSON Web Key Set) with the RSA Key
    // / Create JWKSource using the JWKSet
    @Bean
    public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
        var jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, context) -> jwkSelector.select(jwkSet);
    }

    @Bean
    public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
        return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
    }

    @Bean
    public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
        return new NimbusJwtEncoder(jwkSource);
    }


    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

}

 

 

일단 여기까지 하는 것도 힘들었는데 한 가지 큰 문제점이 있었다.

일단 jwt를 쓰기로 한 이상, refresh token과 access token 두개가 필요하다.

하지만 만약 저 @Bean에서 정의한 key pair를 바탕으로 token 두개를 다 만들면?????

헤더에 refresh token 넣어도 access token처럼 동작한다.

이러면 토큰의 존재 이유가 사라진다...

 

그래서 생각해낸 해결책이 role을 이용해서 거르자는 아이디어였다.

 

즉, access token에 ROLE_USER를 해놓고

refresh token에는 Refresh 이런 식으로 해서 refresh token을 검증하는 엔드포인트를 제외한 모든 엔드포인트에 hasRole("USER")를 넣어주는거다.

 

예상한 것은 refresh token을 넣었을 때는 role이 없어서 오류가 뜨고, access token을 넣었을 때는 role이 USER이기 떄문에 정상 동작할 거라고 생각했다. 하지만 왠지 모르겠는데 role 인식을 둘 다 못했다. 그래서 이유는 차치하고 다른 방법을 생각해보기로 했다.

 

 다음으로 생각난 해결책은 key pair를 두개 만드는 아이디어였다.

 

그렇게 된다면 spring boot는 다른 key pair로 만든 refresh token을 디코딩하지 못할 것이고 에러로 이어질 것이다.

 

그래서 jwt를 따로 dependency에 추가했다.

여기서 한가지 든 의문이 나는 spring boot가 관리하게 하기 위해서 이미 jwt를 썼는데, jwt가 있는지알고 확인해보니까 없었다. 

 그 이유인 즉슨, 나는 지금까지 oauth2에서 제공하는 jwt를 내 마음대로 쓸 수 있는 줄 알았다는 것이다.

 

체크해둔 것이 spring boot에서 제공하는 jwt이고 jjwt라고 되어있는 게 내가 생각하는 jwt였던 것이다.

 

(와중에 dependency 잘못받아서 메소드 안뜬 것 때문에 꽤 고생좀 했다.)

 

그리고 알게된 건... jwt 토큰 생성할 때 oauth2에서 지원하는 방식과 jjwt에서 지원하는 방식이 많이 다르다는 거였다. 이러면 키페어가 똑같아도 상관 없지 않을까..? 싶었지만 확실한게 최고다.

하나 더만들었다.

아래와 같이 oauth는 JwtClaimsSet을 쓰지만 jjwt는 Jwts를 이용하고 constructor에 들어가는 field의 이름도 다르다.

 

 

그리고 유효성 검사도 전자는 스프링이 알아서 해주지만 후자는 직접 해야 한다.

열심히 찾아서 만들었다.

public boolean validateToken(String token) {
    var claims = extractAllClaims(token);
    String userid = claims.getSubject();
    String existingRefreshToken = getExistingRefreshToken(userid);
    boolean isValidToken = token.equals(existingRefreshToken);
    boolean isExpired = isTokenExpired(token);

    return !isExpired && isValidToken;
}

public String getExistingRefreshToken(String userid) {
    var userEntityOptional = authRepository.findByUserEntityUserid(userid);
    if(userEntityOptional.isEmpty()) {
        return "유저 없음";
    }

    return userEntityOptional.get().getRefreshToken();
}

public boolean isTokenExpired(String token) {
    var claims = extractAllClaims(token);
    var expiration = claims.getExpiration();
    return expiration.before(Date.from(Instant.now()));
}

public String retrieveUserid(String token) {
    var claims = extractAllClaims(token);
    return claims.getSubject();
}

 

이렇게 해서 refresh 경로로 access token을 재발급 받을 수 있고, refresh 경로에서도  에러가 뜰 경우에는 프론트 단에서 알아서 로그아웃 해주면 되겠지. 싶다

그리고 서로서로 디코딩 안되는 것도 확인했다.