반응형

이번 시간에는, 리프레시 토큰을 이용하여 액세스 토큰을 재발급하는 기능을 구현해보도록 하겠습니다.

 

 

service.sign 패키지에 작성된 SignService에 다음과 같이 SignService.refreshToken 메소드를 작성합니다.

// SignService.java

public RefreshTokenResponse refreshToken(String rToken) {
    validateRefreshToken(rToken);
    String subject = tokenService.extractRefreshTokenSubject(rToken);
    String accessToken = tokenService.createAccessToken(subject);
    return new RefreshTokenResponse(accessToken);
}

private void validateRefreshToken(String rToken) {
    if(!tokenService.validateRefreshToken(rToken)) {
        throw new AuthenticationEntryPointException();
    }
}

검증된 리프레시 토큰에서 subject를 추출하여, 새로운 액세스 토큰을 발급해줍니다.

리프레시 토큰이 유효하지 않다면, 어드바이스를 통해 AuthenticationEntryPointException 예외를 잡아낼 수 있도록 합니다.

해당 예외를 잡아낸 어드바이스는, 401 (Unauthorized) 상태코드를 응답해주게 됩니다.

 

그리고, 클래스 레벨에 작성된 @Transactional을 제거해줍니다.

@Service
@RequiredArgsConstructor
public class SignService {
    ...
}

SignService.refreshToken 메소드는 별도의 데이터베이스 작업이 없기 때문에, 트랜잭션을 열어줄 필요가 없습니다.

이로 인해 클래스 레벨에 @Transactional을 선언하면, 데이터베이스 작업이 없더라도 SignService.refreshToken 메소드에서도 불필요하게 트랜잭션을 열어야합니다.

 

SignService.signUp과 같이, SignService.signIn도 메소드 레벨에 @Transactional을 선언해주도록 하겠습니다.

// SignService.java

@Transactional(readOnly = true)
public SignInResponse signIn(SignInRequest req) {
    ....
}

다음과 같이 수정해줍니다.

 

 

dto.sign 패키지에 RefreshTokenResponse 클래스를 작성해줍니다.

package kukekyakya.kukemarket.dto.sign;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class RefreshTokenResponse {
    private String accessToken;
}

 

단순히 액세스 토큰을 가지고 있습니다.

 

 

***

- 액세스 토큰을 재발급할 때, 리프레시 토큰도 재발급해주지 않은 이유

리프레시 토큰을 재발급해주지 않은 이유는, 최소한의 안전장치가 필요했기 때문입니다.

 

이전에 토큰 인증 방식으로 진행했던 프로젝트(KUKE meet)에서는, 액세스 토큰을 재발급할 때 리프레시 토큰도 같이 재발급 해주도록 구현하였습니다.

그 때는, 유효한 리프레시 토큰이 단일하다는 것을 보장해주었기 때문입니다.

각 사용자에게 발급된 리프레시 토큰에 대해서 데이터베이스에 별도로 저장을 해주었기 때문에, 리프레시 토큰이 탈취당하더라도 사용자는 새롭게 로그인하면 기존의 리프레시 토큰을 무효화시킬 수 있었습니다.

 

하지만 지금 같은 상황에서는 각 사용자에게 발급된 리프레시 토큰을 별도로 관리해주지 않습니다.

무상태성을 위해 채택한 토큰 인증 방식의 장점을 굳이 해치지 않았고 싶었기 때문입니다.

이로 인해 리프레시 토큰이 탈취당할 경우, 해당 토큰을 이용해서 액세스 토큰을 무한정으로 재발급할 수 있게 됩니다.

이에 대한 최소한의 안전장치를 마련하고자, 로그인 할 때 발급 받은 리프레시 토큰은, 해당 토큰이 만료된다면 더 이상 유효하지 않도록 만든 것입니다.

리프레시 토큰의 만료 기한을 일주일로 설정해두었기 때문에 일주일마다 재로그인하는 과정을 거쳐야하지만, 일주일이라는 시간은 그렇게 짧은 시간이 아니어서 사용성을 크게 해치지 않을 것이라고 보았습니다.

만약, 리프레시 토큰도 재발급하여 사용자의 편의성을 더욱 높이고자 한다면, 추가적인 안전장치가 필요할 것이라고 판단됩니다.

***

 

 

이제 SignService에 새롭게 작성된 코드를 테스트해보겠습니다.

test 디렉토리에 작성해두었던 SignSerivceTest에 다음 코드를 추가해줍니다.

@Test
void refreshTokenTest() {
    // given
    String refreshToken = "refreshToken";
    String subject = "subject";
    String accessToken = "accessToken";
    given(tokenService.validateRefreshToken(refreshToken)).willReturn(true);
    given(tokenService.extractRefreshTokenSubject(refreshToken)).willReturn(subject);
    given(tokenService.createAccessToken(subject)).willReturn(accessToken);

    // when
    RefreshTokenResponse res = signService.refreshToken(refreshToken);

    // then
    assertThat(res.getAccessToken()).isEqualTo(accessToken);
}

@Test
void refreshTokenExceptionByInvalidTokenTest() {
    // given
    String refreshToken = "refreshToken";
    given(tokenService.validateRefreshToken(refreshToken)).willReturn(false);

    // when, then
    assertThatThrownBy(() -> signService.refreshToken(refreshToken))
            .isInstanceOf(AuthenticationEntryPointException.class);
}

정상적인 케이스와 유효하지 않은 토큰으로 인한 예외 케이스를 테스트해주었습니다.

자세한 설명은 생략하겠습니다.

 

 

이제 controller.sign.SignController에 토큰을 리프레시해주기 위한 API를 추가해보겠습니다.

@PostMapping("/api/refresh-token")
@ResponseStatus(HttpStatus.OK)
public Response refreshToken(@RequestHeader(value = "Authorization") String refreshToken) {
    return success(signService.refreshToken(refreshToken));
}

HTTP Authorization 헤더에 리프레시 토큰을 전달하여, SignService.refreshToken을 수행합니다.

파라미터에 설정된 @RequestHeader는 required 옵션의 기본 설정 값이 true이기 때문에, 이 헤더 값이 전달되지 않았을 때 예외가 발생하게 됩니다.

이 때 발생하는 예외가 MissingRequestHeaderException 입니다.

 

advice.ExceptionAdvice에 해당 예외도 잡아낼 수 있도록 설정해주겠습니다.

// ExceptionAdvice.java

@ExceptionHandler(MissingRequestHeaderException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Response missingRequestHeaderException(MissingRequestHeaderException e) {
    return Response.failure(-1009, e.getHeaderName() + " 요청 헤더가 누락되었습니다.");
}

MissingRequestHeaderException은 누락된 헤더의 이름을 가지고 있습니다.

응답 메시지를 통해 누락된 헤더의 이름을 알려주도록 하겠습니다.

상태코드는 400(Bad Request)를 응답해줍니다.

 

 

새롭게 작성된 API의 Security 설정을 해주겠습니다.

config.security.SecurityConfig 클래스의 configure(HttpSecurity http)를 다음과 같이 설정해줍니다.

토큰의 리프레시를 위한 API는 permitAll()로 설정해주겠습니다.

        http
                .httpBasic().disable()
                .formLogin().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .authorizeRequests()
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")
                        .anyRequest().hasAnyRole("ADMIN")
                        ...

토큰 재발급을 위한 리프레시 토큰의 유효성 검증은, SignService에서 수행해야 할 비즈니스 로직으로 볼 수 있습니다.

따라서 Spring Security에서는 해당 API를 누구든지 요청할 수 있도록 하고, SignService에서 요청을 수행할 수 있는지 토큰의 유효성을 직접 검증해주도록 하겠습니다.

 

 

이제 테스트를 작성해보겠습니다.

test 디렉토리에서 SignControllerTest에 다음 코드를 추가해줍니다.

// SignControllerTest.java

@Test
void refreshTokenTest() throws Exception {
    // given
    given(signService.refreshToken("refreshToken")).willReturn(createRefreshTokenResponse("accessToken"));

    // when, then
    mockMvc.perform(
            post("/api/refresh-token")
                    .header("Authorization", "refreshToken"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.result.data.accessToken").value("accessToken"));
}

단순히 API 요청과 의도한 응답에 대해 성공적으로 수행되는지 검증해줍니다.

 

 

이제 각 예외사항에 대해 어드바이스가 정상적으로 작동되는지 테스트해보겠습니다.

SignControllerAdviceTest에 다음 코드를 추가해줍니다.

// SignControllerAdviceTest.java

@Test
void refreshTokenAuthenticationEntryPointException() throws Exception { // 1
    // given
    given(signService.refreshToken(anyString())).willThrow(AuthenticationEntryPointException.class);

    // when, then
    mockMvc.perform(
            post("/api/refresh-token")
                    .header("Authorization", "refreshToken"))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.code").value(-1001));
}

@Test
void refreshTokenMissingRequestHeaderException() throws Exception { // 2
    // given, when, then
    mockMvc.perform(
            post("/api/refresh-token"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value(-1009));
}

1. 유효하지 않은 토큰으로 인해 AuthenticationEntryPointException 예외가 발생한다면, 어드바이스를 통해 401 상태코드를 응답받게 됩니다.

2. 누락된 HTTP 요청 헤더로 인해 MissingRequestHeaderException 예외가 발생한다면, 어드바이스를 통해 400 상태코드를 응답받게 됩니다.

 

 

 

이제 토큰을 재발급받는 과정까지 마무리 되었지만..

지난 시간에 작성했던 Spring Security 로직을 개선해보도록 하겠습니다.

우리는 액세스 토큰으로만 API 요청을 할 수 있도록 하였고, 리프레시 토큰으로는 API 요청을 수행할 수 없도록 하였습니다.

이를 위해 Security에서 관리해주는 Context에 토큰의 타입을 구분하여 인증된 사용자를 저장할 수 있도록 하였습니다.

하지만 토큰 재발급 API를 permitAll로 설정했기 때문에, 리프레시 토큰으로 요청한 사용자를 Security에서 구분할 필요가 없게 되었습니다.

이를 위해 사용자가 요청을 수행할 수 있는지 토큰의 타입을 검증하는 불필요한 과정은 제거하도록 하겠습니다. 

 

config.security.JwtAuthenticationFilter 클래스를 다음과 같이 수정해줍니다.

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final TokenService tokenService;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = extractToken(request);
        if(validateToken(token)) {
            setAuthentication(token);
        }
        chain.doFilter(request, response);
    }

    private String extractToken(ServletRequest request) {
        return ((HttpServletRequest)request).getHeader("Authorization");
    }

    private boolean validateToken(String token) {
        return token != null && tokenService.validateAccessToken(token);
    }

    private void setAuthentication(String token) {
        String userId = tokenService.extractAccessTokenSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()));
    }

}

액세스 토큰이 유효할 때만, 컨텍스트에 사용자 정보를 저장해주었습니다.

리프레시 토큰을 검증하고, 이를 이용하여 사용자 정보를 조회하는 작업은 제거되었습니다.

 

 

config.security.CustomAuthenticationToken 클래스를 다음과 같이 수정해줍니다.

이제 토큰의 타입은 가지고 있을 필요가 없습니다.

public class CustomAuthenticationToken extends AbstractAuthenticationToken {

    private CustomUserDetails principal;

    public CustomAuthenticationToken(CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        setAuthenticated(true);
    }

    @Override
    public CustomUserDetails getPrincipal() {
        return principal;
    }

    @Override
    public Object getCredentials() {
        throw new UnsupportedOperationException();
    }

}

토큰의 타입은 더 이상 사용되지 않습니다.

 

 

config.security.guard.AuthHelper 클래스도 수정해줍시다.

@Component
@Slf4j
public class AuthHelper {

    public boolean isAuthenticated() {
        return getAuthentication() instanceof CustomAuthenticationToken &&
                getAuthentication().isAuthenticated();
    }

    public Long extractMemberId() {
        return Long.valueOf(getUserDetails().getUserId());
    }

    public Set<RoleType> extractMemberRoles() {
        return getUserDetails().getAuthorities()
                .stream()
                .map(authority -> authority.getAuthority())
                .map(strAuth -> RoleType.valueOf(strAuth))
                .collect(Collectors.toSet());
    }

    private CustomUserDetails getUserDetails() {
        return (CustomUserDetails) getAuthentication().getPrincipal();
    }

    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

이제는 어차피, 액세스 토큰을 이용한 요청자의 정보만 컨텍스트에 저장되어 있습니다.

어떤 토큰으로 인증을 요청한 사용자인지 구분할 필요가 없어졌습니다.

 

 

마지막으로, AuthHelper를 의존하는 config.security.guard.MemberGuard 클래스도 수정해주겠습니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {

    private final AuthHelper authHelper;

    public boolean check(Long id) {
        return authHelper.isAuthenticated() && hasAuthority(id);
    }

    private boolean hasAuthority(Long id) {
        Long memberId = authHelper.extractMemberId();
        Set<RoleType> memberRoles = authHelper.extractMemberRoles();
        return id.equals(memberId) || memberRoles.contains(RoleType.ROLE_ADMIN);
    }
}

코드가 한결 간결해졌습니다. 토큰의 타입까지 검증하는 로직은 제거되었습니다.

이제는 단순히, 인증된 사용자인지와 요청을 수행할 수 있는 권한을 가지고 있는지만 검사해주면 됩니다.

 

 

수정된 로직이 정상적으로 반영되었는지 모든 테스트를 수행해줍니다.

하나의 테스트가 실패하게 됩니다. 살펴봅시다.

@Test
void deleteAccessDeniedByRefreshTokenTest() throws Exception {
    // given
    Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
    SignInResponse signInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));

    // when, then
    mockMvc.perform(
            delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getRefreshToken()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/exception/access-denied"));
}

MemberControllerIntegrationTest.deleteAccessDeniedByRefreshTokenTest가 실패하였습니다.

우리는 액세스 토큰으로만 테스트를 수행할 수 있다는 사실을 검증하기 위해, 위 테스트를 작성했었습니다.

이전의 방식에서는, 정상적으로 인증된 사용자라고 하더라도, 액세스 토큰이 아닌 리프레시 토큰으로 API를 요청하였을 때 CustomAccessDeniedHandler가 작동하는게 정상이었습니다.

하지만 수정된 방식에서는 리프레시 토큰으로 요청하더라도, 인증할 수 있는 사용자로 판단하지 않기 때문에 Security가 관리해주는 컨텍스트에 사용자의 정보를 등록하지 않습니다.

따라서 이제는, 리프레시 토큰으로 API 요청을 보냈을 때 더이상 사용자가 인증되지 않으므로 CustomAuthenticationEntryPointHandler가 동작해야합니다.

 

 

실패한 위 테스트를 다음과 같이 수정해줍니다.

@Test
void deleteUnauthorizedByRefreshTokenTest() throws Exception {
    // given
    Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
    SignInResponse signInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));

    // when, then
    mockMvc.perform(
            delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getRefreshToken()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/exception/entry-point"));
}

이제 /exception/entry-point로 리다이렉트되면, 인증되지않은 사용자에 대하여 올바른 처리가 수행된 것입니다.

 

 

 

다시 모든 테스트를 수행해보도록 하겠습니다.

모든 테스트 성공

모든 테스트가 성공적으로 수행되었습니다.

 

 

포스트맨을 이용하여, 토큰을 재발급하는 API를 직접 요청해봅시다.

자동화된 테스트도 좋지만, 한번씩 눈으로 확인하는 과정도 좋다고 생각합니다.

로그인 성공 이미지

회원가입된 사용자로 로그인해서 토큰을 발급 받습니다.

주어진 리프레시 토큰으로, 액세스 토큰을 재발급 받아보도록 하겠습니다.

 

 

누락된 헤더 요청 실패

어드바이스가 잘 동작하는지, 누락된 헤더로 요청을 보내보았습니다.

상태 코드 400을 응답 받고, 누락된 헤더가 무엇인지 알게되었습니다.

정상적인 요청을 다시 보내겠습니다.

 

 

토큰 재발급 성공

리프레시 토큰으로, 토큰 재발급 요청을 보냈습니다. (TYPE이 Bearer Token으로 설정되어있기 때문에 접두어 Bearer는 자동으로 추가되므로, 우리의 요청 문자열에서는 제거하고 전송해주면 됩니다.)

상태 코드 200을 응답받고, 새로운 액세스 토큰을 발급 받을 수 있었습니다.

 

 

토큰 인증 방식에 대해 몇 가지 논의를 해보겠습니다.

이 부분은, 주관적으로 작성되었기 때문에 그냥 넘어가셔도 좋습니다.

 

***

- 리프레시 토큰이 정말 필요한 것일까?

우리의 서비스에서는, 일반적으로 알려진 토큰 인증 방식에 따라 두 가지 종류의 토큰(액세스 토큰과 리프레시 토큰)을 발급하고 있습니다.

하지만 개인적으로, 리프레시 토큰이 꼭 필요할 것인지 의문입니다.

액세스 토큰은 사용자를 인증 및 인가하는 용도로 사용됩니다.

액세스 토큰은 API 요청에 포함되어야하므로 네트워크를 자주 오가게 되며, 이로 인해 탈취될 염려가 있으므로 짧은 만료 시간을 가지게 된다고 알려져 있습니다.

짧은 만료 시간으로 인한 재로그인 문제를 해결하기 위해, 리프레시 토큰을 이용하여 액세스 토큰을 재발급 받는 과정이 필요하게 되는 것이고요.

이러한 까닭에 우리는, 토큰을 두 종류로 나눈 대표적인 이유를 다음과 같은 두 가지로 판단하여 논의해보겠습니다.

1. 보안 - 토큰의 탈취 염려

2. 편의성 - 사용자 재로그인으로 인한 사용성 저하

 

먼저 보안 관점에서 살펴보겠습니다.

우리는 보통 http 프로토콜을 그대로 사용하지 않고, 보안을 위해 https를 이용합니다. (http를 이용한다면, 어차피 다양한 방법으로 손쉽게 토큰을 탈취할 수 있으니 논의에서 배제하도록 하겠습니다.)

https 방식에서는 응용 계층과 전송 계층 사이에 보안을 위한 Secure Socket Layer(SSL 또는 TLS)를 별도로 구축하고, 공개키 방식과 발급된 인증서를 통해 서버와 클라이언트 간에 암호화 키를 형성하게 됩니다.

이로 인해 서로 통신되는 모든 데이터는 암호화됩니다.

 

그런데, https를 이용하면 요청으로 전달되는 토큰(또는 데이터)은 전송되기 전에 암호화되는데, 네트워크를 통한 전송 과정에서 토큰이 정말 탈취될 수 있냐는 것입니다.

https로 해도 네트워크 전송 과정에서 탈취 당하는 것이라면, 이 암호화된 데이터를 복호화할 수 있다는 것인데, 그럴 가능성은 현저히 낮습니다.

만약 액세스 토큰이 탈취 당한 것이라면, 네트워크 전송 과정에서의 문제가 아니라 클라이언트 단의 문제로 인해 탈취 당했을 가능성이 높다는 것입니다.

하지만 클라이언트는 액세스 토큰과 리프레시 토큰을, 각자의 로컬 저장소(쿠키든, 다른 스토리지든) 어딘가에 저장해두었을 것입니다.

그렇다면, 액세스 토큰이 탈취당한다는 것은, 리프레시 토큰 또한 탈취 당한다는 것과 동일합니다. 

두 토큰을 서로 다른 저장소에 저장해둔게 아니라면 말이죠. (클라이언트에서 토큰을 위한 안전한 저장소의 선택지가 많지 않기 때문에, 두 토큰을 서로 다른 저장소에 저장하는 경우가 많을지는 잘 모르겠습니다.)

결국 네트워크 전송 과정에서의 보안적인 문제로는, 토큰을 두 종류로 구분지었을 때에도 별다른 이점을 취할 수 없다는 것입니다.

 

그렇다면 이번에는, 토큰을 두 종류로 구분한 것을 편의성 관점에서 보겠습니다.

토큰을 두 종류로 나눈 이유를 네트워크 전송 과정에서의 보안 문제를 원인으로 본다면, 위에서 서술된 이유로 인해 토큰을 두 종류로 구분지을 필요는 없습니다.

그렇다면 토큰을 두 종류로 나눈 이유로 남는 것은, 토큰을 재발급하여 사용자의 편의성을 올려주기 위함입니다.

리프레시 토큰의 용도는, 짧은 만료 기한의 액세스 토큰을 재발급하기 위함입니다.

이로 인해 사용자는 재로그인 과정을 직접 경험하지 않아도 됩니다.

그런데 단순히 재로그인에 대한 편의성이 목적이라면, 토큰을 굳이 두 종류로 구분지을 필요가 없습니다.

사용자의 편의성을 유지하고자 한다면, 차라리 그냥 액세스 토큰의 만료 기한을 길게 잡거나, 액세스 토큰이 리프레시 토큰의 역할도 할 수 있으면 됩니다.

 

하지만 이렇게 한 종류의 토큰 방식을 채택할 경우에, 또 다른 보안적인 문제가 생길 수 있습니다.

사용자의 액세스 토큰이 탈취 당한 상황을 가정해보겠습니다.

액세스 토큰을 탈취한 공격자는, 이를 이용하여 요청을 보내더라도, 서버는 탈취된 토큰인지 판별하지 못할것이고, 상황에 따라(액세스 토큰으로 계속해서 만료기한을 연장할 수 있는 경우) 무한정 사용할 수 있는 액세스 토큰을 얻게 됩니다.

이러한 상황을 방지하려면 어떻게 해야될까요?

 

단순히 액세스 토큰의 만료 기한을 길게만 설정했다면, 자연스럽게 이러한 상황을 방지할 수 있습니다.

토큰의 유효 시간이 만료 된다면, 자연스럽게 탈취 당한 토큰은 무효화될 것입니다.

하지만 기존의 토큰을 이용하여 새로운 토큰을 재발급할 수 없기 때문에, 토큰이 만료되었을 때마다 로그인하는 과정이 필요하므로 편의성 측면에서 약간의 손해를 감수하게 됩니다.

이러한 상황에 대응할 수 있는 적절한 만료 기한이 필요하겠고요.

 

이번에는 액세스 토큰을 이용하여 토큰을 재발급할 수 있다면 어떻게 될까요?

사용자는 기존의 토큰을 이용하여 새로운 토큰을 재발급할 수 있기 때문에, 토큰이 만료되더라도 로그인하는 과정이 필요하지 않으므로 편의성 측면에서 이점을 얻게 됩니다.

하지만 이를 탈취당하였을 경우, 공격자는 탈취한 토큰으로 무한정 만료 기한을 연장할 것이고, 기존에 탈취 당한 토큰을 무효화시킬 수 없게 됩니다.

이를 해결하기 위해서는, 서버는 각 사용자에게 단일한 토큰이 발급되었음을 보장하고, 이를 구분할 수 있어야합니다.

서버에서 별도의 데이터베이스(RDB, 메모리 DB 등)를 이용하여 토큰의 상태를 유지해야하는 것입니다.

 

즉, 상태 유지 여부에 따라서 두 토큰 발급 방식의 편의성과 보안 특성이 달라지게 된다는 것입니다.

 

표로 나타내면 다음과 같습니다.

  상태를 유지하는 경우 무상태를 유지하는 경우
두 종류의 토큰을 발급 보안 - 각 사용자에게 발급된 토큰에 대해 서버가 보장해주기 때문에, 토큰이 만료되기 전에 문제를 해결할 수 있다.
편의성 - 기존의 토큰으로 새로운 토큰을 무한정 재발급할 수 있다.
보안 - 각 사용자에게 발급된 토큰에 대해 서버가 보장해주지 못하기 때문에, 토큰이 만료되어야 문제를 해결할 수 있다. 적절한 토큰 유효 기간 설정이 필요하다.
편의성 - 리프레시 토큰이 만료되기 전까지는 새로운 토큰을 무한정 재발급할 수 있지만, 리프레시 토큰이 만료된 이후에는 더 이상 토큰을 재발급할 수 없다.
한 종류의 토큰을 발급 보안 - 각 사용자에게 발급된 토큰에 대해 서버가 보장해주기 때문에, 토큰이 만료되기 전에 문제를 해결할 수 있다.
편의성 - 하나의 토큰으로 관리할 수 있다. 기존의 토큰으로 새로운 토큰을 무한정 재발급할 수 있다.
보안 - 각 사용자에게 발급된 토큰에 대해 서버가 보장해주지 못하기 때문에, 토큰이 만료되어야 문제를 해결할 수 있다. 적절한 토큰 유효 기간 설정이 필요하다.
편의성 - 하나의 토큰으로 관리할 수 있다. 단, 기존의 토큰으로 새로운 토큰을 재발급할 수는 없다. 재로그인을 방지하려면, 단지 토큰의 만료 기한을 늘려주어야한다.

* 보안 : 토큰 탈취 측면

* 편의성 : 재로그인 방지 측면

 

이를 통해 도출할 수 있는 몇 가지 내용은 다음과 같습니다.

- 편의성 관점에서는 토큰을 하나만 사용하는 것이 더 편리하다. 적절한 만료 기한을 설정할 수 있다면, 두 종류를 사용한다고 해서 얻을 수 있는 특별한 이점은 없기 때문이다.

- 편의성을 더욱 높이기 위해서는, 기존의 토큰으로 새로운 토큰을 무한정 재발급할 수 있는, 상태를 유지하면서 한 종류의 토큰을 사용하는 방법을 선택할 수 있다.

- 보안 관점에 대한 차이는, 토큰의 개수가 아니라 상태 유지 여부에 따라서 구분지어진다.

- 상태를 유지한다면 서버가 할 일은 늘어나지만, 탈취된 토큰을 무효화시킬 수 있기 때문에 보안 관점에서 더 좋다.

- 상태를 유지하지 않는다면 서버가 할일은 줄어들지만, 탈취된 토큰의 만료 기한을 기다려야하기 때문에 보안 관점에서 더 좋지 않다.

- 토큰의 개수에 따라서는, 서로 간의 장점과 단점이 명확하게 구분지어지진 않는다.

- 상태 유지 여부에 따라서 장단점이 결정되는 것이 더 크다.

 

결론적으로,

더욱 뛰어난 편의성 및 보안을 위해 상태 유지를 감수한다면, 굳이 두 종류의 토큰을 택할 필요는 없습니다. 한 종류의 토큰으로도 충분합니다.

적당한 편의성 및 보안을 위해 무상태를 유지할 것이라면, 개인의 선택에 따라 토큰의 개수를 결정할 수 있습니다.

네트워크 전송 과정이 여전히 의심된다면, 두 종류의 토큰 방식을 채택하면 될 것이고,

https를 통한 네트워크 전송 과정을 신뢰할 수 있다면, 한 종류의 토큰 방식을 채택해도 됩니다.

적절한 토큰 만료기한을 설정한다면 편의성에서는 큰 차이가 없을 것이라고 여겨지지만, 결국 보안성을 위해 상태 유지 여부를 선택해야하는 것이 가장 큰 차이입니다.

상태 유지가 된다면, 두 개의 토큰을 사용할 이유는 자연스레 없어지게 됩니다.

물론, 여기에서도 네트워크 전송 과정이 여전히 의심된다면, 두 종류의 토큰 인증 방식을 사용해도 됩니다.

하지만 https를 이용한 암호화와 상태 유지로 인한 발빠른 대응이 가능한데도, 두 종류의 토큰 인증 방식을 채택할 이유가 있을지는 잘 모르겠습니다.

 

즉, 우리는 토큰 인증 방식을 결정함에 있어서 그 종류의 개수보다는, 상태를 유지할 수 있는가에 대해서 검토해야한다는 것입니다. 

특별한 이유가 없다면, 대부분의 경우에서 하나의 토큰으로도 충분하다고 봅니다.

 

기억해야할 것은, 상태를 유지한다는 것은 토큰 인증 방식의 장점을 깨트릴 수도 있다는 것입니다.

 

여기서 언급된 내용은, 두 종류의 토큰 상태를 모두 유지해야 한다는 것은 아닙니다.

리프레시 토큰만 상태를 유지해도 충분할 것입니다.

 

우리의 서비스는 무상태를 유지하면서 두 종류의 토큰을 채택한 상황이고, 현 상태를 유지하도록 하겠습니다.

 

* 여기에 작성된 내용은 모두 개인적인 견해입니다.

***

 

***

- 클라이언트에서 토큰의 저장 위치?

우리의 서비스에서는, 사용자가 로그인을 하게 되면 JSON 형태로 토큰을 응답해주고 있습니다.

그렇다면, 클라이언트에서는 이러한 토큰을 안전한 저장소에 보관해두었다가, 요청을 전송할 때마다 HTTP 헤더에 담아주어야합니다.

여기서 말하는 안전한 저장소는 어디일까요?

클라이언트는 안전한 저장소에 대한 선택지로, 로컬 스토리지와 같은 저장소나 쿠키를 이용할 수 있습니다.

 

이전에 진행했던 프로젝트(KUKE meet)에서는, 쿠키를 이용하여 토큰을 발급해줬습니다.

서버에서 응답해줄 때, 응답 헤더에 Set-Cookie를 domain, secure, httpOnly 등의 옵션을 설정하여 쿠키로 응답해준 것입니다.

이를 통해 지정해둔 도메인에서만 쿠키를 사용할 것이고, 자바스크립트를 이용한 쿠키 조작은 불가능하며, https에서만 쿠키가 전송될 수 있게 됩니다.

이에 대한 방식에는 크게 문제될 것이 없어보이지만, 몇 가지 문제가 있었습니다.

브라우저를 이용한 요청에는 지속적으로 저장된 쿠키가 같이 전송됩니다.

하지만 이렇게 되면, 어차피 브라우저에 저장된 쿠키를 통해서 토큰이 전달되기 때문에, 토큰을 담기 위해 굳이 HTTP 헤더를 사용할 필요가 없게 되는 것입니다.

토큰 전달을 위해 사용하고 있던 Authorization 헤더의 역할은 무색해지고, 토큰이 필요없는 요청에도 토큰을 포함하게 됩니다.

만약 두 종류의 토큰을 발급하였다면, 실질적으로 토큰 재발급 API에만 필요한 리프레시 토큰이 계속해서 모든 요청에 포함되어야하기 때문에 네트워크 대역폭을 낭비하게 되는 것입니다.

그렇다고 다른 스토리지에 저장된다면, 다양한 공격에 노출될 위험이 있습니다.

또, 안드로이드와 같은 모바일 앱 환경인 경우 쿠키를 이용하는데 어려움이 생길 수 있습니다. (사실 모바일 앱 환경은 제대로 경험해본적이 없기 때문에 잘 모르겠습니다.)

 

하지만 그렇다고 해도, 쿠키를 이용한 방식이 제일 무난하고 안전하다는 것이 개인적인 생각입니다.

다양한 옵션을 통해 쿠키를 제어할 수 있고, 클라이언트에서는 안전한 저장소에 대한 선택지가 그리 많지 않기 때문입니다.

우리의 서비스에서는 JSON 형태로만 응답하긴 하지만,

결국 이를 클라이언트에 저장하기 위한 방법에는, 응답 헤더에 Set-Cookie 설정을 해주는 방식도 있다는 것을 말씀드리기 위해 가볍게 언급하였습니다.

***

 

***

- 클라이언트는 어떻게 리프레시 토큰을 이용한 토큰 재발급을 수행해야할까?

이전에 진행했던 프로젝트에서는, 토큰 만료로 인하여 요청이 실패되었을 경우에는, 콜백으로 토큰 재발급을 요청하여 새로운 토큰을 발급받았었습니다.

하지만 이 방법에는 문제가 있었습니다.

토큰을 재발급해야하는 시점에는, 하나의 API를 요청하는 과정에 3번의 요청이 연쇄적으로 일어난다는 것입니다.

1. 필요한 API 요청

2. 토큰 만료로 인한 요청 실패 응답

3. 콜백으로 토큰 재발급 API 요청

4. 새롭게 발급받은 토큰 응답

5. 콜백으로 원래 필요하던 API 요청

6. 필요한 API 응답

 

이를 해결하기 위한 방법으로는, 클라이언트에서 토큰의 만료 시간을 확인하여 setTimeOut 등의 방식으로 직접 콜백을 등록해주는 방식도 있겠습니다.

하지만 브라우저에서의 사이트 이동이나, 브라우저를 껐다 킨 상황 등의 이벤트를 구체적으로 잡아내지 못한다면, 콜백이 중복으로 등록되거나 의도하지 않은 동작이 생기게 될 문제가 있다고 판단하여, 세 번의 요청을 거치더라도 확실하게 토큰을 재발급받는 식으로 구성을 했었습니다.

물론, 개인적인 판단으로 구현했던 방식이기 때문에(브라우저의 이벤트 방식에 대해서 정확히 모르기도 하고), 어떤 것이 정답이라고 단언할 수는 없겠습니다.

***

 

* 로그인 기능은 35편에서 더욱 개선되었습니다.

2022.01.04 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (35) - 토큰 인증 방식 수정

필수 사항은 아니며, 선택적으로 적용하시면 됩니다.

이후의 과정에는 전혀 지장이 없습니다.

 

* 로그인 기능은 37편에서 더욱 개선되었습니다.

2022.01.08 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (37) - API 접근 제어 방식 수정

필수 사항은 아니며, 선택적으로 적용하시면 됩니다.

이후의 과정에는 전혀 지장이 없습니다.

 

 

우리는 이번 시간에 토큰 재발급을 위한 API를 작성했을 뿐만 아니라, Spring Security에서의 사용자 인증 요건 또한 수정하였습니다.

액세스 토큰으로 요청한 사용자인지 검사해야하는 불필요한 과정을 제거하여, 코드와 로직을 단순화시킬 수 있었습니다.

Security에 대한 설정의 변경으로 인해 API 요청에 연쇄적으로 커다란 변화가 생길 수 있었지만,

세밀하게 작성해둔 테스트로 인해 수정된 로직을 빠른 시간 내에 검증할 수 있었습니다.

단지 모든 테스트를 수행하고, 실패한 테스트를 확인하여 다시 작성해줄 뿐이었습니다.

 

앞으로 프로젝트를 진행하다보면, 변경이 필요한 사항이 다시 생길 수도 있습니다.

변경은 위험한 작업입니다.

더욱이 여러 객체가 협력하고 있는 상황이라면, 변경으로 인한 여파가 어디까지 전파될지 모릅니다.

하지만 우리는 자동화된 테스트와 함께 프로젝트를 진행하고 있습니다.

변경이 있더라도, 우리의 코드를 검증해줄 테스트가 있습니다.

우리는 그저, 테스트를 수행하여 변경으로 인한 여파를 감지하고, 그에 대응해주면 됩니다.

테스트로 인해 더이상 변경이 두렵지 않게 되었고, 우리의 코드는 언제든 손쉽게 검증해낼 수 있습니다.

이를 위해 새로운 기능을 작성하더라도, 테스트 작성을 게을리하지 않겠습니다. 

 

로그인 기능이 드디어 마무리 되었습니다.

정말 기나 긴 과정이었고, 설계에 대한 논의도 여러 번 거쳐왔습니다.

당장의 변경이 필요한 사항이 있다면, 그에 대해 망설임 없이 변경하고 개선해왔습니다.

 

지금 우리는, 총 5개의 API를 작성하였습니다.

아직 몇 개의 API 밖에 없지만, 앞으로 API는 계속해서 추가될 것입니다. 

이에 미리 대응하기 위해, 다음 시간에는 API 문서를 작성하는 시간을 가져보도록 하겠습니다.

 

* 정말 개인적인 견해가 많이 들어간 내용이라, 오해나 문제의 소지가 있다면 삭제하도록 하겠습니다. 지적 부탁드립니다.

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

 

반응형

+ Recent posts