장쫄깃 기술블로그

[Spring Security] Spring Security (JWT, Access Token, Refresh Token) - Spring Security 6.1 이후 본문

Spring Framework/Spring Security

[Spring Security] Spring Security (JWT, Access Token, Refresh Token) - Spring Security 6.1 이후

장쫄깃 2024. 8. 13. 18:13
728x90


들어가며


Spring Security 6.1부터 기존에 사용하던 and()와 non-Lambda DSL Method가 Deprecated 되고, 필수적으로 Lambda DSL을 사용하도록 변경되었다.

 

변경된 내용으로 스프링 시큐리티 JWT 로그인을 구현해보려 한다.

 

다만, 본 게시글은 스프링 시큐리티 위주의 내용만 작성하려고 한다. 로그인, 회원가입 등의 별도 비즈니스 로직 코드는 게시글 하단의 깃허브를 참고하길 바란다.

 

기술스택

- Spring Boot 3.3.1
- Spring Security 6.3.1
- JPA
- JWT(Access Token, Refresh Token) 구현
- Spring Security 6.1 이후 lambda 문법을 이용한 코드 적용

 

JWT에 대한 설명이나 이전에 작성한 내용에 대해선 다음 글을 참고하면 된다.

링크 : https://jangjjolkit.tistory.com/26

 

[Spring Security] 3. Spring Security 적용하기 (JWT, Access Token, Refresh Token)

들어가며 지난 게시글에서 스프링 시큐리티를 이용한 로그인 구현 시 Session을 사용하는 방법을 알아보았다. 스프링 시큐리티는 기본적으로 Session을 사용하는 것이 기본이지만 JWT를 이용하여 로

jangjjolkit.tistory.com

 

 

1. build.gradle


plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.1'
	id 'io.spring.dependency-management' version '1.1.5'
}

group = 'com.jdh'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	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 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	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'
	implementation 'org.springframework.boot:spring-boot-starter-validation'

	// log4jdbc
	implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

	// 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'

	// gson
	implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9'

	// UUID
	implementation "com.fasterxml.uuid:java-uuid-generator:4.0.1"

}

test {
	useJUnitPlatform()
}

compileJava {
	options.compilerArgs << '-parameters'
}

 

 

2. SpringBootApplication.java


@EnableJpaAuditing
@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
public class springSecurityJwtApplication {

	public static void main(String[] args) {
		SpringApplication.run(springSecurityJwtApplication.class, args);
	}

}

Spring Boot Security Auto Configuration을 비활성화하기 위해 @SpringBootApplication(exclude = SecurityAutoConfiguration.class)를 적용한다.

 

추가적으로, JPA를 사용하기 때문에 @EnableJpaAuditing 어노테이션을 사용했다.

 

 

3. Spring Security Configuration


/**
 * Spring Security Config
 */
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler;

    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
        return new MvcRequestMatcher.Builder(introspector);
    }

    @Bean
    public SecurityFilterChain config(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
        MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector);

        // white list (Spring Security 체크 제외 목록)
        MvcRequestMatcher[] permitAllWhiteList = {
                mvc.pattern("/login"),
                mvc.pattern("/register"),
                mvc.pattern("/token-refresh"),
                mvc.pattern("/favicon.ico"),
                mvc.pattern("/error")
        };

        // http request 인증 설정
        http.authorizeHttpRequests(authorize -> authorize
                .requestMatchers(permitAllWhiteList).permitAll()
                // 사용자 삭제는 관리자 권한만 가능
                .requestMatchers(HttpMethod.DELETE, "/user").hasRole(RoleName.ROLE_ADMIN.getRole())
                // 그 외 요청 체크
                .anyRequest().authenticated()
        );

        // form login disable
        http.formLogin(AbstractHttpConfigurer::disable);

        // logout disable
        http.logout(AbstractHttpConfigurer::disable);

        // csrf disable
        http.csrf(AbstractHttpConfigurer::disable);

        // session management
        http.sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 미사용
        );

        // before filter
        http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        // exception handler
        http.exceptionHandling(conf -> conf
                .authenticationEntryPoint(customAuthenticationEntryPointHandler)
                .accessDeniedHandler(customAccessDeniedHandler)
        );

        // build
        return http.build();
    }

}

기존에 and()를 사용하여 설정 옵션을 연결하는 방식이 아닌 Lambda DSL 방식을 사용하는 Security Configuration이다. Security, login, logout, Handler 설정 등을 담당한다. 또, 비밀번호 암/복호화에 사용할 BCryptPasswordEncoder와 특정 경로 매칭을 위한 MvcRequestMatcher를 bean으로 등록한다.

 

JWT 방식을 사용하기 때문에 form login, logout 등을 미사용 하도록 설정한다.

// form login disable
http.formLogin(AbstractHttpConfigurer::disable);

// logout disable
http.logout(AbstractHttpConfigurer::disable);

 

JWT를 사용하는 경우 세션을 사용하지 않기 때문에 스프링 시큐리티에서 세션을 생성하거나 사용하지 않도록 설정한다.

// session management
http.sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 미사용
);

 

세션 생성 정책(SessionCreationPolicy)은 다음과 같다.

  • ALWAYS (Default)
    • 스프링 시큐리티가 HttpSession을 항상 생성하고 사용
  • NEVER
    • 스프링 시큐리티가 HttpSession을 생성하지 않으나, 이미 존재하는 HttpSession에 대해서는 사용
  • IF_REQUIRED
    • 스프링 시큐리티가 필요한 경우에만 HttpSession을 생성
  • STATELESS
    • 스프링 시큐리티가 HttpSession을 생성하지 않고, SecurityContext를 획득하는 데 사용하지 않음

 

토근을 검증하기 위한 Filter를 설정한다.

// before filter
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

 

 

4. JwtProvider.java


application.yml

jwt:
  secret: c3ByaW5nU2VjdXJpdHlKd3RQcmFjdGljZUtleUphbmdKam9sa2l0

먼저 토큰에 사용할 secret key를 설정한다. 특정 문자열을 base64 encode 해서 key로 사용한다.

 

base64 encode는 해당 사이트를 이용했다.

링크 : https://www.base64encode.org/

 

Base64 Encode and Decode - Online

Encode to Base64 format or decode from it with various advanced options. Our site has an easy to use online tool to convert your data.

www.base64encode.org

 

그리고 JWT를 생성, 검증, 및 관리를 담당하는 JwtProvider를 구현한다.

 

JwtProvider.java

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtProvider {

    // jwt 만료 시간 1시간
    private static final long JWT_TOKEN_VALID = (long) 1000 * 60 * 30;

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

    private SecretKey key;

    @PostConstruct
    public void init() {
        key = Keys.hmacShaKeyFor(secret.getBytes());
    }

    /**
     * token Username 조회
     *
     * @param token JWT
     * @return token Username
     */
    public String getUsernameFromToken(final String token) {
        return getClaimFromToken(token, Claims::getId);
    }

    /**
     * token 사용자 속성 정보 조회
     *
     * @param token JWT
     * @param claimsResolver Get Function With Target Claim
     * @param <T> Target Claim
     * @return 사용자 속성 정보
     */
    public <T> T getClaimFromToken(final String token, final Function<Claims, T> claimsResolver) {
        // token 유효성 검증
        if(Boolean.FALSE.equals(validateToken(token)))
            return null;

        final Claims claims = getAllClaimsFromToken(token);

        return claimsResolver.apply(claims);
    }

    /**
     * token 사용자 모든 속성 정보 조회
     *
     * @param token JWT
     * @return All Claims
     */
    private Claims getAllClaimsFromToken(final String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 토큰 만료 일자 조회
     *
     * @param token JWT
     * @return 만료 일자
     */
    public Date getExpirationDateFromToken(final String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    /**
     * access token 생성
     *
     * @param id token 생성 id
     * @return access token
     */
    public String generateAccessToken(final String id) {
        return generateAccessToken(id, new HashMap<>());
    }

    /**
     * access token 생성
     *
     * @param id token 생성 id
     * @return access token
     */
    public String generateAccessToken(final long id) {
        return generateAccessToken(String.valueOf(id), new HashMap<>());
    }

    /**
     * access token 생성
     *
     * @param id token 생성 id
     * @param claims token 생성 claims
     * @return access token
     */
    public String generateAccessToken(final String id, final Map<String, Object> claims) {
        return doGenerateAccessToken(id, claims);
    }

    /**
     * JWT access token 생성
     *
     * @param id token 생성 id
     * @param claims token 생성 claims
     * @return access token
     */
    private String doGenerateAccessToken(final String id, final Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setId(id)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALID)) // 30분
                .signWith(key)
                .compact();
    }

    /**
     * refresh token 생성
     *
     * @param id token 생성 id
     * @return refresh token
     */
    public String generateRefreshToken(final String id) {
        return doGenerateRefreshToken(id);
    }

    /**
     * refresh token 생성
     *
     * @param id token 생성 id
     * @return refresh token
     */
    public String generateRefreshToken(final long id) {
        return doGenerateRefreshToken(String.valueOf(id));
    }

    /**
     * refresh token 생성
     * 
     * @param id token 생성 id
     * @return refresh token
     */
    private String doGenerateRefreshToken(final String id) {
        return Jwts.builder()
                .setId(id)
                .setExpiration(new Date(System.currentTimeMillis() + (JWT_TOKEN_VALID * 2) * 24)) // 24시간
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .signWith(key)
                .compact();
    }

    /**
     * token 검증
     *
     * @param token JWT
     * @return token 검증 결과
     */
    public Boolean validateToken(final String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SecurityException e) {
            log.warn("Invalid JWT signature: {}", e.getMessage());
        } catch (MalformedJwtException e) {
            log.warn("Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            log.warn("JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.warn("JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.warn("JWT claims string is empty: {}", e.getMessage());
        }

        return false;
    }

}

 

 

5. JwtAuthFilter.java


요청을 필터링하고 JWT를 사용하여 인증 및 권한 부여를 처리하는 JwtAuthFilter를 구현한다.

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    private final UserGetService userGetService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String token = request.getHeader("Authorization");

        String username = null;

        // Bearer token 검증 후 user name 조회
        if(token != null && !token.isEmpty()) {
            String jwtToken = token.substring(7);

            username = jwtProvider.getUsernameFromToken(jwtToken);
        }

        // token 검증 완료 후 SecurityContextHolder 내 인증 정보가 없는 경우 저장
        if(username != null && !username.isEmpty() && SecurityContextHolder.getContext().getAuthentication() == null) {
            // Spring Security Context Holder 인증 정보 set
            SecurityContextHolder.getContext().setAuthentication(getUserAuth(username));
        }

        filterChain.doFilter(request,response);
    }

    /**
     * token의 사용자 idx를 이용하여 사용자 정보 조회하고, UsernamePasswordAuthenticationToken 생성
     * 
     * @param username 사용자 idx
     * @return 사용자 UsernamePasswordAuthenticationToken
     */
    private UsernamePasswordAuthenticationToken getUserAuth(String username) {
        var userInfo = userGetService.getUserById(Long.parseLong(username));

        return new UsernamePasswordAuthenticationToken(userInfo.id(),
                userInfo.password(),
                Collections.singleton(new SimpleGrantedAuthority(userInfo.roleName().name()))
        );
    }

}

 

 

6. Handler 구현


CustomAuthenticationEntryPointHandler.java

/**
 * Custom Authentication Entry Point Handler
 */
@Slf4j
@Component
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        log.info("[CustomAuthenticationEntryPointHandler] :: {}", authException.getMessage());
        log.info("[CustomAuthenticationEntryPointHandler] :: {}", request.getRequestURL());
        log.info("[CustomAuthenticationEntryPointHandler] :: 토근 정보가 만료되었거나 존재하지 않음");

        response.setStatus(ApiExceptionEnum.ACCESS_DENIED.getStatus().value());
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=UTF-8");

        JsonObject returnJson = new JsonObject();
        returnJson.addProperty("errorCode", ApiExceptionEnum.ACCESS_DENIED.getCode());
        returnJson.addProperty("errorMsg", ApiExceptionEnum.ACCESS_DENIED.getMessage());

        PrintWriter out = response.getWriter();
        out.print(returnJson);
    }

}

AuthenticationEntryPoint의 구현체 CustomAuthenticationEntryPointHandler를 구현한다. 이 핸들러는 사용자가 인증되지 않았거나 유효한 인증정보를 가지고 있지 않은 경우 동작하는 클래스이다. 한마디로 로그인을 하지 않은 사용자가 로그인이 필요한 리소스에 접근할 때 동작한다. 해당 코드에선 401 관련 내용을 응답한다.

 

CustomAccessDeniedHandler.java

/**
 * Custom Access Denied Handler Handler
 */
@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.info("[CustomAccessDeniedHandler] :: {}", accessDeniedException.getMessage());
        log.info("[CustomAccessDeniedHandler] :: {}", request.getRequestURL());
        log.info("[CustomAccessDeniedHandler] :: 토근 정보가 만료되었거나 존재하지 않음");

        response.setStatus(ApiExceptionEnum.FORBIDDEN.getStatus().value());
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=UTF-8");

        JsonObject returnJson = new JsonObject();
        returnJson.addProperty("errorCode", ApiExceptionEnum.FORBIDDEN.getCode());
        returnJson.addProperty("errorMsg", ApiExceptionEnum.FORBIDDEN.getMessage());

        PrintWriter out = response.getWriter();
        out.print(returnJson);
    }
}

AccessDeniedHandler의 구현체 CustomAccessDeniedHandler를 구현한다. 이 핸들러는 사용자가 접근한 리소스에 대한 권한이 없는 경우 동작하는 클래스이다. 해당 코드에선 403 관련 내용을 응답한다.

 

 

7. RTR (Refresh Token Rotation)


RTR을 간단하게 설명하면 Refresh Token을 단 한 번만 사용할 수 있도록 하는 방법이다.

 

Access Token과 Refresh Token은 서버가 관리하지 않는 Stateless 상태이기 때문에, 탈취 여부를 알 수 없다. Access Token은 탈취를 방지해 만료시간을 짧게 설정하지만, Refresh Token은 만료시간이 길기 때문에 한번 탈취당하면 장시간 악용될 수 있다.

 

이 문제를 해결하기 위해 등장한 것이 RTR 기법이다.

 

출처 : https://pragmaticwebsecurity.com/articles/oauthoidc/refresh-token-protection-implications.html

 

RTR 작동 원리는 다음과 같다.

  1. Access Token1과 Refresh Token1을 얻는다.
  2. Refresh Token1을 사용하여 Access Token2과 Refresh Token2를 얻는다.
  3. Refresh Token2를 사용하여 Access Token3과 Refresh Token3을 얻는다.
  4. 이 과정을 반복한다.

클러스터링 환경에서 빠르고 편하게 Token을 처리하기 위해 Redis가 적합하다. Redis에 Refresh Token을 저장하고 만료시간을 설정하면, 후에 Token이 자연스럽게 만료되거나, Refresh Token을 사용하면 Redis에 있는 기존 Refresh Token을 폐기시키는 방식이다.

 

하지만, 본 게시물에서는 단순 기능 구현을 목적으로 static HashMap을 사용했다. 실제 운영 환경에서 Refresh Token RTR 기법을 구현하려는 경우, HashMap 대신 Redis를 사용하는 것을 권장한다.

 

필자는 2가지 경우에 Refresh Token을 폐기시킨다.

첫 번째는 Refresh Token을 사용하여 Access Token과 Refresh Token을 갱신할 때이다. 두 번째는 로그인할 때, 해당 사용자의 기존 Refresh Token을 폐기시키고 새로운 Refresh Token을 발급한다.

 

만약 공격자가 Refresh Token을 탈취하여 사용한다면, 원래 사용자는 더 이상 해당 Refresh Token을 사용할 수 없게 된다. 이때 원래 사용자가 Token을 갱신하지 못하고 다시 로그인하면, 기존 Refresh Token을 폐기시키기 때문에, 공격자가 가지고 있는 Refresh Token은 사용하지 못하게 된다.

 

RefreshToken.java

/**
 * RefreshToken 저장 객체
 *
 * <p>
 * 해당 프로젝트는 스프링 시큐리티 위주의 프로젝트이기 때문에 간단하게 구현
 * 운영환경에서는 해당 방식이 아닌 Redis 사용을 추천
 * Redis 에서 만료시간을 설정하여 관리
 * </p>
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RefreshToken {

    protected static final Map<String, Long> refreshTokens = new HashMap<>();

    /**
     * refresh token get
     *
     * @param refreshToken refresh token
     * @return id
     */
    public static Long getRefreshToken(final String refreshToken) {
        return Optional.ofNullable(refreshTokens.get(refreshToken))
                .orElseThrow(() -> new RefreshTokenException(RefreshTokenExceptionResult.NOT_EXIST));
    }

    /**
     * refresh token put
     *
     * @param refreshToken refresh token
     * @param id id
     */
    public static void putRefreshToken(final String refreshToken, Long id) {
        refreshTokens.put(refreshToken, id);
    }

    /**
     * refresh token remove
     *
     * @param refreshToken refresh token
     */
    private static void removeRefreshToken(final String refreshToken) {
        refreshTokens.remove(refreshToken);
    }

    // user refresh token remove
    public static void removeUserRefreshToken(final long refreshToken) {
        for(Map.Entry<String, Long> entry : refreshTokens.entrySet()) {
            if(entry.getValue() == refreshToken) {
                removeRefreshToken(entry.getKey());
            }
        }
    }

}

 

RefreshToken을 static 하게 관리하기 위한 클래스이다.

 

RefreshTokenServiceImpl.java

@Service
@RequiredArgsConstructor
public class RefreshTokenServiceImpl implements RefreshTokenService {

    private final JwtProvider jwtProvider;

    /**
     * refresh token을 이용하여 access token, refresh token 재발급
     *
     * @param refreshToken refresh token
     * @return RefreshTokenResponseDTO
     */
    @Override
    public RefreshTokenResponseDTO refreshToken(final String refreshToken) {
        // refresh token 유효성 검증
        checkRefreshToken(refreshToken);

        // refresh token id 조회
        var id = RefreshToken.getRefreshToken(refreshToken);

        // 새로운 access token 생성
        String newAccessToken = jwtProvider.generateAccessToken(id);

        // 기존에 가지고 있는 사용자의 refresh token 제거
        RefreshToken.removeUserRefreshToken(id);

        // 새로운 refresh token 생성 후 저장
        String newRefreshToken = jwtProvider.generateRefreshToken(id);
        RefreshToken.putRefreshToken(newRefreshToken, id);

        return RefreshTokenResponseDTO.builder()
                .accessToken(newAccessToken)
                .refreshToken(newRefreshToken)
                .build();
    }

    /**
     * refresh token 검증
     *
     * @param refreshToken refresh token
     */
    private void checkRefreshToken(final String refreshToken) {
        if(Boolean.FALSE.equals(jwtProvider.validateToken(refreshToken)))
            throw new RefreshTokenException(RefreshTokenExceptionResult.INVALID);
    }

}

 

Refresh Token으로 Token 갱신 요청 시 동작하는 Service이다. 만약 Refresh Token이 검증되지 않으면, Custom Exception을 발생시킨다. 만약 Refresh Token이 검증되면, 기존에 가지고 있는 사용자의 Refresh Token을 제거하고 새로운 Refresh Token을 생성, 저장한다. 그리고 저장한 Refresh Token을 새로운 Access Token과 함께 반환한다.

 

LoginServiceImpl.java

@Service
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService {

    private final UserGetService userGetService;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    private final JwtProvider jwtProvider;

    @Override
    @Transactional
    public LoginResponseDTO login(final LoginRequestDTO loginRequestDTO) {
        // 사용자 정보 조회
        UserGetResponseDTO userInfo = userGetService.getUserByUserId(loginRequestDTO.getUserId());

        // password 일치 여부 체크
        if(!bCryptPasswordEncoder.matches(loginRequestDTO.getPassword(), userInfo.password()))
            throw new LoginException(LoginExceptionResult.NOT_CORRECT);

        // jwt 토큰 생성
        String accessToken = jwtProvider.generateAccessToken(userInfo.id());

        // 기존에 가지고 있는 사용자의 refresh token 제거
        RefreshToken.removeUserRefreshToken(userInfo.id());

        // refresh token 생성 후 저장
        String refreshToken = jwtProvider.generateRefreshToken(userInfo.id());
        RefreshToken.putRefreshToken(refreshToken, userInfo.id());

        return LoginResponseDTO.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

}

 

사용자가 로그인할 때, 기존에 저장되어 있던 사용자의 Refresh Token을 모두 제거한다. 그리고 새로운 Refresh Token을 생성, 저장한다. 마지막으로, 저장한 Refresh Token을 Access Token과 함께 반환한다.

 

 

테스트


1. 회원가입

 

2. 로그인

 

로그인에 실패할 경우 다음과 같이 응답한다.

 

3. 사용자 정보 조회

로그인 성공 후 응답받은 Access Token을 Header의 Authorization에 설정하고 사용자 정보를 조회하면 Token에 대한 사용자 정보를 응답한다.

 

만약 Token이 유효하지 않은 경우 다음과 같이 응답한다.

 

4. 계정 삭제

스프링 시큐리티 설정에서 계정 삭제는 관리자 권한만 접근 가능하도록 설정했다. 만약 권한이 없는 사용자가 계정 삭제를 시도하는 경우 다음과 같이 응답한다.

 

만약 권한이 있는 사용자(관리자)가 계정 삭제를 시도하면 정상적으로 처리된다.

 

DB의 del_yn이 true로 변경되어 삭제된 것을 볼 수 있다.

 

5. Refresh Token을 이용한 Access Token 갱신

Refresh Token을 이용해 Access Token을 갱신할 수 있다.

 

한번 사용한 Refresh Token은 RTR 기법으로 인해 재사용할 수 없다.

 

Refresh Token은 로그인할 때마다 갱신된다. 따라서 Refresh Token이 탈취되더라도, 사용자가 다시 로그인하면 이전에 탈취된 Refresh Token은 사용할 수 없게 된다.

 

 

정리하며


위에서 언급했듯이 스프링 시큐리티 위주의 내용 설명만을 다뤘다. 자세한 코드는 깃허브를 참고하길 바란다.

 

링크 : https://github.com/JangDaeHyeok/SpringBoot-Security-Jwt

 

GitHub - JangDaeHyeok/SpringBoot-Security-Jwt

Contribute to JangDaeHyeok/SpringBoot-Security-Jwt development by creating an account on GitHub.

github.com

 

728x90