Search
🔐

OAuth2.0로 SSO 구현하기

Person
태그
소셜로그인
OAuth2.0

OAuth2.0을 구현하게 된 계기

처음에는 구글 로그인을 도입하려 하다가 백엔드 팀원이 3명인 관계로 네이버와 카카오까지 3개의 소셜 로그인 구현을 하기로 하였다.
이전에 구글 로그인을 RestApi 형식으로 구현해본 적이 있어서 접근은 크게 어렵지 않았으나
3가지의 소셜 로그인 구현을 사용하는 만큼 코드 생산성을 높이기 위해 OAuth2.0 프로토콜을 사용하여 SSO를 구현해보기로 하였다.

SSO (Single sign-on)란?

처음 우리가 구현하려고 했던 소셜 로그인 로직은 다음과 같다. 사용자가 로그인하고자 하는 소셜 로그인 각 서버의 계정을 사용해야 한다.
이럴 경우 각각 다른 인증 서비스를 거쳐야 하기 때문에 매우 번거롭다.
why? 각 서버 별로 관리하게 된다면 각각의 서버에 필요한 보안 솔루션이 적용되어야 하기 때문이다. 또한 사용자 관점에서도 마찬가지로 각 계정 정보를 기억해야 하기 때문에 편의성과는 거리가 멀어진다.
이런 문제를 해결하기 위해 등장한 것이 바로 하나의 사용자 정보를 기반으로 여러 시스템을 하나의 통합 인증을 거쳐 사용할 수 있도록 하는 SSO이다.
SSO는 아이디 패스워드를 중앙에서 효율적으로 관리하며 사용자 편의성을 증가시킨다.
OAuth2.0은 SSO 를 구현하는 방법 중의 하나로 인증을 위한 개방형 표준 프로토콜이다.
OAuth2.0을 사용한다면 사용자는 하나의 애플리케이션 가입으로 다른 애플리케이션의 권한을 부여 받을 필요 없이 기타 다양한 애플리케이션에서 권한을 행사할 수 있다.
무슨 말인가 하면 로그인이나 개인 정보 관리에 대한 책임을 ‘Third-Party Application(google/kakao/naver)’에 위임할 수 있다는 것을 의미한다.
즉, 우리 프로젝트에서 kakao 로그인을 통해 로그인 했다면 사용자가 넘겨준 토큰을 사용하여 애플리케이션에서 kakao 서버로부터 해당 사용자의 정보를 조회할 수 있다.

OAuth2.0 구현하기

1.
OAuth2.0의 구성 요소와 인증 방식
우선 OAuth의 개념을 이해하기 위해 아래 4가지의 구성 요소를 알고 있어야 한다.
Resource Server
Resource Owner
Client
Authorization Server
Resource Server
자원 서버는 보호된 정보를 제공하는 서버이다. (네이버, 카카오, 구글)
Resource Owner
자원 서버에 계정을 가지고 있는 사용자를 의미하며 클라이언트가 이들의 계정을 통해 자원 서버에 접근하는 것을 인가(Authorize) 한다. 소셜 로그인 계정을 소유하고 있는 사용자.
Client
클라이언트는 자원 서버, 그러니까 웹 애플리케이션의 서비스를 사용하고자 하는 애플리케이션을 의미한다.
Authorization Server
클라이언트가 자원 서버의 서비스를 사용할 때 사용하는 접근 토큰(Access Token)을 발행한다.
자원 서버에 접근하기 위해서는 토큰이 필요하다. 이 토큰을 또 요청하기 위해서는 자원 소유자로부터 인가를 받아야 하는데 OAuth는 다양한 인가 승인 유형을 제공한다. 대표적으로 4개가 있는데 프로젝트에서는 가장 보편적인 방식인
Authorization Code Grant 방식으로 구현하였다.
로직을 살펴보면 다음과 같다.
1.
클라이언트가 권한 서버에 response=code로 접근 권한을 요청하면 권한 서버에서 제공하는 로그인 페이지를 브라우저에 띄운다.
2.
사용자는 로그인 페이지를 통해 로그인을 시도한다.
3.
권한 서버는 클라이언트로부터 온 접근 권한 요청 시 전달 받은 redirect_uri로 Authorization Code를 전달한다.
4.
클라이언트는 전달 받은 Authorization Code로 자원 서버에 Access Token을 요청하고 자원 서버는 다시 이를 클라이언트로 전달한다.
5.
클라이언트는 전달 받은 Access Token을 통해 자원 서버의 자원에 접근할 수 있게 된다.

Authorization Code는 왜 필요한가?

만약 Authorization code의 발급 없이 Access Token을 바로 발급 받는다면 redirect uri를 통해 전달받게 되는데 이렇게 되면 브라우저에 그대로 Access Token이 노출되기 때문에 탈취의 위험이 생겨버린다..!
그래서 이를 보완하기 위해 Authorization Code를 사용한 것이다.
redirect uri를 통해 Authorization Code를 전달 받으면 이는 프론트에서 백엔드로 전달되고 그럼 백엔드는 이 코드를 통해 자원 서버에 접근하여 Access Token을 발급 받게 된다.

동작 메커니즘

의존성 추가 및 provider 추가
스프링부트에서는 oauth2-client 라이브러리를 통해 OAuth2.0 프로토콜을 구현할 수 있는 기능을 제공한다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
Java
복사
ex) 카카오
security: oauth2: client: registration: kakao: client-id: "클라이언트 아이디" client-secret: "시크릿코드" redirect-uri: "콜백 uri" authorization-grant-type: authorization_code client-authentication-method: POST client-name: Kakao scope: - profile_nickname - account_email - profile_image
Java
복사
Spring Security 설정 추가
http .oauth2Login() .authorizationEndpoint().baseUri("/oauth/authorize") .and() .redirectionEndpoint().baseUri("/oauth/callback") .and() .userInfoEndpoint() // oauth2 로그인 성공후에 사용자 정보를 바로 가져온다. .userService(customOAuth2UserService) .and() .successHandler(oAuth2AuthenticationSuccessHandler);
Java
복사
소셜 로그인 실행 전 각 소셜 로그인의 개발자 도구에 들어가 제공받을 정보, redirect uri 등을 설정해야 하는데
여기서는 “/oauth/callback”으로 통일하였다.
authorizationEndpoint의 경우 spring security에서 권장하는 방식 그대로 사용하였다.
프론트에서 “/oauth/authorize/naver”로 각 소셜 로그인 페이지로 접근할 수 있다.
OAuth2 유저 정보 설정
OAuth2User를 구현한 커스텀 코드이다.
받아온 유저의 provider를 통해 어느 소셜 로그인 계정인지를 구분한 후 사용자 정보를 가져온다.
[CustomOAuth2User]
@Data public class CustomOAuth2User implements OAuth2User { private Member user; private Map<String, Object> attributes; //OAuth 로그인 생성자 public CustomOAuth2User(Member user, Map<String, Object> attributes) { this.user = user; this.attributes = attributes; } @Override public <A> A getAttribute(String name) { return OAuth2User.super.getAttribute(name); } @Override public Map<String, Object> getAttributes() { return attributes; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return Collections.singleton(new SimpleGrantedAuthority("ROLE_MEMBER")); } @Override public String getName() { if (user.getProvider().equals("kakao")){ return ((Map<?, ?>) attributes.get("properties")).get("nickname").toString(); } else if (user.getProvider().equals("google")){ return attributes.get("name").toString(); } else if (user.getProvider().equals("naver")) { return ((Map<?, ?>) attributes.get("response")).get("nickname").toString(); } return null; } }
Java
복사
각 소셜 로그인 계정에 따른 사용자 정보를 가져오기 위해 userInfo 인터페이스를 만들고 이를 구현한다.
다음은 카카오 예시이다.
[OAuth2UserInfo]
public interface OAuth2UserInfo { String getProviderId(); String getProvider(); String getProfile(); String getEmail(); String getName(); String getPassword(); }
Java
복사
[KakaoUserInfo]
@AllArgsConstructor public class KakaoUserInfo implements OAuth2UserInfo { private Map<String, Object> attributes; @Override public String getProviderId() { return attributes.get("id").toString(); } @Override public String getProvider() { return "kakao"; } @Override public String getProfile() { Map<String, Object> kakaoAccount = (Map<String, Object>)attributes.get("kakao_account"); Map<String, Object> kakaoProfile = (Map<String, Object>)kakaoAccount.get("profile"); return (String) kakaoProfile.get("profile_image_url"); } @Override public String getEmail() { return (String) ((Map) attributes.get("kakao_account")).get("email"); } @Override public String getName() { Map<String, Object> kakaoAccount = (Map<String, Object>)attributes.get("kakao_account"); Map<String, Object> kakaoProfile = (Map<String, Object>)kakaoAccount.get("profile"); return (String) kakaoProfile.get("nickname"); } @Override public String getPassword(){ return "kakao";} }
Java
복사
Service와 SuccessHandler 구현하기
[CustomOAuth2UserService]
public class CustomOAuth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { // 로그인 로직 실행 return new CustomOAuth2User(member.get(), oAuth2User.getAttributes()); } }
Java
복사
Spring Security가 Access Token을 이용해 OAuth2 서버에서 유저 정보를 가져온 후 loadUser 메서드를 통해 그 유저의 정보를 가져온다.
사용자 정보를 확인 후 만약 정보가 데이터베이스에 존재하지 않는다면 회원 가입을, 존재한다면 로그인을 해야 한다.
회원가입과 로그인 로직을 분리하기 위해 서비스 단에서 회원가입을 진행하였고
회원가입에 성공하면 successHandler로 이동하여 로그인을 할 수 있도록 처리하였다.
[OAuth2AuthenticationSuccessHandler]
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal(); String provider = customOAuth2User.getUser().getProvider(); String email = null; if (provider.equals("kakao")) { //카카오 } else if (provider.equals("google")) { // 구글 } else if (provider.equals("naver")) { // 네이버 } String targetUrl = // 리다이렉트URL // 로그인 처리 // JSON 형태로 변환하여 응답 보내기 response.setContentType("application/json;charset=UTF-8"); PrintWriter writer = response.getWriter(); writer.write(objectMapper.writeValueAsString(authResponse)); writer.flush(); // 리다이렉트 수행 super.onAuthenticationSuccess(request, response, authentication); } }
Java
복사

성공 화면

궁금한 점

1.
별도의 API 요청 없이 Authorization code를 어디서 받아오는가?
위에서 설명했듯이 보통은 프론트에서 자원 서버에 접근하여 Authorization Code를 받아오고 이를 백엔드로 넘겨준다. 그런데 OAuth2.0을 구현했을 때는 별도의 api 호출 없이 프론트에서는 단지 버튼 클릭 한번만으로 모든 과정이 이루어졌다.
왜 그런 것일까?
코드를 뜯어보자..
2.
네이버 로그인에서 404가 뜨는 이유? → 해결
네이버 로그인 시 동의하기 버튼을 누르면 404 페이지가 발생하였다.. 아무리 생각해도 이상해서 콘솔로 확인해봤는데 로그인 후 응답요청이 온 다음 인코딩되어 한번 더 오고 있었다. 에러 원인을 확인해보니 Access Token Response 에러 였다. 그래서@@ yml 파일의 client-authentication-method: post 를 지워주니 해결할 수 있었다. ** post의 경우 클라이언트가 서버에게 ClientSecret을 인코딩하여 전송해준다고 한다. 그래서 인코딩 문제가 발생하는 것이었다..! ** 내가 구현했던 카카오의 경우 시크릿 키가 활성화되어있을 때 에러 핸들링이 되어버려서 그냥 빼놓았었는데.,, 이것도 비슷한 문제였던 걸까 싶다. 카카오의 경우 post인데 네이버의 경우는 basic을 사용해야 한다고 한다. 그런데 기본값과 같아서 그냥 지워도 상관없음!
Java
복사