들어가기
Spring과 React 기반으로 프로젝트를 진행하며 소셜로그인 기능을 도입했는데 굉장한 삽질의 연속이었습니다.
다시는 삽질하지 않도록 그 과정을 남겨 놓고자 합니다.
OAuth2의 전 과정을 Spring에서 처리하기 [Spring Security OAuth2 Client]
가장 먼저 시도했던 방법으로 Spring Security의 OAuth2 클라이언트를 사용해 정말 간편하게 소셜로그인을 구현할 수 있었습니다.
프론트는 /oauth/authorize/kakao 로 요청을 보내면 Spring의 OAuth2 Client가 그 이후의 모든 과정을 대신 처리해줍니다.
모든 과정을 대신 처리해준다는 의미는 카카오 로그인 페이지로 리다이렉션, Authorization Code 요청, AccessToken 요청, 회원 정보 요청을 대신해준다는 것입니다.
자세한 구현 내용은 다른 블로그에도 좋은 포스팅이 많으니 생략하겠습니다.
OAuth2 Client의 문제점
문제점은 프론트가 요청하는 /oauth2/authorization/kakao이 XMLHttpRequest 방식으로 이루어질 수 없다는 점입니다.
사용자가 로그인을 마치면 카카오에선 Authorization 코드를 리다이렉션 해주는데 Spring Security OAuth2 Client를 사용하면 백엔드로 Authorization 코드를 리다이렉션 해줍니다. 프론트에서 서버에 XMLHttpReuqest로 요청을 시작할 수 없는 이유이기도 합니다.
XMLHttpRequest 방식의 요청이 불가능 해지면서 서버가 자체 JWT를 생성해 액세스 토큰은 바디에 리프레쉬 토큰은 쿠키에 담고자 했으나 바디에 응답해주는 것이 불가능했습니다.
오직 쿼리스트링으로 액세스 토큰을 전달할 수 있습니다. 쿼리스트링에 보안상 중요한 토큰을 노출하는 것을 피하고 싶었고 흐름 자체를 바꾸었습니다.
OAuth2 Client를 걷어내고 Restful하게 개발해보자
이 모든 문제의 원흉은 서버가 혼자서 다 처리하기 때문입니다. 정확히 말하면 카카오에 설정한 리다이렉션 URL이 백엔드이기 때문입니다.
카카오에 설정한 리다이렉션 URL은 소셜로그인이 성공한 후에 AccessToken 발급에 필요한 Authorization Code를 응답해주는 주소인데 이 주소를 프론트엔드 측 주소(ex. localhost:3000/redirected/kakao)로 설정하면 문제를 해결할 수 있습니다.
프론트엔드는 Authorization 코드를 받고 백엔드 서버에 XMLHttpRequest 방식으로 Authorization 코드를 전송하면 인증을 마치고 바디 혹은 Authentication 헤더에 AccessToken을 전달할 수 있습니다.
개선된 흐름은 아래와 같습니다.
Oauth2 Controller
@GetMapping("/{oauth2ProviderType}")
public void redirectOauth2LoginUrl(
@PathVariable final Oauth2ProviderType oauth2ProviderType,
final HttpServletResponse response)
throws IOException {
final String loginUrl = oauth2Service.getRedirectionLoginUrl(oauth2ProviderType);
response.sendRedirect(loginUrl);
}
Oauth2 Client를 사용하지 않기 때문에 이제 백엔드는 소셜로그인에 대한 요청을 받기 위해서 API를 직접 열어주어야합니다.
첫 번째로 사용자에게 소셜로그인 페이지를 제공해주어야합니다.
Oauth2Service
@Service
@RequiredArgsConstructor
public class Oauth2Service {
private final Oauth2ProviderSelector oauth2ProviderSelector;
private final MemberService memberService;
public String getRedirectionLoginUrl(final Oauth2ProviderType oauth2ProviderType) {
final Oauth2ProviderService providerService =
oauth2ProviderSelector.getProvider(oauth2ProviderType);
return providerService.getRedirectionLoginUrl();
}
}
소셜로그인은 여러 제공 서비스가 존재하기 때문에 Oauth2ProviderSelector라는 클래스를 만들어 내부에서 Oauth2ProviderService를 Map으로 관리하고 사용자가 요청한 서비스를 선택하게 설계했습니다. (ex GoogleProviderService, KakaoProviderService 등)
Oauth2ProviderSelector
@Component
public class Oauth2ProviderSelector {
private final Map<Oauth2ProviderType, Oauth2ProviderService> providerServices;
public Oauth2ProviderSelector(final Set<Oauth2ProviderService> providerServices) {
this.providerServices =
providerServices.stream()
.collect(
Collectors.toMap(
Oauth2ProviderService::getOauth2ProviderType,
Function.identity()));
}
public Oauth2ProviderService getProvider(final Oauth2ProviderType oauth2ProviderType) {
return Optional.ofNullable(providerServices.get(oauth2ProviderType))
.orElseThrow(InvalidOauth2ProviderException::invalidOauth2Provider);
}
}
객체지향 원칙인 OCP를 준수하기 위해서 각 소셜로그인은 Oauth2ProviderService[interface]를 구현하고 Component로 등록하면 그 이외의 코드 수정은 필요하지 않도록 설계했습니다.
KakaoOauth2ProviderService
@Service
@RequiredArgsConstructor
@Slf4j
public class KakaoOAuth2ProviderService implements Oauth2ProviderService {
private static final String GRANT_TYPE = "grant_type";
private static final String AUTHORIZATION_CODE = "authorization_code";
private static final String CODE = "code";
private static final String CLIENT_ID = "client_id";
private static final String REDIRECT_URI = "redirect_uri";
private static final String BEARER = "Bearer ";
private final KakaoRestClient kakaoRestClient;
private final KakaoOauth2Properties kakaoOauth2Properties;
private final KakaoRedirectionLoginUrl kakaoRedirectionLoginUrl;
@Override
public Oauth2ProviderType getOauth2ProviderType() {
return Oauth2ProviderType.KAKAO;
}
@Override
public String getRedirectionLoginUrl() {
return kakaoRedirectionLoginUrl.redirectionUrl();
}
@Override
public Oauth2Member getOauth2Member(final String authCode) {
final KakaoAuthorization kakaoAuthorization =
kakaoRestClient.getKakaoAccessToken(requestParams(authCode));
final String accessToken = kakaoAuthorization.accessToken();
log.info("access {}", accessToken);
final KakaoMemberResponse kakaoMember =
kakaoRestClient.getKakaoMember(BEARER + accessToken);
return kakaoMember.toOauth2Member();
}
private MultiValueMap<String, String> requestParams(final String authCode) {
final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add(GRANT_TYPE, AUTHORIZATION_CODE);
params.add(CLIENT_ID, kakaoOauth2Properties.clientId());
params.add(REDIRECT_URI, kakaoOauth2Properties.redirectUri());
params.add(CODE, authCode);
params.add(CLIENT_ID, kakaoOauth2Properties.clientSecret());
return params;
}
}
KakaoRedirectionLoginUrl
로그인페이지 주소는 Bean으로 생성해 관리하도록 했습니다.
@Configuration
@RequiredArgsConstructor
public class Oauth2LoginUrlConfig {
private final KakaoOauth2Properties kakaoOauth2Properties;
@Bean
public KakaoRedirectionLoginUrl redirectionLoginUrl() {
return new KakaoRedirectionLoginUrl(
UriComponentsBuilder.fromUriString(kakaoOauth2Properties.loginUri())
.queryParam("response_type", "code")
.queryParam("client_id", kakaoOauth2Properties.clientId())
.queryParam("redirect_uri", kakaoOauth2Properties.redirectUri())
.queryParam("scope", String.join(",", kakaoOauth2Properties.scope()))
.toUriString());
}
}
배포 후 쿠키가 안담기는 문제
프론트에 Authorization 코드가 리다이렉션되고 서버까지 전송은 잘 완료됐습니다. 이제 서버가 Authorization 코드로 카카오 서버에 AccessToken을 요청한 뒤에 AccessToken으로 로그인한 사용자의 정보를 조회하고 자체 DB에 저장해야합니다.
로컬에선 위 모든 과정이 문제 없이 잘 동작합니다.(물론 localhost:8080[백엔드], localhost:3000[프론트] 둘은 Same Origin이 아니기 때문에 CORS 설정을 해줘야 합니다.)
개발 과정에서 프론트 측의 개발 편의성을 위해서 GCP에 임시로 배포를 해놓은 상황이었는데요. 배포 후 달라지는 것은 도메인입니다. 같은 localhost 도메인이 아니기 때문에 쿠키를 전송할 수 없습니다.
이는 CSRF 공격을 막기위해서 브라우저 정책에 따라 쿠키를 전송하지 않는 것인데요. 크롬 브라우저의 기본정책인 SameStie="LAX" Get을 제외한 Post, Delete 요청엔 쿠키를 같이 전송하지 않습니다.
그래서 서버에선 SameStie="none"로 속성을 변경했고 SameSite="none"이면 보안을 위해 HTTPS로 통신을 해야만합니다.
추가로 쿠키설정을 secure=true로 설정했습니다. 프론트에선 쿠키를 포함하기 위해서 withCredentials를 활성화 시켜주면 쿠키가 전송됩니다.
private ResponseCookie makeCookie(final String token) {
return ResponseCookie.from(REFRESH_COOKIE_NAME, token)
.maxAge(REFRESH_COOKIE_AGE_SECONDS)
.secure(true)
.httpOnly(true)
.sameSite("none")
.path("/")
.build();
}
'Spring' 카테고리의 다른 글
동시성 문제 해결하기 (3) | 2024.10.26 |
---|---|
정렬 알고리즘 [선택정렬, 버블정렬, 삽입정렬] (0) | 2024.09.27 |
Google S2 (3) | 2024.09.11 |
Google S2를 이용한 위치 검색 개선 (0) | 2024.09.09 |
위치기반 서비스 데이터베이스 선택 [PostgreSQL GIS] (0) | 2024.07.13 |