OAuth2.0 이해하기OAuth2 설정하기Spring Security OAuth2.0 Client (카카오 인증 연동)Github OAuth2.0OAuth의 인증정보를 DB에 저장하기
OAuth2.0 이해하기
사용자가 가입된 서비스(구글, 페이스북, 카카오, 네이버 등)에서 제공하는 API를 이용하여 사용자 데이터에 접근하기 위해서는 사용자로부터 권한을 위임 받아야 한다. 이 때 사용자의 패스워드 없이도 권한을 위임 받을 수 있는 방법이 필요한데, OAuth2.0(Open Authorization, Open Authentication 2) 라는 표준 인증 프로토콜을 통해 처리한다.
- 왜 API는 비밀번호만으로 인증하는 방법을 사용하지 않는가? → 그래서 카카오, 네이버 등 에서 로그인 하고난 후 그 권한을 위임받아 사용
- 신뢰 — 사용자는 애플리케이션에 비밀번호를 제공하기 꺼려함
- 불필요하게 넓은 접근 범위 — 사용자가 애플리케이션에 비밀번호를 제공하면 애플리케이션에 필요한 데이터 뿐만 아니라 사용자 계정 안에 있는 모든 데이터에 접근할 수 있음
- 사용성 — 사용자가 비밀번호를 바꾸면 애플리케이션은 더는 해당 데이터에 접근하지 못함
- OAuth 주요 용어 4가지
- Resource Owner — 서비스를 이용하는 사용자이자, 리소스 소유자
- Client (어플리케이션) — 리소스 소유자를 대신하여 보호된 리소스에 액세스하는 응용 프로그램 (카카오 네이버를 이용하는 제 3의 서비스)
- Resource Server — 보호받는 리소스를 호스팅하고 액세스 토큰을 사용하는 클라이언트의 요청을 수락하고 응답할 수 있는 서버 (카카오, 네이버 등의 리소스 서버)
- Authorization Server — 클라이언트 및 리소스 소유자를 성공적으로 인증한 후 액세스 토큰을 발급하는 서버 (카카오, 네이버 등의 인증 서버)
OAuth2.0에서 Client는 Authorization server에게 4가지 방법으로 토큰 발생을 요청할 수 있다.
Authorization Code Grant (가장 많이 쓰이고 중요함)
- OAuth2.0에서 가장 중요하고, 널리 사용되는 인증 방법 — 이 방법에서 클라이언트는 써드파티 서비스의 백엔드 서버가 됨(Server to Server 연동 방식)
- 백엔드 서버가 존재하는 웹/모바일 서비스에 적합함
- 사용자 인증 후 Callback을 통해 authorization code를 받고, 이를 client-id, client-secret과 함께 Access-Token으로 교환함
- Callback 처리는 백엔드 서버에서 이루어지기 때문에, Access-Token이 외부에 노출되지 않음 (보안상 안전)
- 4단계 처리 Flow
- Authorization Request — 클라이언트(우리 쪽 백엔드 서버)는 사용자를 Authorization Server의 인증 URI로 리다이렉션(아래 파라미터들을 가지고)
- response_type — code 고정
- client_id — Authorization Server에서 클라이언트를 식별하기 위한 식별키
- redirect_uri — Authorization Server에서 처리 완료(Resource Owner인증 완료) 후 우리 쪽 백엔드 서버로 리다이렉션 하기 위한 URL
- scope — 클라이언트가 요구하는 리소스를 정의
- state — 클라이언트는 임의의 문자열을 생성하여 CSRF 공격을 방지함
- Authorization Response — 클라이언트에서 요구하는 리소스에 대해 사용자 동의를 받고(사용자가 정상적으로 로그인하고, scope에 명시된 리소스에 대해 접근 승인을 받게되면) , 요청과 함께 전달된 redirect_uri로 리다이렉션
- code — Access-Token 교환을 위한 승인 코드
- state — 요청과 함께 전달 된 임의의 문자열
- Token Request — 승인 코드를 Access-Token으로 교환(클라이언트 백엔드 서버는 1회성 승인 코드를 이용해서 Authorization Server의 Access-Token발급 uri를 호출함)
- grant_type — authorization_code 고정 (OAuth 어떤 방식으로 grant하는지 그 종류)
- code — 앞 단계에서 전달 받은 코드
- client_id — Authorization Server에서 클라이언트를 식별하기 위한 식별키
- client_secret — 클라이언트 비밀키
- Token Response — Access-Token 및 부가정보 획득(Authorization Server에서 클라이언트 백엔드서버로 access_token과 refresh_token을 넘겨줌)
- access_token — 리소스 요청에 필요한 토큰 (보통 짧은 생명주기를 지니고 있음)
- refresh_token — Access-Token을 갱신하기 위한 토큰
- Authorization Request - (Access Application, Authentication and Request Authorization, Authentication and Grant Authorization)
- Authorization Response - (Send Authorization Code)
- Token Request - (Request Code Exchange for Token)
- Token Response - (Issue Access Token)
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
위의 파라미터들이 문제가 없다면 Authorization Server는 사용자에게 (카카오, 네이버 등 의)로그인 페이지를 보여주고 인증을 요구하게 됨. 우리쪽 로그인 페이지가 아님. 즉, 카카오나 네이버에 직접 로그인을 하는 것
/login/oauth2/code/kakao ?code=jzcahTyqbAx4zs9pKfBDlGXmB36sPX2YJCNIIw0RKkW_ODsYTQpheSGABo17dHC5rXRD2Qopb9QAAAF76FELEg &state=xI8tRNCSoeiAIw87NaUr5foPbhBhW2METzHDBK75jgo%3D
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"

Implicit Grant
Client Credentials Grant
Resource Owner Password Credentials Grant
OAuth2 설정하기
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 연동 정보가 저장되어 있음
OAuth2ClientProperties
에서 application.yaml의 값들 @ConfigurationProperties 통해서 매핑하고 그걸 가져옴- 인증 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()); }
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (!requiresAuthentication(request, response)) { chain.doFilter(request, response); return; } /* ... */ } protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { if (this.requiresAuthenticationRequestMatcher.matches(request)) { return true; } if (this.logger.isTraceEnabled()) { this.logger .trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher)); } return false; } // requiresAuthenticationRequestMatcher = Ant[pattern='/login/oauth2/code/*']
- 해당 Repository의 기본 구현은 Session에 저장하는 것이고 해당 구현이 하는 일은 AuthorizationRequest를 request 간(처음 로그인 요청 페이지로 리다이렉트 할 때, 그리고 로그인이 완료되고 Token Request를 보낼 때 사이에)에 공유할 수 있도록 잠시 저장하는 역할을 하는 것임
- 즉, OAuth2AuthorizationRequestRedirectFilter에서 AuthorizationRequest를 만들어서 요청을 하고 repository에 저장 후,
- OAuth2LoginAuthenticationFilter에서 해당 AuthorizatinoRequest에서 providerId뽑아내고 Token을 만들때 사용됨
String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID); ClientRegistration clientRegistration = this.clientRegistrationRepository .findByRegistrationId(registrationId); /* .. */ OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)); authenticationRequest.setDetails(authenticationDetails); OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this .getAuthenticationManager().authenticate(authenticationRequest);
- OAuth2.0 인증 처리를 명시적으로 나타내는 Authentication 인터페이스 구현체
- 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(Out Of Memory Error) 발생 가능성이 있음
- 또한 인증된 사용자 정보가 특정 서버 메모리에만 저장되고 있기 때문에 특정 서버 장애 발생 시 사이드 이펙트가 발생할 수 있음
- 다행히 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 같은 정보도 포함되 있음


코드로 보는 흐름 설명
- 어플리케이션 로그인 화면에 접근

<tr><td><a href="/oauth2/authorization/kakao">kakao</a></td></tr>
- kakao 로그인 누를 시 위 url로 요청
- 이 request를 RequestRedirectFilter에서 잡아서 AuthorizationRequest를 만듦
- 만들어진 AuthorizationRequest를 AuthorizationRequestRepository에 저장하고 configuration 해둔 카카오의 인증 url(아래 코드블럭)로 요청을 리다이렉트 시킴
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request); if (authorizationRequest != null) { this.sendRedirectForAuthorization(request, response, authorizationRequest); return; } /* ... */ } } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { String registrationId = this.resolveRegistrationId(request); if (registrationId == null) { return null; } String redirectUriAction = getAction(request, "login"); return resolve(request, registrationId, redirectUriAction); } private String resolveRegistrationId(HttpServletRequest request) { if (this.authorizationRequestMatcher.matches(request)) { return this.authorizationRequestMatcher.matcher(request).getVariables() .get(REGISTRATION_ID_URI_VARIABLE_NAME); } return null; } // this.authoriztionRequestMatcher // AntPathRequestMatcher "Ant[pattern='/oauth2/authorization/{registrationId}'"
https://kauth.kakao.com/oauth/authorize?response_type=code &client_id=80b063bff8c23a47b2e3674ab70fb32d &scope=profile_nickname%20profile_image &state=LZreiXdVTY7-8RGDkMoQfALyRVTaDO9gT3pgVBoBxB0%3D &redirect_uri=http://localhost:8080/login/oauth2/code/kakao
- 카카오 로그인 페이지에서 로그인 함

- 카카오 로그인이 완료되면 인증서버에서 이전에 보냈던 AuthorizationRequest에 담겨있던 redirect_url로 request를 보내고 인가가 됨 → OAuth2LoginAuthenticationFilter에서 request를 가로챔
- OAuth2LoginAuthenticationFilter에서 AuthorizationRequestRepository에서 이전의 AuthorizationRequest를 조회하여 AuthorizationResponse 만듦
OAuth2LoginAuthenticationProvider
내부에서 Token Request보내고 Token Response 받음- 해당 Token Response를
OAuth2LoginAuthenticationToken
객체로 변환
/login/oauth2/code/kakao ?code=jzcahTyqbAx4zs9pKfBDlGXmB36sPX2YJCNIIw0RKkW_ODsYTQpheSGABo17dHC5rXRD2Qopb9QAAAF76FELEg &state=xI8tRNCSoeiAIw87NaUr5foPbhBhW2METzHDBK75jgo%3D
Github OAuth2.0
Github OAuth application 생성

application.yaml 설정과 Spring Security에서 oauth2Login 설정
spring: security: oauth2: client: registration: github: clientId: <github-app의 ClientId> clientSecret: <github-app의 clientSecret>
@Override protected void configure(HttpSecurity http) throws Exception { http.cors() .and() .csrf().disable() .httpBasic().disable() /// .sessionManagexment().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .oauth2Login() .and() .authorizeRequests() .antMatchers("/", "/sign??").permitAll() .anyRequest().authenticated(); http.addFilterAfter(jwtAuthenticationFilter, CorsFilter.class); }
- OAuth2 가 인증이 되면, user 정보를 Session에다가 넣어버려서 session이 없으면 AuthenticationPrincipal이 null 이 됨! Session 사용해야함
- Github User email 이 null로 올때 해결방법 [ Github user email is null, despite user:email scope ]
OAuth의 인증정보를 DB에 저장하기
OAuth2UserService
를 implements
하여 커스텀 로직을 구현하면 됨
@Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { // OAuth2User 추출 OAuth2UserService delegate = new DefaultOAuth2UserService(); OAuth2User oAuth2User = delegate.loadUser(userRequest); log.info("loadUser== {}", oAuth2User.toString()); // OAuth2 정보 확인 String registrationId = userRequest.getClientRegistration().getRegistrationId(); String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() .getUserInfoEndpoint().getUserNameAttributeName(); OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); // 해당 attribute로 User 생성 or 수정 User user = saveOrUpdate(attributes); // 세션에 유저 정보 저장 httpSession.setAttribute("login_user", new SessionUser(user)); return new DefaultOAuth2User( Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())), attributes.getAttributes(), attributes.getNameAttributeKey()); }