Backend/Spring Security

OAuth를 통한 인증

olsohee 2024. 1. 25. 14:26

OAuth의 정의

OAuth는 사용자가 특정 서비스를 사용할 때 아이디, 패스워드 등의 사용자 정보를 입력하며 회원가입을 할 필요 없이, 신뢰할 수 있는 외부 어플리케이션(ex, 카카오, 네이버, 구글)에 등록된 사용자 정보를 통해 외부 어플리케이션에서 대신 인증을 처리해주는 방식이다. 

 

만약 OAuth를 사용하지 않으면, 사용자가 매 사이트마다 회원가입을 진행하며 사용자 정보를 노출해야 한다. 그러나 OAuth를 사용하면 사용자 정보를 특정 외부 어플리케이션에서만 관리하고 인증을 처리해준다. 따라서 사용자가 사용자 정보를 다른 서비스에 노출하지 않아도 된다.

동작 원리

용어는 다음과 같다.

  • Resource Owner: 인증을 수행하는 주체(Resource Sercer의 계정을 소유하고 있는 사용자)
  • Client: Resource Server의 API를 사용하여 사용자 정보를 가져오려는 어플리케이션 서버
  • Authorization Server: Clinet가 Resource Server의 서비스를 사용할 수 있게 인증하고,
    토큰을 발행해주는 인증 서버(ex, 카카오, 네이버, 구글 등의 인증 서버)
  • Resource Server: 인가를 수행하고 리소스를 제공하는 주체(ex, 카카오, 네이버, 구글)

동작 순서는 다음과 같다.

  1. Resource Owner가 "구글로 로그인하기" 등의 버튼을 클릭하여 로그인을 요청한다. Client는 OAuth 프로세스를 시작하기 위해 사용자의 브라우저를 Authorization Server로 보낸다.
    • 이때 Client는 Authorization Server가 제공하는 url에 response_type, client_id, redirect_uri, scope 등을 포함하여 보내야 한다.
      • response_type: 반드시 code 로 값을 설정해야한다. 인증이 성공할 경우 클라이언트는 Authorization Code를 받을 수 있다.
      • client_id: 애플리케이션을 생성했을 때 발급받은 Client ID
      • redirect_uri: 애플리케이션을 생성할 때 등록한 Redirect URI
      • scope: 클라이언트가 부여받은 리소스 접근 권한
  2. Client가 빌드한 Authorization URL로 이동된 Resource Owner는 제공된 로그인 페이지에서 ID와 PW 등을 입력하여 인증을 진행한다.
  3.  인증이 성공되었다면, Authorization Server는 Redirect URI로 사용자를 리디렉션시킨다. 이때 Redirect URI에 Authorization Code를 포함한다. Authorization Code란 Client가 Access Token을 획득하기 위해 사용하는 임시 코드이다. 이 코드는 수명이 매우 짧다.
  4. Client는 Authorization Server에 Authorization Code를 전달하고, Access Token을 응답받는다. Client는 발급받은 Resource Owner의 Access Token을 저장한다.
    • Authorization Code를 통해 Access Token을 발급받으려면, 다음과 같은 정보를 전달해야 한다. 그리고 이때 요청은 application/x-www-form-urlencoded 형식이다.
      • grant_type: 항상 authorization_code 로 설정되어야 한다.
      • code: 발급받은 Authorization Code
      • redirect_uri: Redirect URI
      • client_id: Client ID
      • client_secret: RFC 표준상 필수는 아니지만, Client Secret이 발급된 경우 포함하여 요청해야한다.
  5. 위 과정을 성공적으로 마치면 Client는 Resource Owner에게 로그인이 성공하였음을 알린다.
  6. 이후 Client는 Resource Server에 있는 Resource Owner의 리소스에 접근이 필요할 때 발급받은 Access Token을 사용한다.

OAuth 적용

위에서 살펴본 동작 원리대로 직접 구현할 수도 있지만 OAuth를 사용하면 OAuth가 이러한 과정을 자동으로 진행해준다. 다음은 OAuth 통신 과정에서 사용되는 주요 클래스이다.

OAuth2UserService

OAuth2UserService는 다음과 같은 인터페이스 형태로, loadUser() 메소드는 Authorization Server로부터 발급받은 토큰을 통해 토큰에서 사용자 정보를 획득하고, OAuth2User 형식으로 인증된 사용자 객체를 반환한다. 

@FunctionalInterface
public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {

	U loadUser(R userRequest) throws OAuth2AuthenticationException;

}

 

그리고 OAuth2UserSerivce의 구현체 중 하나로 DefaultOAuth2UserService가 있다.

 

만약 Authorization Server로부터 발급받은 토큰을 통해 사용자 객체를 생성하고 DB에 저장하는 등의 추가적인 작업이 필요하면, OAuth2UserService의 구현체를 정의하면 된다. 그러면 OAuth가 OAuth2UserService의 구현체를 실행하여 커스텀한 구현체의 loadUser() 메소드가 실행된다.

 

반면 별도의 구현체를 정의하지 않으면, OAuth는 OAuth2UserService의 구현체 중 하나인 DefaultOAuth2UserService의 loadUser() 메소드를 실행한다.

public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		Assert.notNull(userRequest, "userRequest cannot be null");
		if (!StringUtils
			.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
					"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		String userNameAttributeName = userRequest.getClientRegistration()
			.getProviderDetails()
			.getUserInfoEndpoint()
			.getUserNameAttributeName();
		if (!StringUtils.hasText(userNameAttributeName)) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
					"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
		ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
		Map<String, Object> userAttributes = response.getBody();
		Set<GrantedAuthority> authorities = new LinkedHashSet<>();
		authorities.add(new OAuth2UserAuthority(userAttributes)); 
		OAuth2AccessToken token = userRequest.getAccessToken(); 
		for (String authority : token.getScopes()) {
			authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
		}
		return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
	}
    ...
}

AuthenticationSuccessHandler

AuthenticationSuccessHandler는 다음과 같이 인터페이스 형태로, OAuth 통신이 성공적으로 수행된 이후에 onAuthenticationSuccess() 메소드가 호출된다. 

public interface AuthenticationSuccessHandler {

	default void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authentication) throws IOException, ServletException {
		onAuthenticationSuccess(request, response, authentication);
		chain.doFilter(request, response);
	}

	void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException;

}

 

만약 OAuth를 통해 인증이 성공한 후에 해당 사용자에게 인가를 위한 토큰을 발급하는 등의 추가 작업이 필요하면, 별도의 AuthenticationSuccessHandler 구현체를 정의하고 해당 클래스에 토큰 발급 로직을 작성해주면 된다.


Reference