OAuth2.0 이해하기
사용자가 가입된 서비스(구글, 페이스북, 카카오, 네이버 등)에서 제공하는 API를 이용하여 사용자 데이터에 접근하기 위해서는 사용자로부터 권한을 위임 받아야 한다. 이 때 사용자의 패스워드 없이도 권한을 위임 받을 수 있는 방법이 필요한데, OAuth2.0(Open Authorization, Open Authentication 2) 라는 표준 인증 프로토콜을 통해 처리한다.
- 왜 API는 비밀번호만으로 인증하는 방법을 사용하지 않는가?
- 신뢰 — 사용자는 애플리케이션에 비밀번호를 제공하기 꺼려함
- 불필요하게 넓은 접근 범위 — 사용자가 애플리케이션에 비밀번호를 제공하면 애플리케이션에 필요한 데이터 뿐만 아니라 사용자 계정 안에 있는 모든 데이터에 접근할 수 있음
- 사용성 — 사용자가 비밀번호를 바꾸면 애플리케이션은 더는 해당 데이터에 접근하지 못함
- OAuth 주요 용어 4가지
- Resource Owner — 서비스를 이용하는 사용자이자, 리소스 소유자
- Client (어플리케이션) — 리소스 소유자를 대신하여 보호된 리소스에 액세스하는 응용 프로그램
- Resource Server — 보호받는 리소스를 호스팅하고 액세스 토큰을 사용하는 클라이언트의 요청을 수락하고 응답할 수 있는 서버 (카카오, 네이버 등의 리소스 서버)
- Authorization Server — 클라이언트 및 리소스 소유자를 성공적으로 인증한 후 액세스 토큰을 발급하는 서버 (카카오, 네이버 등의 인증 서버)
OAuth2.0에서 Client는 Authorization server에게 4가지 방법으로 토큰 발생을 요청할 수 있다.
Authorization Code Grant
- OAuth2.0에서 가장 중요하고, 널리 사용되는 인증 방법 — 이 방법에서 클라이언트는 써드파티 서비스의 백엔드 서버가 됨
- 백엔드 서버가 존재하는 웹/모바일 서비스에 적합함
- 사용자 인증 후 Callback을 통해 authorization code를 받고, 이를 client-id, client-secret과 함께 Access-Token으로 교환함
- Callback 처리는 백엔드 서버에서 이루어지기 때문에, Access-Token이 외부에 노출되지 않음 (보안상 안전)
- 4단계 처리 Flow
- Authorization Request — 클라이언트는 사용자를 Authorization Server로 리다이렉션
- response_type — code 고정
- client_id — Authorization Server에서 클라이언트를 식별하기 위한 식별키
- redirect_uri — Authorization Server에서 처리 완료 후 리다이렉션 하기 위한 URL
- scope — 클라이언트가 요구하는 리소스를 정의
- state — 클라이언트는 임의의 문자열을 생성하여 CSRF 공격을 방지함
https://kauth.kakao.com/oauth/authorize ?response_type=code &client_id=0492f15cb715d60526a3eb9e2323c559 &scope=profile_nickname%20profile_image &state=xI8tRNCSoeiAIw87NaUr5foPbhBhW2METzHDBK75jgo%3D &redirect_uri=http://localhost:8080/login/oauth2/code/kakao
- code — Access-Token 교환을 위한 승인 코드
- state — 요청과 함께 전달 된 임의의 문자열
/login/oauth2/code/kakao ?code=jzcahTyqbAx4zs9pKfBDlGXmB36sPX2YJCNIIw0RKkW_ODsYTQpheSGABo17dHC5rXRD2Qopb9QAAAF76FELEg &state=xI8tRNCSoeiAIw87NaUr5foPbhBhW2METzHDBK75jgo%3D
- grant_type — authorization_code 고정
- code — 앞 단계에서 전달 받은 코드
- client_id — Authorization Server에서 클라이언트를 식별하기 위한 식별키
- client_secret — 클라이언트 비밀키
HTTP POST https://kauth.kakao.com/oauth/token Accept=[application/json, application/*+json] Writing [ {grant_type=[authorization_code], code=[jzcahTyqbAx4zs9pKfBDlGXmB36sPX2YJCNIIw0RKkW_ODsYTQpheSGABo17dHC5rXRD2Qopb9QAAAF76FELEg], redirect_uri=[http://localhost:8080/login/oauth2/code/kakao], client_id=[0492f15cb715d60526a3eb9e2323c559], client_secret=[oqoKOBecGMC45Uh7z7bmdtMJ0A4PSQ2l]} ] as "application/x-www-form-urlencoded;charset=UTF-8"
- access_token — 리소스 요청에 필요한 토큰 (보통 짧은 생명주기를 지니고 있음)
- refresh_token — Access-Token을 갱신하기 위한 토큰

Implicit Grant
Client Credentials Grant
Resource Owner Password Credentials Grant
Spring Security OAuth2.0 Client (카카오 인증 연동)
Spring Security 인프라 스트럭처 위에서 Authorization Code Grant 타입 OAuth2.0 인증 처리 방법을 알아보자.
10장의 베이스코드에서 User 모델과 테이블 정의가 변경되었다. 간단한 변경이므로 User 모델 정의를 참고한다
카카오 Application 생성
- 카카오 개발자 사이트에서 어플리케이션을 하나 등록함
- 요약 정보의 REST API 키 값을 OAuth2.0에서 client_id 값으로 사용됨
- 카카오 로그인 설정을 활성화하고, Redirect URI 부분에 http://localhost:8080/login/oauth2/code/kakao 주소를 입력
- 동의 항목 설정에서 profile_nickname, profile_image 필드를 필수 동의로 설정 — 해당 값은 scope 값으로 사용됨
- 보안 설정에서 Client Secret을 활성화하고, 코드를 생성 — 해당 값은 client_secret 값으로 사용됨
Spring Security OAuth2.0 의존성 추가 및 설정
- spring-boot-starter-oauth2-client — 클라이언트 관점에서 OAuth2.0 인증 처리를 처리할 수 있도록 도와줌
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency>
- application.xml 파일에 kakao OAuth2.0 연동을 위한 정보를 입력함
- 카카오 로그인 설정에서 입력한 Redirect URI 주소를 security.oauth2.client.registration.kakao.redirect-uri 부분에 입력함
- 카카오 로그인 설정에서 입력한 Redirect URI 주소의 마지막 부분은 {registrationId} 변수로 처리함
spring: security: oauth2: client: registration: kakao: client-name: kakao client-id: 19f4f13148900bb3bedeb7c4eff31e31 client-secret: NwhWWnB5D62JcSFRzX3zUC2sRrgJ0iRd scope: profile_nickname, profile_image redirect-uri: "http://localhost:8080/login/oauth2/code/{registrationId}" authorization-grant-type: authorization_code client-authentication-method: POST provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id
- 카카오 인증이 완료되었을 때 후처리를 담당할 AuthenticationSuccessHandler 인터페이스 구현체 추가
- 카카오 인증이 완료된 사용자가 신규 사용자라면 사용자를 가입 시킴
- 서비스 접근을 위한 JWT 토큰 생성 및 응답
- 아래 코드에서는 단순히 JSON 포맷으로 응답을 생성하지만, 앱 연동을 위해 앱 전용 스킴을 설계하고 데이터를 전달할 수 있음
public class OAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final Logger log = LoggerFactory.getLogger(getClass()); private final Jwt jwt; private final UserService userService; public OAuth2AuthenticationSuccessHandler(Jwt jwt, UserService userService) { this.jwt = jwt; this.userService = userService; } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { if (authentication instanceof OAuth2AuthenticationToken) { OAuth2AuthenticationToken oauth2Token = (OAuth2AuthenticationToken) authentication; OAuth2User principal = oauth2Token.getPrincipal(); String registrationId = oauth2Token.getAuthorizedClientRegistrationId(); User user = processUserOAuth2UserJoin(principal, registrationId); String loginSuccessJson = generateLoginSuccessJson(user); response.setContentType("application/json;charset=UTF-8"); response.setContentLength(loginSuccessJson.getBytes(StandardCharsets.UTF_8).length); response.getWriter().write(loginSuccessJson); } else { super.onAuthenticationSuccess(request, response, authentication); } } private User processUserOAuth2UserJoin(OAuth2User oAuth2User, String registrationId) { return userService.join(oAuth2User, registrationId); } private String generateLoginSuccessJson(User user) { String token = generateToken(user); log.debug("Jwt({}) created for oauth2 login user {}", token, user.getUsername()); return "{\"token\":\"" + token + "\", \"username\":\"" + user.getUsername() + "\", \"group\":\"" + user.getGroup().getName() + "\"}"; } private String generateToken(User user) { return jwt.sign(Jwt.Claims.of(user.getUsername(), new String[]{"ROLE_USER"})); } } @Service public class UserService { // ... 생략 ... @Transactional public User join(OAuth2User oauth2User, String provider) { checkArgument(oauth2User != null, "oauth2User must be provided."); checkArgument(isNotEmpty(provider), "authorizedClientRegistrationId must be provided."); String providerId = oauth2User.getName(); return findByProviderAndProviderId(provider, providerId) .map(user -> { log.warn("Already exists: {} for (provider: {}, providerId: {})", user, provider, providerId); return user; }) .orElseGet(() -> { Map<String, Object> attributes = oauth2User.getAttributes(); @SuppressWarnings("unchecked") Map<String, Object> properties = (Map<String, Object>) attributes.get("properties"); checkArgument(properties != null, "OAuth2User properties is empty"); String nickname = (String) properties.get("nickname"); String profileImage = (String) properties.get("profile_image"); Group group = groupRepository.findByName("USER_GROUP") .orElseThrow(() -> new IllegalStateException("Could not found group for USER_GROUP")); return userRepository.save( new User(nickname, provider, providerId, profileImage, group) ); }); } }
- Spring Security 설정
- OAuth2AuthenticationSuccessHandler Bean 추가 및 설정
@Bean public OAuth2AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler(Jwt jwt, UserService userService) { return new OAuth2AuthenticationSuccessHandler(jwt, userService); } @Override protected void configure(HttpSecurity http) throws Exception { http // ... 생략 ... .oauth2Login() .successHandler(getApplicationContext().getBean(OAuth2AuthenticationSuccessHandler.class)) .and() .addFilterAfter( getApplicationContext().getBean(JwtAuthenticationFilter.class), SecurityContextPersistenceFilter.class ) ; }
어떤 일들이 벌어진 것일까?
- filterChainProxy 살펴보기 — 3개의 필터가 추가됨
- DefaultLoginPageGeneratingFilter — 로그인 페이지 생성 필터
- 로그인 전략에 따라 Form 로그인 페이지, OAuth2.0 로그인 페이지 등이 생성됨
- /oauth2/authorization/kakao — 카카오 OAuth 인증 요청 링크
- OAuth2AuthorizationRequestRedirectFilter 에서 해당 요청을 처리하게됨

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="description" content=""> <meta name="author" content=""> <title>Please sign in</title> <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous"> <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/> </head> <body> <div class="container"> <h2 class="form-signin-heading">Login with OAuth 2.0</h2> <table class="table table-striped"> <tr><td><a href="/oauth2/authorization/kakao">kakao</a></td></tr> </table> </div> </body> </html>
- /oauth2/authorization/{registrationId} 패턴의 URL 요청을 처리함 (기본값)
- {registrationId} 부분에는 인증 Provider 식별키(kakako 같은)가 입력됨
- AuthorizationRequestRepository 인터페이스 구현체에는 application.yml 파일에 설정한 OAuth 연동 정보가 저장되어 있음
- 인증 Provider 식별키로 AuthorizationRequestRepository 인터페이스에서 OAuth 연동 정보를 가져옴
authorization-uri
주소로 사용자를 리다이렉트 시킴
private void sendRedirectForAuthorization( HttpServletRequest request, HttpServletResponse response, OAuth2AuthorizationRequest authorizationRequest ) throws IOException { if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) { this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response); } this.authorizationRedirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri()); }
- 흥미로운 점은 AuthenticationManager, AuthenticationProvider 같은 Spring Security 기본 인프라 스트럭처가 그대로 활용됨
- OAuth2LoginAuthenticationToken
- OAuth2.0 인증 처리를 명시적으로 나타내는 Authentication 인터페이스 구현체
- OAuth2LoginAuthenticationProvider
- OAuth2LoginAuthenticationToken 타입 인증 요청을 처리할 수 있는 AuthenticationProvider 인터페이스 구현체
- Authorization Server에서 Access-Token 및 Refresh-Token을 가져옴
- 발급 받은 Access-Token 을 이용해, 사용자 데이터를 조회해옴 — OAuth2User 객체로 표현됨
@Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // ... 생략 ... OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response); if (authorizationRequest == null) { OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } // ... 생략 ... OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken( clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse) ); authenticationRequest.setDetails(authenticationDetails); OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest); OAuth2AuthenticationToken oauth2Authentication = new OAuth2AuthenticationToken( authenticationResult.getPrincipal(), authenticationResult.getAuthorities(), authenticationResult.getClientRegistration().getRegistrationId() ); oauth2Authentication.setDetails(authenticationDetails); OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient( authenticationResult.getClientRegistration(), oauth2Authentication.getName(), authenticationResult.getAccessToken(), authenticationResult.getRefreshToken() ); this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response); return oauth2Authentication; }
추가적인 개선
OAuth2AuthorizationRequestRedirectFilter, OAuth2LoginAuthenticationFilter 구현을 자세히 보면 서로 연결되는 부분이 있음
- 이것은 CSRF 공격 방지를 위한 임의의 문자열 state를 확인하는 절차
- OAuth2AuthorizationRequestRedirectFilter — authorizationRequestRepository를 통해 authorizationRequest 저장
- OAuth2LoginAuthenticationFilter — authorizationRequestRepository를 통해 authorizationRequest 조회
- authorizationRequest 조회가 안되면 오류 처리
- 그런데, authorizationRequestRepository 인터페이스 기본 구현체가 HttpSessionOAuth2AuthorizationRequestRepository 클래스로 Session을 사용함
- API 서버는 Session을 사용하지 않기 때문에 HttpCookieOAuth2AuthorizationRequestRepository 구현을 추가하여, Session 대신 Cookie을 사용하도록함
@Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { return getCookie(request) .map(this::getOAuth2AuthorizationRequest) .orElse(null); } @Override public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { if (authorizationRequest == null) { getCookie(request).ifPresent(cookie -> clear(cookie, response)); } else { String value = Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(authorizationRequest)); Cookie cookie = new Cookie(cookieName, value); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setMaxAge(cookieExpireSeconds); response.addCookie(cookie); } } @Override public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) { return loadAuthorizationRequest(request); } @Override public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { return getCookie(request) .map(cookie -> { OAuth2AuthorizationRequest oauth2Request = getOAuth2AuthorizationRequest(cookie); clear(cookie, response); return oauth2Request; }) .orElse(null); } private Optional<Cookie> getCookie(HttpServletRequest request) { return ofNullable(WebUtils.getCookie(request, cookieName)); } private void clear(Cookie cookie, HttpServletResponse response) { cookie.setValue(""); cookie.setPath("/"); cookie.setMaxAge(0); response.addCookie(cookie); } private OAuth2AuthorizationRequest getOAuth2AuthorizationRequest(Cookie cookie) { return SerializationUtils.deserialize( Base64.getUrlDecoder().decode(cookie.getValue()) ); }
OAuth2LoginAuthenticationFilter 구현의 마지막 부분 — OAuth2AuthorizedClient 저장
- authorizedClientRepository를 통해 OAuth2AuthorizedClient 객체를 저장함 — 즉, OAuth2.0 인증이 완료된 사용자 정보를 저장
- 그런데, authorizedClientRepository 기본 구현체가 AuthenticatedPrincipalOAuth2AuthorizedClientRepository 클래스이며 내부적으로 InMemoryOAuth2AuthorizedClientService 클래스를 사용해 OAuth2AuthorizedClient 객체를 저장함
- 따라서, OAuth2.0 으로 인증되는 클라이언트가 많아지면 OOME 발생 가능성이 있음
- 또한 인증된 사용자 정보가 특정 서버 메모리에만 저장되고 있기 때문에 특정 서버 장애 발생 시 사이드 이펙트가 발생할 수 있음
- 다행히 InMemoryOAuth2AuthorizedClientService 클래스는 OAuth2AuthorizedClientService 인터페이스 구현체이며, InMemoryOAuth2AuthorizedClientService 를 대체할 수 있는 JdbcOAuth2AuthorizedClientService 클래스가 있음
public final class AuthenticatedPrincipalOAuth2AuthorizedClientRepository implements OAuth2AuthorizedClientRepository { private final AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); private final OAuth2AuthorizedClientService authorizedClientService; private OAuth2AuthorizedClientRepository anonymousAuthorizedClientRepository = new HttpSessionOAuth2AuthorizedClientRepository(); public AuthenticatedPrincipalOAuth2AuthorizedClientRepository( OAuth2AuthorizedClientService authorizedClientService) { Assert.notNull(authorizedClientService, "authorizedClientService cannot be null"); this.authorizedClientService = authorizedClientService; } // ... 생략 ... @Override public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, HttpServletRequest request, HttpServletResponse response) { if (this.isPrincipalAuthenticated(principal)) { this.authorizedClientService.saveAuthorizedClient(authorizedClient, principal); } else { this.anonymousAuthorizedClientRepository.saveAuthorizedClient(authorizedClient, principal, request, response); } } // ... 생략 ... private boolean isPrincipalAuthenticated(Authentication authentication) { return authentication != null && !this.authenticationTrustResolver.isAnonymous(authentication) && authentication.isAuthenticated(); } }
AuthenticatedPrincipalOAuth2AuthorizedClientRepository 클래스 구현에서 Session을 사용하는 HttpSessionOAuth2AuthorizedClientRepository 클래스가 확인되지만, 논리적으로 해당 클래스가 사용되는 부분은 실행되지 않는다. 왜냐하면 OAuth2.0 인증된 사용자의 authentication은 null이 아니며 anonymous 상태도 아니기 때문이다.
Java Configuration 수정 및 확인
- 설정 변경 부분
- HttpCookieOAuth2AuthorizationRequestRepository — HttpSessionOAuth2AuthorizationRequestRepository Bean을 대체함
- JdbcOAuth2AuthorizedClientService — InMemoryOAuth2AuthorizedClientService Bean을 대체함
- AuthenticatedPrincipalOAuth2AuthorizedClientRepository — JdbcOAuth2AuthorizedClientService 의존성 주입
public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() { return new HttpCookieOAuth2AuthorizationRequestRepository(); } @Bean public OAuth2AuthorizedClientService authorizedClientService( JdbcOperations jdbcOperations, ClientRegistrationRepository clientRegistrationRepository ) { return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); } @Bean public OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) { return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService); } @Override protected void configure(HttpSecurity http) throws Exception { http // ... 생략 ... .oauth2Login() .authorizationEndpoint() .authorizationRequestRepository(authorizationRequestRepository()) .and() .successHandler(getApplicationContext().getBean(OAuth2AuthenticationSuccessHandler.class)) .authorizedClientRepository(getApplicationContext().getBean(AuthenticatedPrincipalOAuth2AuthorizedClientRepository.class)) .and() .addFilterAfter(jwtAuthenticationFilter(), SecurityContextPersistenceFilter.class) ; }
- 설정 완료 후 카카오 인증을 진행
- OAuth2LoginAuthenticationFilter에서 authorizationRequestRepository를 통해 OAuth2AuthorizationRequest를 조회하는 부분을 확인
- Session 기반 구현체 대신 HttpCookieOAuth2AuthorizationRequestRepository 클래스가 사용됨
- 모든 처리 완료 후 OAUTH2_AUTHORIZED_CLIENT 테이블에 데이터가 들어간 것을 확인 할 수 있음
- 해당 테이블에는 Access-Token 외에 Refresh-Token 같은 정보도 포함되 있음

