[Oauth] AccessToken, RefreshToken 눈물의 테스트 작성기 (2 / 2)
글이 길어져서 포스팅을 두 편으로 나눴습니다.
🚀 이번 포스팅에서 알아갈 것
- AccessToken
- Mocking
- Oauth 인수테스트
✅ 테스트 진행 방식
- 인수테스트로 진행한다.
- RestAssured를 이용한다.
- 외부에 의존되는 부분은 Mocking 한다.
💬 상황
프로젝트를 진행하면서 Oauth2.0을 이용한 깃허브 소셜 로그인 구현을 맡게 되었다.
기능 구현에 초점을 두었고 먼저 RefreshToken 없이 AccessToken의 시간을 무려 한 달이라는 시간동안 지속되도록 했다.
AccessToken을 짧게 30분, 1시간 유지되도록 할 수 있었지만 사용자가 없었던 개발 단계였기 때문에 길게 잡아도 큰 문제가 되지는 않았다. 또한 HTTPS를 적용했으니 큰 문제가 생각하고 싶기도 했지만 이 중요한 AccessToken이 계속해서 네트워크를 타고 다닌다는 것은 당연히 보안상 문제가 될 수 있는 부분이었고 이러한 보안 문제를 해결하기 위해서 RefreshToken을 도입하기로 한다.
먼저 'AccessToken 성공 시나리오',
'AccessToken 만료, 실패 시나리오' 를 보도록 하자.
🎥 AccessToken 발급 및 사용자 요청 성공 시나리오
AccessToken을 발급받고 사용하는 그림은 아래 플로우와 같다.
사용자가 서버에게 로그인을 요청했을 때 서버는 깃허브 페이지로 redirect하도록 했다.
사용자는 깃허브 페이지에서 로그인을 진행하면 깃허브에서 AuthCode(Authentication Code)를 사용자에게 응답한다.
사용자에는 응답받은 AuthCode를 서버에게 전송하게 된다.
AuthCode를 받은 서버는 깃허브에게 AuthCode과 함께 사용자 정보와 AccessToken을 요청하고 깃허브로부터 응답받는다.
서버는 사용자가 인증할 수 있는 AccessToken을 암호화하여 JWT(Json Web Token)으로 만들어 사용자에게 전송한다.
사용자는 앞으로 JWT(AccessToken)을 통해서 서버에게 자신임을 증명하게 된다.
사용자는 JWT(AccessToken)과 함께 서버에게 요청을 보낸다.
서버는 JWT를 받아 디코딩과 동시에 검증을 진행한다.
JWT가 유효하다고 판단될 경우 요청을 성공적으로 진행하게 된다.
🎥 AccessToken 만료, 실패 시나리오
사용자가 만료된 JWT(AccessToken)을 이용해서 서버에게 요청했을 때 실패하게 된다.
즉, 만료된 JWT를 가졌다는 의미를 로그아웃 된 것이라고 생각해도 좋을거 같다.
만료될 때마다 사용자가 로그아웃되어 재로그인 해야하는 불편함이 있을 수 있지만 짧은 유효기간을 갖음으로서 탈취되더라도 이미 만료된 JWT를 탈취할 가능성이 크다.
당연하게도 이러한 사용자의 불편함을 해소하기 위해 RefreshToken이라는 개념을 도입하게 된다.
AccessToken의 유효기간은 30분 ~ 1시간 정도.
유효기간이 만료된 AccessToken은 사용할 수 없다.
30분마다 사용자가 새로운 AccessToken을 요청하기는 서비스가 불편해진다.
그래서 RefreshToken 이라는 개념이 나오게된다.
🎥 RefreshToken을 이용한 AccessToken 재발급
사용자가 JWT를 포함하여 요청을 보냈을 때 서버는 JWT가 만료되었는지 검증한다.
서버가 JWT가 만료되었음을 확인하고 JWT 만료 했음을 사용자에게 응답한다.
사용자는 가지고 있던 JWT(RefreshToken)으로 새로운 JWT(AccessToken)을 서버에게 요청한다.
서버에서는 받은 JWT(RefreshToken)을 디코딩하여 검증한 후 redis에 refreshToken을 키로 사용하여 값을 조회한다.
조회된 값(memberId)를 다시 JWT(AccessToken)로 만들어 사용자에게 응답한다.
사용자는 새로 발급된 JWT(AccessToken)을 가지고 다시 서버에게 정상적인 요청을 할 수 있다.
먼저 RefreshToken이 없었을 때 기존 소셜 로그인 로직 흐름으로 작성하려고 한다.
실제 코드는 아래 깃허브 링크를 참조하길 바란다.
🟩 흐름) 기존 소셜 로그인 구현 (RefreshToken 없을 때)
01. Redirect 주소
먼저 사용자가 회원가입을 요청하면 해당 소셜에 해당하는 주소로 redirect하도록 서버에서 구현했다.
여기서 소셜 redirect 주소는 'AuthCodeRequestUrlProviderComposite'에서 받을 수 있다.
'AuthCodeRequestUrlProviderComposite'는 사용자로부터 받은 소셜 타입을 가지고 해당하는 redirect 주소를 응답한다.
02. 서버에 AuthCode 전달
사용자는 redirect된 소셜 로그인 페이지에서 로그인을 성공하면 소셜 서버로부터 'AuthCode'를 응답받는다.
사용자는 응답받은 'AuthCode'를 서버에게 전송한다.
03. AuthCode를 소셜 서버에 전달하여 AccessToken 요청
서버는 사용자로부터 받은 'AuthCode', '소셜 타입'을 가지고 'OauthClientComposite'에 접근한다.
그리고 'HttpInterface'를 이용해서 해당하는 소셜 서버에 접근하여 'AccessToken'을 요청한다.
04. AccessToken으로 소셜 서버에 사용자 정보 요청
'AccessToken'을 응답 받는다면 서버는 'HttpInterface'와 'OauthConfig' 이용해서 'AccessToken'을 가지고 소셜 서버에 사용자 정보를 요청한다.
05. 사용자 정보 저장 및 토큰 응답
서버는 사용자 정보를 받아 데이터베이스에 저장하고 사용자에게 전달해줄 memberId를 인코딩하여 JWT로 만들어 응답한다.
이 때 JWT 만료 기간을 30분으로 설정한다.
사용자는 서버로부터 받은 JWT를 저장하고 있다가 필요할 때 JWT를 함께 서버에 전송하여 자원을 요청하게 된다.
맛난 테스트를 작성해볼 때가 왔다.
😋😋 소셜 로그인을 Mocking하여 테스트를 진행해보자. 😋😋
🟩 테스트) 그래서 뭘 Mocking 해야할까?
Mocking이 필요한 이유는 외부 소셜 서버에 의존하지 않기 위해서이다.
즉, 외부와의 의존이 강한 부분을 Mocking하면 된다.
Mocking 할 부분
- AuthCodeRequestUrlProviderComposite
- OauthClientComposite
- JwtConfig
위 세 가지만 Mocking하면 된다.
그 이유는 아래에서 진행하면서 설명려고 한다.
🟩 Mocking) AuthCodeRequestUrlProviderComposite에 대한 설명
조금 더 자세한 사항을 보여주기 위해 다이어그램을 펼쳐봤다.
Github와 같이 외부에 의존되는 곳은 신경쓰지 않는 방향으로 진행하려 한다.
(* 물론 외부 api 테스트도 중요하지만 지금은 다루지 않으려고 한다.)
💬 <<Enum>> OauthType 그리고 <<Interface>> AuthCodeRequestUrlProvider
먼저 AuthCodeRequestUrlProviderComposite을 살펴보자.
필드로 Map이 선언되어 있고 key는 소셜 타입(OauthType)을 value는 redirectUri(AuthCodeRequestUrlProvider)를 갖는다.
이 부분은 소셜 타입(OauthType)을 통해서 해당하는 소셜의 RedirectUrl이 나오도록 하기 위함이다.
💬 신경쓰지 않을 클래스; GithubAuthCodeRequestUrlProvider 그리고 GithubOauthConfig
GithubAuthCodeRequestUrlProdvider, GithubOauthConfig는 테스트에서는 현재 테스트에서는 사용하지 않을 것이다.
AuthCodeRequestUrlProvider를 Mocking 해서 진행해도 되지만 여러 상황에 대한 ReuqestUrl을 받을 것이 아니기 때문에 AuthCodeRequestUrlProviderComposite을 Mocking해서 단순화하는 것을 선택했다.
💬 AuthCodeRequestUrlProviderComposite은 RedirecUrl을 반환한다.
@Component
public class AuthCodeRequestUrlProviderComposite {
private final Map<OauthType, AuthCodeRequestUrlProvider> providers;
public AuthCodeRequestUrlProviderComposite(final Set<AuthCodeRequestUrlProvider> providers) {
this.providers = providers.stream()
.collect(toMap(AuthCodeRequestUrlProvider::oauthType, identity()));
}
public String findRequestUrl(final OauthType oauthType) {
return Optional.ofNullable(providers.get(oauthType))
.orElseThrow(OauthException.RequestUrl::new)
.requestUrl();
}
}
이제 우리는 AuthCodeRequestUrlProviderComposite에만 신경을 쓰면 되고 findRequestUrl() 기능을 Mocking 해서 단 테스트용 redirectUrl이 반환되도록 만들자.
🟧 @MockBean 대신 @Bean
@MockBean을 이용해도 좋지만 애플리케이션 컨텍스트가 계속해서 새로 띄어질 가능성이 있다.
대신해서 @Bean으로 Mock 객체를 등록하자.
🟧 중복된 빈 등록을 피하기 위한 @Profile
RestAssured로 테스트를 진행할 때 이미 등록된 상태라고 예외가 발생하게 된다.
이를 방지하기 위해서 AuthCodeRequestUrlProviderComposite에 @Profile("!test")를 붙여 스프링 프로필 변수가 test가 아닌 경우에만 빈으로 등록하게 한다.
@Profile("!test")
@Component
public class AuthCodeRequestUrlProviderComposite {
// ...
}
반대로 MockAuthCodeRequestUrlCompositeTestConfig는 스프링 프로필 변수가 test일 때 등록되도록 만들어준다.
실제 프로덕션 코드에 @Profile 어노테이션을 작성해야하는 문제가 이후에 구성 정보에 대한 복잡성을 증가시킬 수 있다.
때문에 테스트 코드에서 스캔을 할 때 제거하는 방식으로 진행해도 좋지만 지금은 @Profile로 진행하겠다.
🟧 테스트용 RedirectUrl이 반환되도록 Mocking한다.
그리고 어떠한 값이 들어가도 "https://test-redirect.test" 가 반환될 수 있도록 Mocking 한다.
@Profile("test")
@TestConfiguration
public abstract class MockAuthCodeRequestUrlCompositeTestConfig {
@Bean
public AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite() {
final var mock = Mockito.mock(AuthCodeRequestUrlProviderComposite.class);
when(mock.findRequestUrl(notNull())).thenReturn("https://test-redirect.test");
return mock;
}
}
🟩 Mocking) OauthClientComposite에 대한 설명
💬 <<Enum>> OauthType 그리고 <<Interface>> OauthClient
필드를 보면 Map이 있고 key로는 소셜 타입(OauthType)이 value로는 OauthClient가 존재한다.
OauthClient는 서버 내부에서 소셜 서버와 통신하여 AccessToken, 사용자 정보를 받아오기 위해 존재한다.
💬 신경쓰지 않을 클래스; OauthClient
소셜 서버와의 통신을 위해서 HttpInterface, Github Oauth 정보(clientId, clientSecret, ...)이 필요하고 Github의 경우 GithubClient라는 구현체를 만든 것을 확인할 수 있다.
그리고 소셜 서버로부터는 공통된 응답을 가져오도록 설정할 것이며 이를 위해 OauthInformation 인터페이스를 만들었다.
GithubHttpInterface 코드를 보면 아래와 같다.
테스트하면 좋겠지만 지금은 외부 API를 테스트하는 것이 아니기 때문에 신경쓰지 않아도 좋을거 같다.
public interface GithubHttpInterface {
@PostExchange(url = "https://github.com/login/oauth/access_token", accept = APPLICATION_JSON_VALUE)
GithubTokenResponse fetchToken(@RequestBody GithubTokenRequest request);
@GetExchange(url = "https://api.github.com/user")
GithubInformationResponse fetchInformation(@RequestHeader(name = AUTHORIZATION) String bearerToken);
}
💬 OauthClientComposite은 AccessToken과 사용자 정보를 반환한다.
OauthClientComposite의 코드는 아래와 같다.
위에서와 마찬가지로 소셜 타입(OauthType)으로 해당 소설 서버에 접근할 구성 정보(OauthConfig)와 HttpInterface가 존재하는 OauthClient를 불러오게 된다. 그리고 소셜 서버로부터 AccessToken과 사용자 정보를 담은 OauthInformation을 반환한다.
@Component
public class OauthClientComposite {
private final Map<OauthType, OauthClient> clients;
public OauthClientComposite(final Set<OauthClient> oauthClients) {
this.clients = oauthClients.stream()
.collect(toMap(OauthClient::oauthType, identity()));
}
public OauthInformation findOauthInformation(final OauthType oauthType, final String authCode) {
return Optional.ofNullable(clients.get(oauthType))
.orElseThrow(OauthException.OauthInformation::new)
.oauthInformation(authCode);
}
}
OauthInformation은 인터페이스로 코드는 아래와 같다.
public interface OauthInformation {
String accessToken();
String oauthId();
String name();
}
🟧 @MockBean 대신 @Bean
마찬가지로 @MockBean을 이용해도 좋지만 테스트 환경이 계속해서 새로 띄어질 가능성이 있다.
대신해서 @Bean으로 Mock 객체를 등록하자.
🟧 중복된 빈 등록을 피하기 위한 @Profile
테스트에서 OauthClientComposite도 중복된 빈으로 등록되어 예외가 발생하므로 @Profile을 이용한다.
@Profile("!test")
@Component
public class OauthClientComposite {
// ...
}
🟧 AccessToken과 사용자 정보가 반환되도록 Mocking하자.
MockOauthClientCompositeTestConfig을 테스트 쪽 패키지에 생성하자.
이제 findOauthInformation() 기능을 Mocking하면된다.
이 때 findOauthInformation() 파라미터에 어떤 값이 들어갔을 때 어떤 AccessToken과 사용자 정보가 반환되도록 설정할지 생각해보자.
public class OauthClientComposite {
// ...
public OauthInformation findOauthInformation(final OauthType oauthType, final String authCode) {
// ...
}
}
소셜 타입(OauthType)과 인증 코드(AuthCode)가 들어왔을 때 OauthInformation(AccessToken, 사용자 정보)를 반환하고 있다. 먼저 테스트용 사용자 두 명을 만들어서 등록해보는 식으로 하자.
AuthCode로 "member_auth_code"가 인자로 들어왔을 때 MockMemberInformation이 반환되도록
AuthCode로 "hyena_auth_code"가 인자로 들어왔을 때 MockHyenaInformation이 반환되도록 Mocking한다.
@Profile("test")
@TestConfiguration
public abstract class MockOauthClientCompositeTestConfig {
@Bean
public OauthClientComposite oauthClientComposite() {
final var mock = Mockito.mock(OauthClientComposite.class);
when(mock.findOauthInformation(OauthType.GITHUB, "member_auth_code")).thenReturn(new MockMemberInformation());
when(mock.findOauthInformation(OauthType.GITHUB, "hyena_auth_code")).thenReturn(new MockHyenaInformation());
return mock;
}
}
그리고 MockHyenaInformation 코드는 아래와 같다.
public class MockHyenaInformation implements OauthInformation {
@Override
public String accessToken() {
return "hyena_test_accessToken_hyena_test_accessToken_hyena_test_accessToken_hyena_test_accessToken";
}
@Override
public String oauthId() {
return "hyena_test_oauthId";
}
@Override
public String name() {
return "hyena_test_name";
}
}
🟩 Mocking) JwtConfig에 대한 설명
JwtConfig도 Mocking 해야한다.
현재 jjwt 라이브러리를 사용해서 JWT로 인코딩, JWT를 디코딩하고 있다.
이 때 SecretKey와 Issuer가 필요한데 프로덕션 코드에서는 이 변수를 외부 yml에서 관리하고 있다.
테스트를 위한 yml을 만들어서 진행할 수 있지만 테스트를 위한 가짜 값을 넣어진행하는 맥락이 같다고 느껴졌다.
위에서 만들어온 Mock들도 똑같이 등록될 수 있도록 관리할 구성 정보 클래스를 만들 것이고 JwtConfig도 Mock으로 만들어서 같은 곳에서 관리하게 되면 이후에 해당 부분도 Mocking하고 있다는 것이 더 알아채기 쉬울거라 판단했다.
JwtConfig는 아래 코드에서 알 수 있듯이 외부 환경 변수로 주입받아야 한다.
@RequiredArgsConstructor
@ConfigurationProperties("jwt.token")
public class JwtConfig {
private final String secretKey;
private final String issuer;
public Key getSecretKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String getIssuer() {
return issuer;
}
}
🟧 JwtConfig가 외부에서 변수를 받지 않게 Mocking하자.
JwtEncoder, JwtDecoder를 빈으로 등록할 때 JwtConfig에 미리 값을 넣어서 생성하면 되지만 Mock 클래스를 만들어서 생성하는게 의도를 전달하기 좋아보였다.
public class MockJwtConfig extends JwtConfig {
private static final String SECRET_KEY = "test_secret_key_test_secret_key_test_secret_key_test_secret_key_test_secret_key_test_secret_key";
private static final String ISSUER = "test";
public MockJwtConfig() {
super(SECRET_KEY, ISSUER);
}
}
그리고 기존에 JwtEncoder, JwtDecoder가 빈으로 등록되지 않도록 @Profile("!test")을 적용하자.
@Profile("!test")
@RequiredArgsConstructor
@Component
public class JwtEncoder {
// ...
}
@Profile("!test")
@RequiredArgsConstructor
@Component
public class JwtDecoder {
// ...
}
그리고 MockJwtTestConfig를 구현해서 Mocking한 JwtConfig를 주입해서 빈으로 JwtEncoder, JwtDecoder를 등록하자.
@Profile("test")
@TestConfiguration
public abstract class MockJwtTestConfig {
@Bean
public JwtEncoder jwtEncoder() {
return new JwtEncoder(new MockJwtConfig());
}
@Bean
public JwtDecoder jwtDecoder() {
return new JwtDecoder(new MockJwtConfig());
}
}
🟩 RestAssuredConfig) 인수 테스트용 구성, 커스텀 어노테이션
이제 계속해서 반복될 인수테스트의 코드를 줄이기 위해서 RestAssured에 필요한 기본 설정 파일인 RestAssruedTestConfig를 만들자.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class RestAssuredTestConfig {
@BeforeEach
void restAssuredSetUp(@LocalServerPort int port) {
RestAssured.port = port;
}
}
이제 인수테스트를 작성할 클래스에 RestAssuredTestConfig를 상속받은 후 진행하면 된다.
열심히 만든 빈(Mock) 구성 정보도 등록될 수 있도록 해야한다.
🟧 RestAssured용 커스텀 어노테이션
지금까지 Oauth를 이용한 로그인 때문에 수많은 구성 정보를 직접 만들어야 했다.
RestAssuredTestConfig에 MockOauthTestConfig를 extends해도 되지만 조금 더 Oauth 관련한 설정이 있다는 것을 눈에 보이게 하고 싶어서 커스텀 어노테이션인 AutoConfigureTestOauth를 구현했다.
@Import(MockOauthTestConfig.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ActiveProfiles
public @interface AutoConfigureTestOauth {
@AliasFor(annotation = ActiveProfiles.class, attribute = "value")
String[] value() default {"test"};
}
이제 RestAssuredTestConfig에 어노테이션을 달아주기만 하면 설정이 끝이난다.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@AutoConfigureTestOauth
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class RestAssuredTestConfig {
@BeforeEach
void restAssuredSetUp(@LocalServerPort int port) {
RestAssured.port = port;
}
}
🟩 테스트) 회원가입 후 본인 정보 조회 인수테스트 작성
이제 원하는 인수테스트를 작성하고 통과하면 끝!
class LoginAcceptanceTest extends RestAssuredTestConfig {
@Test
void 첫_로그인_시_자동으로_회원가입을_진행하고_응답받은_JWT로_본인_정보를_조회할_수_있다() throws Exception {
Oauth서버에_로그인을_요청을_성공한다();
var 액세스_토큰_응답 = AuthCode로_회원가입_혹은_로그인을_진행을_성공한다();
var 액세스_토큰 = 액세스_토큰.accessToken();
var 회원_단건_조회_응답 = 액세스_토큰으로_본인_회원_정보_조회를_성공한다(액세스_토큰);
}
void Oauth서버에_로그인을_요청을_성공한다() {
RestAssured
.given()
.pathParam("oauthType", GITHUB)
.log().all()
.when()
.redirects().follow(false)
.get("/oauth/{oauthType}")
.then()
.log().all()
.assertThat().statusCode(FOUND.value());
}
JwtResponse AuthCode로_회원가입_혹은_로그인을_진행을_성공한다() {
JwtResponse 액세스_토큰 = RestAssured
.given()
.pathParam("oauthType", GITHUB)
.queryParam("code", "hyena_auth_code")
.log().all()
.when()
.get("/oauth/new/{oauthType}")
.then()
.log().all()
.assertThat().statusCode(OK.value())
.extract().as(new TypeRef<>() {
});
assertSoftly(softly -> {
softly.assertThat(액세스_토큰).isNotNull();
softly.assertThat(액세스_토큰.accessToken()).isNotBlank();
});
return 액세스_토큰;
}
QuerySingleMemberResponse 액세스_토큰으로_본인_회원_정보_조회를_성공한다(final String 액세스_토큰) {
QuerySingleMemberResponse 회원_단건_조회_응답 = RestAssured
.given()
.auth().preemptive().oauth2(액세스_토큰)
.log().all()
.when()
.get("/members")
.then()
.log().all()
.assertThat().statusCode(OK.value())
.extract().as(new TypeRef<>() {
});
assertSoftly(softly -> {
softly.assertThat(회원_단건_조회_응답).isNotNull();
softly.assertThat(회원_단건_조회_응답.memberId()).isNotNull();
softly.assertThat(회원_단건_조회_응답.name()).isNotBlank();
});
return 회원_단건_조회_응답;
}
}
다음 포스팅과 이어집니다!