간단하게 OIDC 소셜 로그인 3개 Ctrl+v 하기 (feat. google, kakao, apple)

komment·2024년 6월 11일
27

2024 개발 일지

목록 보기
1/6
post-thumbnail

서론

  사이드 프로젝트를 하면서 가장 고민되는 부분이 MVP에서 회원가입과 로그인을 구현할 것인가?이다. 프론트에서는 유저에 대한 상태 관리, 서버에서는 유저에 대한 보안 관리를 해야 하고, 특히 무엇보다 피쳐의 크기가 작지 않기에 (귀.찮.다.) 시간이 꽤 소요된다.

  그럼에도 불구하고 MVP 개발 범위에 대한 회의 결과, 로그인 기능이 포함되는 경우가 꽤 있는데, 이럴 경우 우리는 소셜 로그인을 많이 활용한다. 지금 개발하고 있는 사이드 프로젝트인 케이크크에서도 소셜 로그인을 구현하기로 결정했고, 이번 포스팅에서는 Spring Security에 대한 설정과 간단하게 소셜 로그인을 Ctrl+c, Ctrl+v 하는 과정을 다루어 보고자 한다.


Spring Security, 그거 복잡하다면서? ;;

  (필자만 그랬을지도 모르지만) Spring Security는 건드리기 싫은 존재였다. 사실 Spring Security를 활용하지 않아도 인증·인가 기능을 구현할 수 있다. 하지만 인증 및 액세스 제어 프레임워크인만큼 Spring Security에 대해 이해하고 조금만 활용할줄 안다면 보다 편하게 구현할 수 있다.

  위의 사진은 Spring Security 인증 프로세스에 대한 아키텍쳐다. 간단하게 살펴보면 다음과 같다.

  1. AuthenticationFilter가 요청을 인터셉트
  2. 인증 책임이 AuthenticationManager에 위임
  3. AuthenticationManager는 인증 논리를 구현하는 AuthenticationProvider를 이용
  4. UserDetailsService에서 유저의 세부 정보를 찾음
  5. PasswordEncoder로 암호 검증
  6. 인증 결과를 필터로 AuthenticationFilter로 반환
  7. 인증 객체를 SecurityContext에 저장

  만약 OAuth를 활용하지 않는다면 위와 같은 프로세스를 기반으로 인증·인가 기능을 구현해야 한다. 하지만 우리는 OAuth와 Json Web Token(이하 Jwt)를 활용할 것이기에 다음과 같이 필터가 구성된다.

  검정색 박스는 새로 구현할 필터들이다. 요청이 들어오면 JwtAuthenticationFilter에서 jwt 토큰으로 인증을 확인하고, 인증 성공 시 인증 객체를 SecurityContext에 저장, 인증 실패 시 에러를 response에 담아 다음 필터로 넘긴다.

  인증이 실패할 경우, 요청이 Dispatcherservlet까지 전달되지 않기 때문에 ExceptionHandler를 통한 에러 핸들링이 힘들다. 이 상태로 두면 로그를 보기 전까지는 그저 403 Forbidden 에러라는 사실만 알 수 있다. 따라서 JwtAuthenticationFilter 뒤에 JwtExceptionHandler를 구성하여 에러 핸들링을 해주었다.

  자, 이제 코드를 슬쩍 살펴보자. 코드를 보기에 앞서 Spring Boot 3.2.4 버전임을 참고하자.

i) SecurityConfig

. . .
	@Bean
	public SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
		return http
			.httpBasic(AbstractHttpConfigurer::disable)
			.csrf(AbstractHttpConfigurer::disable)
			.formLogin(AbstractHttpConfigurer::disable)
			.headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
			.sessionManagement(setSessionManagement())
			.authorizeHttpRequests(setAuthorizePath())
			.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
			.addFilterAfter(jwtExceptionFilter, JwtAuthenticationFilter.class)
			.build();
	}
. . .

  기본적인 설정은 생략하고, 필터의 순서를 구성한 부분만 확인하자. 앞서 말했듯이 Spring Security는 Filter 기반으로 동작하기 때문에 커스텀 시에도 필터의 순서를 잘 설계하는 것이 중요해보인다.

ii) JwtAuthenticationFilter

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		Optional<String> token = getTokensFromHeader(request, accessHeader);

		token.ifPresent(it -> {
			final String accessToken = replaceBearerToBlank(it);

			final Authentication authentication = jwtProvider.getAuthentication(accessToken);
			SecurityContextHolder.getContext().setAuthentication(authentication);
		});

		filterChain.doFilter(request, response);
	}

  JwtAuthenticationFilter에서는 Header에서 access token을 가져와 인증에 대한 확인을 한다. 코드에서 확인할 수 있듯이 에러가 발생하지 않는다면 SecurityConext에 인증 객체를 저장한다.

iii) JwtExceptionFilter

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		try {
			filterChain.doFilter(request, response);
		} catch (CakkException exception) {
			setErrorResponse(exception.getReturnCode(), response);
		}
	}

  JwtAuthenticationFilter에서 에러가 발생하지 않았다면 doFilter()를 통해 AuthenticationFilter로 넘겨주고, 에러가 발생했다면 response에 에러에 대한 응답값을 담아준다. 이 때는 doFilter() 메서드를 호출하지 않기 때문에 클라이언트에서 에러에 대한 메세지를 확인할 수 있다.


소셜 로그인 구현에 앞서...

  소셜 로그인을 제공해주는 플랫폼은 여러가지지만 google, kakao, 그리고 apple을 다루어 볼 생각이다. 이 세 플랫폼을 선택한 이유는 모두 oidc 인증 방식을 제공하기 때문이다.

(ps. oauth와 oidc에 대한 설명은 이전에 작성한 포스팅으로 대체한다.)

  Google의 경우, GoogleIdTokenVerifier를 라이브러리로 제공해준다. verify() 메서드를 통해 GoogleIdToken 객체를 얻고, getPayload()를 통해 provider id를 가져올 수 있다. Kakao와 Apple의 경우, 공개키 목록을 가져와 kid(서명 시 사용하는 키)와 alg(알고리즘)가 일치한지 확인 후 파싱하여 provider id를 가져올 수 있다.

i) application.yml

. . .
oauth:
  kakao:
    public-key-info: https://kauth.kakao.com/.well-known/jwks.json
  apple:
    public-key-url: https://appleid.apple.com/auth/keys
  google:
    client-id: ${GOOGLE_CLIENT_ID}
. . .

  Kakao와 Apple은 공개키를 가져올 수 있는 주소, Google은 client id를 추가해준다.

ii) OidcProvider.java와 OidcProviderFactory.java

public interface OidcProvider {

	String getProviderId(String idToken);

	default Map<String, String> parseHeaders(String token) {
		String header = token.split("\\.")[0];

		try {
			return new ObjectMapper().readValue(decodeBase64(header), Map.class);
		} catch (IOException e) {
			throw new CakkException(INTERNAL_SERVER_ERROR);
		}
	}
}

  앞으로 구현할 Oidc 관련 Provider에 대하여 추상화 하였다. 위의 인터페이스와 Enum 타입의 Provider.java를 활용하여 다음과 같이 전략 패턴을 적용할 수 있다.

@Component
public class OidcProviderFactory {

	private final Map<Provider, OidcProvider> authProviderMap;
	private final AppleAuthProvider appleAuthProvider;
	private final KakaoAuthProvider kakaoAuthProvider;
	private final GoogleAuthProvider googleAuthProvider;

	public OidcProviderFactory(
		AppleAuthProvider appleAuthProvider,
		KakaoAuthProvider kakaoAuthProvider,
		GoogleAuthProvider googleAuthProvider
	) {
		this.authProviderMap = new EnumMap<>(Provider.class);
		this.appleAuthProvider = appleAuthProvider;
		this.kakaoAuthProvider = kakaoAuthProvider;
		this.googleAuthProvider = googleAuthProvider;

		initialize();
	}
    
    public String getProviderId(Provider provider, String idToken) {
		return getProvider(provider).getProviderId(idToken);
	}

	private void initialize() {
		authProviderMap.put(Provider.APPLE, appleAuthProvider);
		authProviderMap.put(Provider.KAKAO, kakaoAuthProvider);
		authProviderMap.put(Provider.GOOGLE, googleAuthProvider);
	}

	private OidcProvider getProvider(final Provider provider) {
		final OidcProvider oidcProvider = authProviderMap.get(provider);

		if (oidcProvider == null) {
			throw new CakkException(ReturnCode.WRONG_PROVIDER);
		}

		return oidcProvider;
	}
}

  팩토리 패턴을 적용하여 아래와 같은 이점을 얻을 수 있다.

  • 객체 생성 책임 분리: Factory가 OidcProvider 구현체의 생성 및 제공에 대한 책임을 가짐
  • 유연성: 새로운 OidcProvider 구현체가 추가되더라도 authProviderMap에 새로운 매핑을 추가하면 됨
  • 캡슐화: OidcProvider 구현체의 생성 로직 캡슐화

선배, 소셜 로그인 구현해 주세요~

  이제 본격적으로 소셜 로그인을 구현해보려 한다. 케이크크의 인증·인가 프로세스는 다음과 같다.

  1. 프론트에서 id token 발급
  2. id token을 포함하여 서버로 요청
  3. 서버에서 id token을 검증하고 provider id를 가져옴
  4. 회원가입 또는 로그인 성공 시 access token과 refresh token 발급

(Google 프로젝트 세팅이나 Kakao 애플리케이션 세팅 등은 다루지 않는다.)

i) Google 로그인 파헤치기

  Google은 라이브러리를 지원한다. 따라서 먼저 dependency를 추가해줘야 한다.

implementation('com.google.api-client:google-api-client-jackson2:2.2.0')
implementation('com.google.api-client:google-api-client:2.2.0')

  라이브러리에서 제공하는 GoogleIdTokenVerifier 또한 Bean으로 등록해준다.

. . .
	@Bean
	public GoogleIdTokenVerifier googleIdTokenVerifier() {
		return new GoogleIdTokenVerifier
			.Builder(new NetHttpTransport(), new GsonFactory())
			.setAudience(Collections.singletonList(googleClientId))
			.build();
	}
. . .

  GoogleAuthProvider를 통해 provider id를 가져오는 메서드는 다음과 같다.

. . .
	@Override
	public String getProviderId(final String idToken) {
		return getGoogleIdToken(idToken).getPayload().getSubject();
	}

	private GoogleIdToken getGoogleIdToken(final String idToken) {
		try {
			final GoogleIdToken googleIdToken = googleIdTokenVerifier.verify(idToken);

			if (isNull(googleIdToken)) {
				throw new CakkException(EXTERNAL_SERVER_ERROR);
			}

			return googleIdToken;
		} catch (GeneralSecurityException | IOException e) {
			throw new CakkException(EXTERNAL_SERVER_ERROR);
		}
	}
. . .

  이렇게 간단하게 가져올 수 있는데, 이 때 verify()는 인증 실패 시 에러를 던지지 않고 null을 반환하기 때문에 예외처리가 꼭 필요하다.

ii) 선배! 혹시.. 애플 로그인도 같이..

  애플 로그인은 복잡하다는 말이 많다. 하지만 카카오 로그인과 로직이 완전히 같다. 다음은 KakaoAuthProvider와 AppleAuthProvider의 메서드다.

// KakaoAuthProvider.java
. . .
	private final KakaoAuthClient kakaoAuthClient;
	private final PublicKeyProvider publicKeyProvider;
	private final JwtProvider jwtProvider;

	@Override
	public String getProviderId(final String idToken) {
		final OidcPublicKeyList oidcPublicKeyList = kakaoAuthClient.getPublicKeys();
		final PublicKey publicKey = publicKeyProvider.generatePublicKey(parseHeaders(idToken), oidcPublicKeyList);

		return jwtProvider.parseClaims(idToken, publicKey).getSubject();
	}
. . .

// AppleAuthProvider.java
. . .
	private final AppleAuthClient appleAuthClient;
	private final PublicKeyProvider publicKeyProvider;
	private final JwtProvider jwtProvider;

	@Override
	public String getProviderId(final String idToken) {
		final OidcPublicKeyList oidcPublicKeyList = appleAuthClient.getPublicKeys();
		final PublicKey publicKey = publicKeyProvider.generatePublicKey(parseHeaders(idToken), oidcPublicKeyList);

		return jwtProvider.parseClaims(idToken, publicKey).getSubject();
	}
. . .

  두 Provider는 공개키 리스트를 가져오기 위해 요청 보내는 주소만 다르다는 것을 확인할 수 있다. 이제 공개키를 제공 받고 검증하는 부분만 구현하면 소셜 로그인 구현이 끝난다.

iii) 공개키 가져오기

  공개키를 가져오기 위해 HTTP 클라이언트를 활용해야 한다. 이전에는 RestTemplate이나 WebClient, OpenFeign 등을 활용했었지만, Spring 6.1버전, Spring Boot 3.2.0 버전 기준으로 RestClient를 제공하기 때문에 RestClient를 활용하기로 했다.

(RestClient에 대한 이야기 보러가기 → 레퍼런스)

// OidcPublicKey.java
public record OidcPublicKey(
	String kid,
	String kty,
	String alg,
	String use,
	String n,
	String e
) {
}

// OidcPublicKeyList.java
public record OidcPublicKeyList(
	List<OidcPublicKey> keys
) {

	public OidcPublicKey getMatchedKey(final String kid, final String alg) {
		return keys.stream()
			.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
			.findAny()
			.orElseThrow(() -> new CakkException(EXTERNAL_SERVER_ERROR));
	}
}

  위는 공개키 관련 record다. 각각 KakaoAuthClient와 AppleAuthClient를 통해 OidcPublicKeyList를 가져올 것이다. 공개키와 관련된 내용은 공식문서를 통해 확인할 수 있다.

// kakaoAuthClient.java 또는 AppleAuthClient.java
 	. . .
	public OidcPublicKeyList getPublicKeys() {
		return restClient.get()
			.uri(publicKeyUrl)
			.retrieve()
			.body(OidcPublicKeyList.class);
	}
    . . .

iv) RSA 공개키 생성하기

  RestClient를 통해 간단하게 공개키 목록을 가져왔다. 목록 중 kid와 alg가 id token과 일치하는 공개키를 찾아 jwt 검증을 위해 필요한 RSA 공개키를 생성해보자.

	public PublicKey generatePublicKey(final Map<String, String> tokenHeaders, final OidcPublicKeyList publicKeys) {
		final OidcPublicKey publicKey = publicKeys.getMatchedKey(tokenHeaders.get("kid"), tokenHeaders.get("alg"));

		return getPublicKey(publicKey);
	}

	private PublicKey getPublicKey(final OidcPublicKey publicKey) {
		final byte[] nBytes = decodeBase64(publicKey.n());
		final byte[] eBytes = decodeBase64(publicKey.e());

		final RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes), new BigInteger(1, eBytes));

		try {
			return KeyFactory.getInstance(publicKey.kty()).generatePublic(publicKeySpec);
		} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
			throw new CakkException(EXTERNAL_SERVER_ERROR);
		}
	}

  이렇게 생성된 공개키를 통해 id token에서 provider id를 가져올 수 있다.

// JwtProvider.java
	public Claims parseClaims(String token, PublicKey publicKey) {
		try {
			return Jwts.parserBuilder()
				.setSigningKey(publicKey)
				.build()
				.parseClaimsJws(token)
				.getBody();
		} catch (ExpiredJwtException e) {
			throw new CakkException(EXPIRED_JWT_TOKEN);
		} catch (RuntimeException e) {
			throw new CakkException(WRONG_JWT_TOKEN);
		}
	}

sign-up, sign-in 구현하기

@Service
@RequiredArgsConstructor
public class SignService {

	private final OidcProviderFactory oidcProviderFactory;
	private final JwtProvider jwtProvider;

. . .

	@Transactional
	public JwtResponse signUp(final UserSignUpRequest dto) {
		final String providerId = oidcProviderFactory.getProviderId(dto.provider(), dto.idToken());
		final User user = userWriter.create(UserMapper.supplyUserBy(dto, providerId));

		return JwtMapper.supplyJwtResponseBy(jwtProvider.generateToken(user));
	}

	@Transactional(readOnly = true)
	public JwtResponse signIn(final UserSignInRequest dto) {
		final String providerId = oidcProviderFactory.getProviderId(dto.provider(), dto.idToken());
		final User user = userReader.findByProviderId(providerId);

		return JwtMapper.supplyJwtResponseBy(jwtProvider.generateToken(user));
	}
. . .

  지금까지 구현한 Provider와 Factory를 활용하여 회원가입과 로그인에 대한 애플리케이션 비즈니스 로직을 쉽게 구현할 수 있다.



포스팅과 관련된 코드는 케이크크 서버 Github에 저장돼 있습니다.

profile
안녕하세요. 서버 개발자 komment 입니다.

13개의 댓글

comment-user-thumbnail
2024년 6월 17일

프론트 공부하는데 썸네일보고 홀려서 들어왔습니다...🤣

1개의 답글
comment-user-thumbnail
2024년 6월 18일

와.. 개발 공부를 하다가 홀린 듯이 글을 다 읽고있습니다..
감사합니다..!!

1개의 답글
comment-user-thumbnail
2024년 6월 21일

마침 Apple과 Kakao 로그인을 구현해야 하는데 좋은 글이네요!
잘 읽었습니다:) Cakk Star도 박았어용👍

1개의 답글
comment-user-thumbnail
2024년 6월 23일

와우... 정성 가득한 훌륭한 글 잘 읽었습니다 👍💖

1개의 답글
comment-user-thumbnail
2024년 8월 6일

안녕하세요! 애플 로그인 구현하는데 있어서 정말 많은 도움이 되었습니다! 👍👍
그런데 구현하는데 궁금한 점이 있어서요....
제가 하는 프로젝트에서 애플의 access token과 refresh token을 발급 받지 않고 id token에서 provider id값을 가지고 회원 가입을 진행한 다음 자체 토큰을 발급하는 형식으로 구현을 했습니다.
그리고 회원 탈퇴를 구현하려고 하는데, 회원 탈퇴는 Sign in with Apple REST API을 이용하여 토큰을 해지 한 다음 사용자의 모든 정보를 삭제해야 앱 심사 때 리젝에 걸리지 않는다고 들었습니다.

그런데 깃허브를 보니, 탈퇴 사용자 테이블에 탈퇴한 사용자 정보를 저장하고 사용자 테이블에서 사용자를 삭제하는 방식으로 회원 탈퇴를 구현하신 것 같은데 혹시 애플 앱 심사 때 리젝을 받지 않았는지, 회원 탈퇴 때 굳이 애플의 엑세스 토큰을 발급받고 이를 해제하지 않아도 괜찮은지 궁금합니다!

1개의 답글
comment-user-thumbnail
2024년 9월 1일

글 너무 잘봤습니다! 마침 저도 카카오와 애플로그인을 둘 다 idToken을 통해 구현중인데, 프론트에서 idToken을 발급받는것이, 결국 프론트에서 client id와 같은 값을 가지고 요청시 노출이 되지 않을까 해서 고민중에 있습니다. 크게 상관 없는 부분인지, 아니면 따로 해결하신게 있는지 여쭤봐도 괜찮을까요?

1개의 답글