본문 바로가기
IT/Spring boot

[Spring boot] Spring Security Jwt Rest Api 구현

by kyu-nahc 2024. 9. 6.
반응형

Spring Security 기본 구현

이전 포스트에서는 Spring Security의 기본적인 세션 인증 방식으로 구현해 보았다.

Spring Security의 기본 구현에 대한 내용은 아래 포스트를 참고하면 된다.

 

[Spring boot] Spring Security 기본 구현

Spring Security이전 포스트에서는 Spring Security의 기본 구조에 대해서 살펴보았다.Spring Security의 기본 구조에 대한 내용은 아래 포스트를 참고하면 된다. [Spring boot] Spring Security 구조 이해Spring Security

kyu-nahc.tistory.com

해당 포스트에서는 Spring Security를 사용하여 Jwt 토큰 인증 방식을 구현할 것이다.

Jwt 토큰 인증 방식은 기존의 세션 인증 방식과 다른 방법으로

토큰 자체를 이용하여 사용자를 인증하고 권한을 부여한다.

토큰 인증 방식과 Jwt 토큰에 대한 내용은 아래 포스팅에 자세히 나와있다.

 

[Server] 토큰 기반 인증 & 세션 기반 인증

Authentication (인증) /  Authorization (인가) 세션 기반 인가와 토큰 기반 인가에 대해 알아보기 이전에 먼저, 인증과 인가가 무엇인지부터 알아야 할 필요가 있다. 인증과 인가를 같거나 비슷한 개념

kyu-nahc.tistory.com

 

 

[Server] What is JWT ( Json Web Token )

JWT ( Json Web Token)현대 웹서비스에서는 토큰을 사용하여 사용자들의 인증 작업을 처리하는 것이 가장 좋은 방법이다. 현재 토큰 기반 인증 시스템에서 가장 많이 사용되는 것이 JWT이다.

kyu-nahc.tistory.com

 

개발 환경 및 코드를 보기 전 Jwt 토큰을 사용하는 흐름을 다시 살펴보면 다음과 같다.

Jwt 토큰을 사용할 때 AccessToken / RefreshToken을 이용한다.

평소에 API 통신할 때는 Access Token을 사용하고, 

Refresh Token은 Access Token이 만료되어 갱신될 때만 사용한다.

통신과정에서 탈취당할 위험이 큰 Access Token의 만료 기간을 짧게 두고

Refresh Token으로 주기적으로 재발급함으로써 피해를 최소화한 것이다.

Access Token과 RefreshToken을 사용하는 과정을 다음과 같다.

  1. 클라이언트가 ID, PW로 서버에게 인증을 요청
    서버는 이를 확인하여 Access Token과 Refresh Token을 발급
  2. 클라이언트 측에서 Refresh Token을 저장하고,
    Access Token으로 서버에게 API를 자유롭게 요청
  3. 서비스를 이용 중 Access Token이 만료되어 사용할 수 없다는 오류를 서버로부터 전달받음
  4. 클라이언트는 Access Token의 만료 사실을 인지하고
    본인이 갖고 있던 Refresh Token을 서버에게 전달하며 새로운 Access Token의 발급을 요청
  5. 서버는 Refresh Token을 받아 서버의 Refresh Token Storage에
    토큰의 존재 여부를 확인 후 있다면 새로운 Access Token을 생성하여 전달
  6. 이후 2번으로 돌아가서 동일한 작업 진행

해당 흐름을 이해하고 개발환경부터 살펴보면 다음과 같다.

 

개발 환경 ( build.gradle )

  • Spring boot 3.3.3
  • Java 21
  • Gradle
  • Spring Security / Jpa / Auth Jwt
  • Maria Database / Redis

주요 개발 환경은 다음과 같고, gradle 의존성은 다음과 같다.

Redis를 사용하는 이유는 AccessToken이 아닌 RefreshToken을 저장하기 위한 용도로

key-value를 UserId - RefreshToken으로 구성하여 저장하기 위해 사용한다.

dependencies {
	implementation group: 'com.auth0', name: 'java-jwt', version: '4.4.0'
	implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.9.8'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

먼저 PrincipalDetails와 PrincipalDetailsService이다.

해당 클래스 객체에 대한 설명은 Spring Security 기본 구현에 자세히 나와있고,

코드만 살펴보면 다음과 같다.

 

PrincipalDetails

@Data
public class PrincipalDetails implements UserDetails {

    private Member member;

    public PrincipalDetails(Member member){
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        member.getRoleList().forEach(role -> authorities.add(() -> role));
        return authorities;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getUserId();
    }

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

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

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

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

 

PrincipalDetailsService

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> memberEntity = memberRepository.findByUserId(username);
        return memberEntity.map(PrincipalDetails::new).orElse(null);
    }
}

 

다음으로 Jwt와 관련된 코드들이다.

Jwt와 관련된 디렉터리를 보면 다음과 같다.

  • JwtAccessDeniedHandler
    AccessDeniedHandler를 구현하는 구현체로,
    접근 권한이 없는 사용자가 접근했을 때, 에러 처리를 하는 클래스이다.
  • JwtAuthenticationEntryPoint
    AuthenticationEntryPoint를 구현하는 구현체로,

    인증 실패를 하였을 때, 예외 처리를 하는 클래스이다.
  • JwtFilter
    Jwt Token을 검증하기 위한 Custom 필터로,

    사용자 요청이 들어왔을 때, 해당 필터를 거쳐서
    Jwt Token이 유효한지 검증한다.
  • JwtRedisService
    Jwt Refresh Token을 저장 / 삭제하기 위한 클래스로,
    RedisTemplate를 통해 Refresh Token 저장 및 삭제를 실시한다.
  • JwtSendErrorService
    JwtAccessDeniedHandler, JwtAuthenticationEntryPoint에서
    에러를 처리하기 위해 사용한다. HttpServletResponse 응답을 작성하는 로직을 가지고 있다.
  • JwtTokenProvider
    Jwt 토큰을 생성하는 클래스로,

    AccessToken, RefreshToken을 생성하고 검증 로직을 가지고 있다.

이제 하나씩 자세히 클래스를 살펴보면 다음과 같다.

 

JwtTokenProvider

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
    public static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
    private final MemberRepository memberRepository;

    public TokenResponse generateToken(Authentication authentication){

        // PrincipalDetails 객체 캐스팅
        // 로그인 완료 시 토큰 생성
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        log.info("Login User Id : {}",principalDetails.getMember().getUserId());

        long now = (new Date()).getTime();

        String accessToken = JWT.create()
                // principalDetails.getUsername() UserId ex) kyu1234
                // UserId를 통해 서명을 다시 한 후 유효성 Entity 검사까지 진행
                .withSubject(principalDetails.getUsername())
                .withExpiresAt(new Date(now + ACCESS_TOKEN_EXPIRE_TIME))
                .withClaim("key",principalDetails.getMember().getId())
                .withClaim("userId",principalDetails.getMember().getUserId())
                .withClaim("name",principalDetails.getMember().getName())
                .sign(Algorithm.HMAC512(secretKey));

        // Refresh Token 생성
        String refreshToken = JWT.create()
                .withSubject(principalDetails.getUsername())
                .withExpiresAt(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .withClaim("key",principalDetails.getMember().getId())
                .withClaim("userId",principalDetails.getMember().getUserId())
                .withClaim("name",principalDetails.getMember().getName())
                .sign(Algorithm.HMAC512(secretKey));
        return new TokenResponse(accessToken,refreshToken);
    }

    public Authentication validateToken(String jwtToken){
        String userId = getUserIdFromToken(jwtToken);
        Optional<Member> memberEntity = memberRepository.findByUserId(userId);
        if (memberEntity.isPresent()) {
            PrincipalDetails principalDetails = new PrincipalDetails(memberEntity.get());
            // Member Entity 권한이 주이므로 Session 객체의 목적은 Member 권한
            return new UsernamePasswordAuthenticationToken(
                    principalDetails, jwtToken, principalDetails.getAuthorities());
        }
        return null;
    }

    public String getUserIdFromToken(String jwtToken){
        return JWT.require(Algorithm.HMAC512(secretKey))
                .build().verify(jwtToken).getClaim("userId")
                .asString();
    }
}

 

해당 클래스에서는 Jwt 토큰인 AccessToken과 RefreshToken을 생성한다.

둘의 차이는 만료시간의 차이로 AccessToken은 30분 RefreshToken은 7일로 설정하였다.

generateToken() 메소드에서는 인증된 Authentication 객체를 매개변수로 받아서

PrincipalDetails 객체로 캐스팅한 후 인증된 유저 객체에 접근하도록 한다.

인증된 유저 정보인 Primary_Key, 아이디, 이름 등을 비공개 클레임으로 등록해 준다.

 

validateToken() 메소드는 JwtFilter에서 사용하는 메소드로

요청으로 온 Token이 유효한지 확인한다. 이때 getUserIdFromToken()을 사용하여

요청으로 온 Jwt Token에서 클레임 userId 정보를 가져온다.

그리고 해당 아이디를 가지고 있는  Member Entity를 가져와서 PrincipalDetails 객체를 만들어준다.

 

인증에 있어서는 Jwt Token을 통해 검증하지만 Spring Security에서 제공하는 권한 제어를 위해

Spring Securiy의 Session인 SpringContextHolder를 사용하기 위해

PrinicpalDetails 객체 및 UsernamePasswordAuthenticationToken을 생성하는 것이다.

세션 인증 기반은 아니지만, 권한 제어를 위한 세션을 사용하는 것이다.

즉 Http Cookie 헤더의 Session ID를 이용하는 인증을 사용하지는 않지만,

Spring Security의 SpringContextHolder의 Session을 이용하는 것이다.

따라서 validateToken() 메소드에서는 해당 Member Entity 객체가 존재하면 ( 유효하다면 )

UsernamePasswordAuthenticationToken 객체를 reutrn 하여

Spring SecurityContextHolder에 저장하는 것이다.

이 저장하는 로직은 JwtFilter에 구현되어 있다.

 

JwtFilter

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

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

        String authToken = resolveToken(request);

        // 만약 AuthToken 없으면 그냥 필터 통과
        if (!StringUtils.hasText(authToken)) {
            filterChain.doFilter(request,response);
            return;
        }
        try{
            Authentication authentication = jwtTokenProvider.validateToken(authToken);
            // Authentication 객체 Security Session에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }catch (JWTVerificationException e) {
            request.setAttribute("exception", e);
        }
        filterChain.doFilter(request,response);
    }

    private String resolveToken(HttpServletRequest request){
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

해당 클래스가 사용자의 HttpServletRequest 요청을 가로채서

Jwt Token이 유효한지 검증하는 Custom Filter이다.

먼저 resolveToken()에서 요청 헤더에 'Authroization'이 존재하는지 검증한다.

'Authroization' 이 존재한다면 Token이 Header에 포함되어 있다는 것이고,

해당 Jwt Token 부분을 substring()으로 분류한 다음 검증을 실시한다.

 

만약 Jwt Token이 없다면 인증이 필요 없는 요청이므로 그냥 필터를 통과하도록 하고,

있다면 JwtProvider에 있는 validateToken() 메소드를 통해 한번 더 검증을 진행한다.

이때 Jwt Token을 Parsing 하는 과정에서 유효하지 않다면,

JwtVerificationException이 발생하므로 예외 처리를 위해

request 요청에 예외 정보를 담아주고, 필터를 통과시킨다.

예외가 발생하지 않는다면 SecurityContextHolder에 Authentication 객체가 저장되고,

예외가 발생한다면 JwtAuthenticationEntryPoint로 요청이 위임된다.

 

 

JwtAuthenticationEntryPoint

@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final JwtSendErrorService jwtSendErrorService;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        JWTVerificationException jwtVerificationException =
                (JWTVerificationException) request.getAttribute("exception");

        // 토큰 만료의 경우 다른 응답
        if (jwtVerificationException instanceof TokenExpiredException) {
            jwtSendErrorService.sendErrorResponseProcess(response, ErrorCode.TOKEN_EXPIRED, 499);
            return;
        }
        // 유효한 토큰이 아닌 경우 다른 응답
        if (jwtVerificationException != null) {
            jwtSendErrorService.sendErrorResponseProcess(response, ErrorCode.TOKEN_INVALID, HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }
        // 토큰이 존재 하지 않는 경우 다른 응답
        jwtSendErrorService.sendErrorResponseProcess(response, ErrorCode.TOKEN_UNAUTHORIZED, HttpServletResponse.SC_UNAUTHORIZED);
    }

}

 

해당 클래스는 인증에 대한 예외를 처리하는 메소드이다.

위의 jwtFilter에서 인증 과정에서 오류가 발생한다면 해당 클래스로 요청이 위임된다.

오류 발생 시 request 요청에 exception 객체를 저장하였으므로,

해당 객체를 다시 캐스팅하여 예외 종류에 따라 처리 가능하다.

예외 종류에 따라 ErrorResponse로 응답하도록 구현하였고, 이때 JwtSendErrorService를 이용한다.

 

여기서 한 가지 중요한 점은 요청으로 온 Jwt Token이 만료된 경우이다.

이때는 클라이언트에게 해당 AccessToken이 만료되었으므로, 재발급받도록 알려줘야 한다.

따라서 상태코드는 499, 메시지는 AccessToken이 만료되었다고 클라이언트에게 전달한다.

이에 대한 응답을 받으면 클라이언트는 발급받은 RefreshToken을 통해 

유효한 토큰인지 검증을 받고 다시 AccessToken을 받도록 해야 한다.

이에 대한 로직은 컨트롤러 및 서비스 레이어 로직에 포함되어 있다.

 

 

JwtSendErrorService

@Service
@RequiredArgsConstructor
public class JwtSendErrorService {

    private final ObjectMapper objectMapper;

    public void sendErrorResponseProcess(
            HttpServletResponse response, ErrorCode errorCode, int status)
            throws IOException {
        ErrorResponse errorResponse = new ErrorResponse(errorCode);
        String body = objectMapper.writeValueAsString(errorResponse);
        response.setStatus(status);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.getWriter().write(body);
    }
}

 

JwtSendErrorSerivce는 에러 응답을 하기 위한 클래스로,

HttpServletResponse 객체의 응답을 설정하는 것이 주 역할이다.

매개변수로 ErrorCode 객체와 status (응답코드)를 같이 받아서

response 응답의 상태코드, 메시지 등을 설정해 준다.

 

JwtAccessDeniedHandler

@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final JwtSendErrorService jwtSendErrorService;

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        jwtSendErrorService.sendErrorResponseProcess(response, ErrorCode.ACCESS_FORBIDDEN, HttpServletResponse.SC_FORBIDDEN);
    }
}

 

해당 클래스는 인증은 성공하였지만,

사용자가 접근권한이 없는 Url Pattern으로 접근 시 에러를 처리하기 위한 클래스이다.

SecurityContextHolder에 인증된 사용자 객체인 PrinicpalDetails가 저장되어 있고,

이때 권한에 따라 Api들을 분류할 수 있다.

만약 권한을 벗어난 Api에 접근하게 되면 Spring Security에서

AccessDeniedHandler의 구현체에게 요청을 위임하게 되고,

이를 JwtAccessDeniedHandler가 받는 것이다.

마찬가지로 JwtSendErrorService를 이용하여 에러 응답을 생성하는 것을 볼 수 있다.

 

 

JwtRedisService

@Service
@RequiredArgsConstructor
@Slf4j
public class JwtRedisService implements RefreshTokenRepository {

    private final RedisTemplate<String,String> redisTemplate;

    @Override
    public void save(String userId,String refreshToken){
        ValueOperations<String, String> operations = redisTemplate.opsForValue();

        if(!Objects.isNull(operations.get(userId))){
            log.info("Update RefreshToken. User Id - {} Delete Token - {}",userId,operations.get(userId));
            delete(userId);
        }
        operations.set(userId,refreshToken,REFRESH_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
        log.info("Save Refresh Token. User Id - {} Save Token - {} ",userId,refreshToken);
    }

    @Override
    public Optional<String> findByUserId(String userId){
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        String refreshToken = valueOperations.get(userId);
        if(refreshToken== null){
            return Optional.empty();
        }
        else{
            return Optional.of(refreshToken);
        }
    }
    @Override
    public void delete(String userId){
        redisTemplate.delete(userId);
    }
}

 

해당 클래스는 Jwt 로직보다는 RefreshToken을 저장 / 조회/ 삭제하는 클래스이다.

Redis 메모리 기반의 데이터베이스를 이용하며,

Key로는 유저의 아이디  ValueRefreshToken을 저장한다.

이때 데이터 만료시간을 정할 수 있는데 JwtTokenProvider의 RefreshToken 만료시간을 사용한다.

여기까지가 Jwt와 관련된 Class이고 이제 설정 부분의 코드들이다.

 

CorsConfig

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**",config);
        return source;
    }
}

 

Spring Security에서는 기본적으로 아무것도 설정하지 않으면,

모든 외부 요청에 대해서 CORS Block을 한다.

Cors를 간단하게 말하면 다른 도메인의 Resource를 사용하는 것을 말한다.

Cross-Site HTTP Requests는 Same Origin Policy를 적용받기 때문에

다른 도메인에서 요청이 불가하다. 즉 프로토콜, 호스트명, 포트가 같아야만 요청이 가능하다.

따라서 Rest Api를 사용하는 경우 다른 Frontend ( React ) 와의 통신을 주고받는 경우가 많은데,

이를 위해 Cors를 허용하도록 Custom 하여 작성해주어야 한다.

 

 

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final CorsConfig corsConfig;

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

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

        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement((session) ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource()))
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorizeRequests) ->
                        authorizeRequests
                                .requestMatchers("/auth/**").permitAll()
                                .requestMatchers("/api/user/**").hasAnyRole("USER","MANAGER","ADMIN")
                                .requestMatchers("/api/manager/**").hasAnyRole("MANAGER","ADMIN")
                                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                                .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtFilter(jwtTokenProvider),UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling((except) ->
                        except.accessDeniedHandler(jwtAccessDeniedHandler)
                                .authenticationEntryPoint(jwtAuthenticationEntryPoint));
        return http.build();
    }
}

 

SecurityFilterChain은 전 포스트에서 자세히 설명하였으므로, 

전 세션 인증 기반과의 차이점만 살펴보면 다음과 같다.

먼저 Http Cookie에 SessionID를 저장하는

세션 인증 기반을 사용하지 않기 때문에, 세션을 사용하지 않는다고 명시해 준다.

그 후 CorsConfig에서 설정한 Cors 설정들을 등록해줘야 한다.

이를 등록하지 않으면 모든 Cors들을 막기 때문에, 다른 외부 서버와 통신할 수 없다.

 

그리고 formLogin을 사용하지 않으므로 disable() 해주어야 한다.

이때 httpBasic()도 같이 disable()을 진행하는데, 이를 간단하게 설명하면, 

http의 기본적인 Session ID를 쿠키에 저장하고 서버에 요청하는 것을 방지한다는 것이다
토큰 인증 방식은 요청 헤더에서 Authorization에 토큰을 넣는다.
여기서 토큰을 들고 가능 방식은 httpBasic이 아니라 Bearer 방법이다.
따라서 Bearer를 사용하기 위해서 httpBasic을 disable() 해주는 것이다.

 

그리고 직접 Custom 하여 작성한 JwtFilter를 등록해 준다.

인증에 대한 로직을 직접 작성하였기 때문에 Spring Security에서 인증 로직을 수행하는

UsernamePasswordAuthenticationFilter 전에 이루어져야 한다.

따라서 .addFilterBefore()를 통해

UsernamePasswordAuthenticationFilter가 작동하기 전에

직접 생성한 Custom Filter가 작동하도록 설정해 준다.

마지막으로 .exceptionHandling()에서 에러 처리의 구현체들을 등록해 준다.

이제 컨트롤러 및 서비스 레이어의 코드들을 살펴보자.

 

AuthController

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/sign-up")
    public ResponseEntity<SignupResponse> signup(@RequestBody SignupRequest signupRequest){
        return ResponseEntity.ok(authService.signupProcess(signupRequest));
    }
    @PostMapping("/sign-in")
    public ResponseEntity<TokenResponse> signIn(@RequestBody SignInRequest signInRequest){
        return ResponseEntity.ok(authService.signInProcess(signInRequest));
    }
    @PostMapping("/reissue")
    public ResponseEntity<TokenResponse> reissue(@RequestBody TokenRequest tokenRequest){
        return ResponseEntity.ok(authService.reissueTokenProcess(tokenRequest));
    }
}

 

AuthController에는 3가지의 메소드가 존재한다.

회원가입, 로그인, AccessToken 재요청 이렇게 3가지의 메소드가 존재한다.

/reissue에 해당하는 컨트롤러 메소드가 RefreshToken에 대한 검증을 거쳐

다시 AccessToken 및 RefreshToken을 발급해 주는 컨트롤러이다.

@RequestBody 부분의 데이터 부분에 자신이 예전에 발급받은 RefreshToekn에 대한 정보가 담겨있다.

 

AuthService

@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;
    private final JwtRedisService jwtRedisService;

    @Transactional(readOnly = true)
    public boolean isExistUserIdProcess(String userId){
        return memberRepository.existsByUserId(userId);
    }

    @Transactional
    public SignupResponse signupProcess(SignupRequest signupRequest) {

        if (isExistUserIdProcess(signupRequest.getUserId())){
            throw new UserIdDuplicatedException(ErrorCode.USER_ID_DUPLICATED);
        }
        String encodePassword = passwordEncoder.encode(signupRequest.getPassword());
        Member newMember = Member.builder()
                .email(signupRequest.getEmail())
                .name(signupRequest.getName())
                .password(encodePassword)
                .userId(signupRequest.getUserId())
                .roles("ROLE_USER")
                .build();
        memberRepository.save(newMember);
        TokenResponse token = signInProcess(
                new SignInRequest(newMember.getUserId(),newMember.getPassword())
        );
        return new SignupResponse("Success",token.getAccessToken(),token.getRefreshToken());
    }

    @Transactional
    public TokenResponse signInProcess(SignInRequest signInRequest) {

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(signInRequest.getUserId(),signInRequest.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        // 만약 로그인 실패 시 해당 로직은 실행되지 않음.
        TokenResponse token = jwtTokenProvider.generateToken(authentication);
        String userId = signInRequest.getUserId();
        jwtRedisService.save(userId,token.getRefreshToken());
        return token;
    }

    public TokenResponse reissueTokenProcess(TokenRequest tokenRequest){

        String targetRefreshToken = tokenRequest.getRefreshToken();

        Authentication authentication;
        try {
            authentication = jwtTokenProvider.validateToken(targetRefreshToken);
            log.info("Verification RefreshToken - {}",targetRefreshToken);
        } catch (JWTVerificationException e){
            // 기간 만료 혹은 잘못된 JWT 토큰일 경우
            log.info("Not Valid Refresh Token -{}",targetRefreshToken);
            throw new RefreshTokenExpiredException(ErrorCode.REFRESH_TOKEN_EXPIRED);
        }
        String targetUserId = jwtTokenProvider.getUserIdFromToken(tokenRequest.getRefreshToken());
        Optional<String> redisRefreshToken = jwtRedisService.findByUserId(targetUserId);
        if (redisRefreshToken.isEmpty()){
            // 이미 로그아웃해서 DB에 없는 경우
            log.info("Not Exists Refresh Token In Redis DB -{} -{}",targetUserId,targetRefreshToken);
            throw new RefreshTokenNotExistException(ErrorCode.REFRESH_TOKEN_IS_NOT_EXIST_REDIS);
        }else{
            if (!targetRefreshToken.equals(redisRefreshToken.get())){
                // DB에 존재하지 않는 Refresh Token 이랑 일치하지 않으므로 에러 응답
                // 다시 로그인 요청
                log.info("Not Equals Refresh Token In Redis DB -{} -{}",targetUserId,targetRefreshToken);
                throw new RefreshTokenNotEqualsException(ErrorCode.REFRESH_TOKEN_IS_NOT_EQUALS_REDIS);
            }
        }
        TokenResponse newTokenDto = jwtTokenProvider.generateToken(authentication);
        log.info("New Refresh Token -{} -{}",targetUserId,newTokenDto.getRefreshToken());
        jwtRedisService.save(targetUserId,newTokenDto.getRefreshToken());
        return newTokenDto;
    }

    public boolean logoutProcess(PrincipalDetails userDetails){
        String userId = userDetails.getUsername();
        Optional<String> redisRefreshToken = jwtRedisService.findByUserId(userId);
        if (redisRefreshToken.isPresent()){
            log.info("Logout User. User Id -{}",userId);
            jwtRedisService.delete(userId);
        }else{
            log.info("Already Logout User. User Id -{}",userId);
            return false;
        }
        return true;
    }
}

 

AuthService에는 회원가입, 로그인, 토큰 재발급, 로그아웃 로직이 들어가 있다.

 

먼저 회원가입을 보면 아이디 중복 체크 검증을 거친 후, 

비밀번호를 암호화시켜서 데이터베이스에 저장한다.

그 후 로그인 로직을 통해 Authentication 객체를 생성하여 Jwt 토큰을 발급받도록 한다.

RefreshToken은 JwtRedisService의 save() 메소드를 통해 데이터베이스에 저장하고,

클라이언트에게 회원가입 성공 메시지와 함께 생성된 토큰 2개를 응답으로 제공한다.

 

로그인 로직에서는

UsernamePasswordAuthenticationToken을 통해 인증용 객체를 만들고, 인증을 시도한다.

인증에 실패하면 인증 실패 Handler로 요청이 위임되므로,

그 이후의 로직인 토큰 생성은 실행되지 않는다.

따라서 유효한 아이디와 비밀번호로 인증이 완료되면 Authentication 객체가 생성되고,

이를 통해 토큰 2개를 응답으로 받는다.

 

다음은 RefreshToken을 통한 AccessToken 재발급 로직이다.

여기서는 3가지 경우가 존재한다.

  • RefreshToken이 기간 만료되거나 유효하지 않은 토큰인 경우
  • 로그아웃을 이미 해서 Redis 데이터베이스에 없는 경우
  • Redis 데이터베이스에는 존재하지만 클라이언트 요청 RefreshToken과
    데이터베이스에 존재하는 값과 다른 경우

이렇게 3가지에 대해서는 에러를 응답해 주고, 다시 로그인을 해서

RefreshToken 갱신 및 토큰 2개를 발급받도록 응답 메시지를 설정한다.

만약 RefreshToken 검증이 모두 유효하다면 새로운 토큰 2개를 발급해 준다.

 

로그아웃은 간단하다.

단 클라이언트 쪽에서 AccessToken과 RefreshToken을 모두 제거하는 로직은 필요하다.

서버 쪽에서는 RefreshToken만 삭제해 주면 된다.

마지막으로 유저 정보에 접근하는 ApiController이다.

 

ApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
@Slf4j
public class ApiController {

    private final AuthService authService;

    @GetMapping("/user")
    public String user(@AuthenticationPrincipal PrincipalDetails userDetails){
        log.info(userDetails.getUsername());
        return "user";
    }
    @GetMapping("/manager")
    public String manager(@AuthenticationPrincipal PrincipalDetails userDetails){
        log.info(userDetails.getUsername());
        return "manager";
    }
    @GetMapping("/admin")
    public String admin(@AuthenticationPrincipal PrincipalDetails userDetails){
        log.info(userDetails.getUsername());
        return "admin";
    }
    @PostMapping("/logout")
    public ResponseEntity<String> logout(@AuthenticationPrincipal PrincipalDetails userDetails){
        if (authService.logoutProcess(userDetails)){
            return ResponseEntity.ok("Logout Successful");
        }else{
            return ResponseEntity.ok("Logout Fail");
        }
    }
}

 

해당 컨트롤러에서는 사용자의 권한에 따라 Api에 접근할 수 있다.

또한 기본 구현에서 살펴보았듯이 @AuthenticationPrincipal를 통해

컨트롤러에서 바로 인증이 완료된 객체를 접근하도록 할 수 있다.

SecurityContextHolder의 Session은 이용하기 때문에 세션 인증 객체에 접근가능한 것이다.

또한 로그아웃은 로그인한 경우에만 접근 가능한 Api로 @AuthenticationPrincipal를 통해

인증 객체를 받아오고 AuthService의 logoutProcess() 매개변수로 전달한다.

 

여기까지가 Spring Security를 사용한 Jwt Rest Api 구현이다.

기본 구현과 다르게 Custom Filter인

JwtFilter를 새로 만들어 등록하고, 검증 또한 직접 구현할 필요가 있다.

RefreshToken을 저장하는 방법은 Redis 말고도

기존의 관계형 데이터베이스를 사용해도 상관없으며,

대신 RefreshToken 이외의 다른 토큰 정보도 추가해서 저장한다면,

토큰 인증 기반의 장점인 무상태성을 잃어버릴 수 있다.

따라서 토큰 기반 인증의 장점을 잃어버리지 않도록 구현하는 것이 중요하다.

반응형

loading