티스토리 뷰
안녕하세요! 오랜만에 이슈 해결 회고 글을 작성하는 것 같습니다.
사실은 써야지 써야지하면서 치일피일 미루다가 영원히 미뤄지겠단 생각에 드디어 글을 작성하게 되었습니다.
문제점
새로운 프로젝트에서 회원 및 인증 도메인을 담당하게 되었고, 그 중 소셜 로그인 개발을 진행하면서 발생한 문제입니다.
소셜 로그인을 진행하기 위해서는 각 플랫폼 서버(네이버, 카카오 등)로부터 client-id
와 client-secret
값을 얻게 되는데, 이는 외부에 노출되면 위험한 식별값입니다. 그렇기 때문에 클라이언트에서 SDK로 관리하며 통신하는 방식보다는 서버에서 관리하는 것이 좀 더 안전하겠다는 팀 내부의 니즈에 맞춰 authorization-grant-type: authorization_code
인증 방식으로 진행하기로 하였습니다.
스프링 프로젝트에 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
의존성을 추가한 뒤, 소셜 로그인을 진행하면 OAuth2UserService
의 loadUser()
메서드를 통해서 OAuthUser(이름, 이메일 등이 포함된)
타입 객체를 받아올 수 있게 됩니다.
제일 먼저 카카오 로그인 개발을 진행하기 위해 카카오 개발자센터에서 설정을 마치고, 로컬 환경에서 로그인을 시도하자마자 반겨주는 것은 401 에러였습니다..
개선
기존에 Security Config
파일에서 확장하던 WebSecurityConfigurerAdapter
는 5.7.0-M2버전부터 deprecated 되었기 때문에 filterChain
을 @Bean
으로 등록해서 설정하였습니다.
이때, @Bean
으로 등록된 filterChain
의 파라미터로 넘어오는 HttpSecurity
를 이용해서 시큐리티 설정이 이루어지는데, oauth2Login()
메서드가 반환하는 OAuth2LoginConfigurer
의 userInfoEndpoint()
에 커스텀한 customOAuthUserService
를 등록하고 리소스 서버로부터 사용자 정보를 받아오고 있습니다.
httpSecurity
.oauth2Login()
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuthUserService))
.successHandler(oAuth2AuthenticationSuccessHandler)
@Slf4j
@RequiredArgsConstructor
@Service
public class CustomOAuthUserService extends AbstractOAuth2UserService
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserAuthProviderService userAuthProviderService;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
ClientRegistration clientRegistration = userRequest.getClientRegistration();
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User existingOAuth2User = oAuth2UserService.loadUser(userRequest);
ProviderUser providerUser = super.providerUser(clientRegistration, existingOAuth2User);
String registrationId = providerUser.getProviderId();
log.info("oauthUser=[{}][{}][{}]", registrationId, providerUser.getName(), providerUser.getEmail());
AuthProvider authProvider = AuthProvider.valueOf(registrationId.toUpperCase());
return userAuthProviderService.toOAuth2User(providerUser.getId(), authProvider);
}
}
참고로 OAuth 프로토콜은 대략적으로 아래와 같이 인증이 이루어지게 됩니다.
- 클라이언트는 플랫폼(네이버, 카카오 등) 인증 서버로 로그인 페이지를 요청합니다.
- 요청받은 로그인 페이지에 아이디, 비밀번호를 입력하고 인증 서버로 전달하면, Authoization code를 반환받습니다.
- 반환받은 Authoization code와 함께 grant-type, client-id 등을 파라미터에 포함하여 인증 서버로 전달하면, 액세스 토큰을 반환받습니다.
- 액세스 토큰으로 리소스 서버로부터 사용자 정보(
OAuthUser
)를 받아오게 됩니다.
여기서 저는 4.
번에서 OAuthUser
를 받아오기 전에, 3.
번에서 이루어지는 “액세스 토큰을 반환 받는 메서드는 대체 무엇일까?“ 하는 의문점이 들기 시작했습니다.
궁금증을 해결하기 위해 OAuth2LoginConfigurer
내부로 들어가서 확인하던 중, init
메서드에서 accessTokenResponseClient
를 초기화하고 있는 모습을 확인했고, tokenEndpoint()
메서드를 통해 accessTokenResponseClient
구현체에서 액세스 토큰을 얻어오는 것을 확인할 수 있었습니다.
곧바로 accessTokenResponseClient
의 구현체인 DefaultAuthorizationCodeTokenResponseClient
에서 implements
하고 있는OAuth2AccessTokenResponseClient
를 implements
하는 커스텀 클래스를 만들고, getTokenResponse
메서드 내부에서 액세스 토큰을 얻기 위한 파라미터에 client-secret
값을 포함하도록 코드를 작성하였습니다.
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) {
ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration();
HttpHeaders headers = generateHeaders();
MultiValueMap<String, String> param = generateParam(clientRegistration, authorizationGrantRequest);
URI uri = getUri(authorizationGrantRequest);
RequestEntity<?> requestEntity = new RequestEntity<>(param, headers, HttpMethod.POST, uri);
ResponseEntity<OAuth2AccessTokenResponse> response = getResponse(requestEntity);
return response.getBody();
}
private URI getUri(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) {
return UriComponentsBuilder
.fromUriString(authorizationGrantRequest.getClientRegistration().getProviderDetails().getTokenUri())
.build().toUri();
}
private MultiValueMap<String, String> generateParam(
ClientRegistration clientRegistration,
OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest
) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add(GRANT_TYPE, clientRegistration.getAuthorizationGrantType().getValue());
params.add(CLIENT_ID, clientRegistration.getClientId());
params.add(CLIENT_SECRET, clientRegistration.getClientSecret());
params.add(CODE, authorizationGrantRequest.getAuthorizationExchange().getAuthorizationResponse().getCode());
return params;
}
private HttpHeaders generateHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.add(CONTENT_TYPE, this.contentType);
return headers;
}
private ResponseEntity<OAuth2AccessTokenResponse> getResponse(RequestEntity<?> request) {
try {
return this.restOperations.exchange(request, OAuth2AccessTokenResponse.class);
} catch (RestClientException ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
+ ex.getMessage(),
null);
throw new OAuth2AuthorizationException(oauth2Error, ex);
}
}
그리고 filterChain
에 커스텀 클래스를 추가하였습니다.
httpSecurity
.oauth2Login()
.tokenEndpoint(tokenEndpoint -> tokenEndpoint.accessTokenResponseClient(new CustomOAuth2TokenResponseClient()))
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuthUserService))
.successHandler(oAuth2AuthenticationSuccessHandler)
해당 코드를 추가하고 카카오 로그인을 진행하자 아래와 같이 파라미터에 정상적으로 파라미터에 client-secret
값이 들어갔고 액세스 토큰을 가져온 뒤, 이슈(401 에러)없이 무사히 사용자 정보를 가져올 수 있었습니다. (네이버 로그인도 무사히 진행되는 모습을 확인했습니다 🙂)
경험을 통해 얻은 것
이전 회사에서 이커머스를 운영하면서 쿠폰이나 메인 화면 상품 목록, 파트너, 후기 등의 개발을 해왔습니다. 이번에 처음으로 회원과 인증 도메인을 맡아 개발을 진행하면서, 사용자와 직접적으로 맞닿는 부분이이기도 하고 시큐리티에 관한 무지함으로 인해 부담감이 컸습니다. 하지만 개발을 진행하면서도 틈틈이 시큐리티를 공부하였고, 발생한 이슈에 대해 고민하며 해결방안을 찾아가는 재미를 느낄수 있었습니다. 그리고 제가 작성한 회고가 저와 같은 문제로 고민하는 다른 개발자분들에게 도움이 되었으면 좋겠습니다.
이제 스프링은 어느정도 할수 있다고 자신하던 지난 날의 자신을 반성하면서 항상 처음과 같은 마음으로 점점 더 성장하는 개발자가 되겠습니다.
읽어주셔서 감사합니다. 🙂
'회고' 카테고리의 다른 글
[퇴사 회고] 개발자로서 첫 회사를 퇴사하며.. (0) | 2022.10.01 |
---|---|
[이슈 해결 회고] 요청마다 호출하는 메서드에 선언된 @transactional이 문제였다고 생각합니다! (3) | 2022.09.24 |
- Total
- Today
- Yesterday
- 김영한
- 코테
- 북클럽
- Algorithm
- 문자열
- 데이터베이스
- 알고리즘
- 스프링 부트
- 인프런
- 정렬
- MySQL
- 노마드코더
- 그리디
- kotlin
- 백준
- 자료구조
- 스프링
- spring boot
- 스프링부트
- mysql 8.0
- 코틀린
- 구현
- 파이썬
- webflux
- Real MySQL
- leetcode
- 노마드
- 리팩토링
- Spring
- 릿코드
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |