OAuth 로그인이란?
보통 우리가 직접 로그인을 구축할 때는 사용자에게 아이디 또는 이메일 & 비밀번호를 입력하게 만든다. 그런데 사용자가 쓰는 서비스가 한두 개도 아닌데, 그 서비스마다 새로운 아이디 또는 이메일과 비밀번호를 입력해야 하면 불편할 것이다. 이때 제3의 서비스에게 이러한 인증 과정을 맡길 수도 있다. 즉, 사람들이 이미 많이 쓰고 있는 서비스인 구글/카카오/네이버로 로그인을 하면 우리 서비스에서도 로그인이 된 것처럼 처리하는 것이다. 결국 로그인 및 회원가입을 하는 이유가 사용자 고유의 정보로 사용자 계정을 식별하기 위한 것이므로, 이미 회원가입이 되어 있는 다른 서비스에서 대신 로그인하고 그 서비스에서 사용자 고유 정보(이메일, 닉네임 등)을 받아오는 것이다.
OAuth는 바로 이러한 인증 방식을 뜻하는 말이다. 따라서 내 백엔드 서버는 구글/카카오/네이버의 OAuth 서버와 통신하면서 로그인 과정이 처리되면 사용자의 정보를 받아온다.
Google Cloud / Kakao Developers 설정하기
글을 시작하기에 앞서, Google / Kakao OAuth 로그인을 구축하려면 Google Cloud나 Kakao Developers에서 먼저 해줘야 하는 설정이 있다. 이는 아래 글에 미리 자세하게 작성해놓았으므로, 아래 글을 먼저 읽고 OAuth 설정을 완료해주도록 하자. (📍중요! 아래 글에서 언급한 것과 달리, redirect URI는 https:/[벡엔드 도메인 네임]/login/oauth2/code/[google 또는 kakao]
로 설정해야 한다.)
Spring Security를 사용하는 것의 장점
OAuth 2.0의 로그인/회원가입 과정은 다음과 같이 이루어진다. 보안 및 인증을 위한 Authorization Code, Redirect URI를 사용해서 Client / Backend Server / OAuth Server와의 정보 교환을 통해 이루어진다고 보면 된다.
모든 것은 결국 API 호출을 통한 정보 전달이므로, 직접 한땀한땀 이렇게 API 호출 등을 통해서 OAuth를 구축해줘도 된다. (직접 구축하고 싶다면 위에서 소개한 글의 Part 2를 보도록 하자.) 하지만 Spring Boot에서 제공하는 라이브러리를 통해서 훨씬 더 간편하게 OAuth를 구축할 수도 있으므로, 이 글에서는 spring-boot-starter-oauth2-client
라이브러리를 사용해서 개발하려고 한다.
참고로 깃허브 코드를 보면서 설명을 읽고 싶은 사람들은 아래 링크를 확인하면 된다:
build.gradle에 종속성 설정
먼저 build.gradle에 종속성을 설정해준다. OAuth 라이브러리만 따지자면 'org.springframework.boot:spring-boot-starter-oauth2-client'
만 필요하고, OAuth로 받아온 정보를 토대로 로그인/회원가입 프로세스를 진행하야 하니까 그냥 Security 라이브러리인 'org.springframework.boot:spring-boot-starter-security'
도 필요하다.
dependencies {
//Security
implementation 'org.springframework.boot:spring-boot-starter-security'
//OAuth
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
application.yml 구성
이제 application.yml을 구성해주면 된다. 참고로 application.yml에 민감한 값들이 많이 들어가게 되므로 이 파일은 절대 깃허브에 올리면 안된다. application.yml 파일을 .gitignore에 등록하는 것을 추천한다.
아래 파일에서 위의 구글 클라우드 / kakao Developer에서 설정했던 클라이언트 ID 및 시크릿 값을 넣어주면 된다. 참고로, spring:
아래에 있는 yml 데이터 구조들은 변경해서는 안된다. 이는 스프링 시큐리티 OAuth 라이브러리에서 정해진 방식이다. 하지만 front-end:
와 back-end:
로 시작하는 코드는 그냥 내가 넣은 부분인데, 이 부분 데이터 구조는 변경해도 된다.
spring:
# /login/oauth2/code/{provider} 경로에서 code 처리
security:
oauth2:
client:
registration:
google:
client-id: [client id 값]
client-secret: [client secret 값]
redirect-uri: https://[backend 도메인 네임]/login/oauth2/code/google
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
client-name: Google
scope: # 변경 가능
- profile
- email
kakao:
client-id: [client id 값]
client-secret: [client secret 값]
redirect-uri: https://[backend 도메인 네임]/login/oauth2/code/kakao
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
client-name: Kakao
scope: # 변경 가능
- profile_nickname
- profile_image
- account_email
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/auth
token-uri: https://oauth2.googleapis.com/token
user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo
user-name-attribute: id
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
front-end:
redirect-url: [redirect-uri 값 == frontend 도메인 네임]
back-end:
server-name: [backend 도메인 네임]
설정들을 잘 보면, 하나의 프로바이더 (Google/Kakao)에 대해서 두 가지 설정을 가지고 있다. 첫 번째는 provider:
아래에 있는 값들로, 위에서 OAuth 과정을 설명한 표에서 볼 수 있듯이 OAuth 로그인을 하려면 Authorization Code나 Access Token 값 등을 Google/Kakao가 운영하는 OAuth 서버에서 받아와야 한다. 이는 다른 서버를 API 호출해 값을 받아오는 것이므로, 당연히 API endpoint가 필요하다. provider:
아래의 값들은 그러한 엔드포인트들을 명시한 것이다. 우리가 설정해주는 값이 아니므로 변경해서는 안된다.
registration:
아래에 있는 값들은 내가 Google Cloud / Kakao Developers에서 설정해줬던 클라이언트 ID 및 시크릿 값을 넣어주고, 이 값들을 사용해서 authentication을 진행할 것이라는 뜻이다. authentication이 정상적으로 완료되면 유저의 정보 중 어떤 값들을 받아올 것인지 결정해야 하는데, scope:
아래에 그 값들을 명시하면 해당 값을 반환받을 수 있다. 여기서는 구글 로그인에 대해서 프로필 사진과 이메일, 카카오 로그인에 대해서는 닉네임, 프로필 이미지, 이메일을 가져온다.
SecurityConfig.java 설정하기
OAuth를 사용하기 위해서는 SecurityFilterChain에 설정해줘야 한다. 이때 OAuth가 일어나면 일어날 엔드포인트 / OAuth가 성공 또는 실패하면 처리할 핸들러를 아래 코드처럼 등록해주면 된다. 이때 보낼 엔드포인트와 핸들러는 모두 내가 앞으로 구현한 Java Class의 이름으로 만들어주면 된다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2CustomUserService oAuth2CustomUserService;
private final RefreshTokenService refreshTokenService;
@Bean
public OAuthSuccessHandler oAuthSuccessHandler(){
return new OAuthSuccessHandler(jwtTokenProvider(), refreshTokenService);
}
@Bean
public OAuthFailureHandler oAuthFailureHandler(){
return new OAuthFailureHandler();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.oauth2Login((oauth2) -> oauth2 //oauth가 일어나면 보내줄 엔드포인트를 설정한다.
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
.userService(oAuth2CustomUserService))
.successHandler(oAuthSuccessHandler()) //성공할 경우
.failureHandler(oAuthFailureHandler()) //실패할 경우
);
return httpSecurity.build();
}
}
참고로 당연히 원래 SecurityConfig.java에는 CORS 설정 등 더 많은 내용이 있지만, 위의 코드는 OAuth와 관련된 부분만 가져온 것이다. 전체 코드를 보고 싶다면 아래를 참고하면 된다.
OAuth2CustomUserService.java 구현하기
이제 OAuth 로그인이 들어오면 Security Fliter Chain이 전달해줄 엔드포인트인 OAuth2CustomUserService
를 만들어보자. 이 클래스는 DefaultOAuth2UserService
를 상속받아 만들고, 해당 클래스 안에서 loadUser 메소드를 구현해야 한다. loadUser 메소드 안에서 OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);
를 사용하면, 리소스 서버에 사용자 정보 요청하고 그 결과로 리소스 객체(OAuth2User
)를 받을 수 있다.
무슨 뜻인가 하면, 우리는 사용자가 OAuth 로그인을 하면 그 로그인을 제공하는 provider의 리소스 서버에서 사용자 고유의 정보를 받아와야 하는데, 이 값을 받아오는 과정을 처리해준다고 할 수 있다. 보통 리소스 서버에서 아래와 같은 JSON 구조로 값을 받아온다. (NOTE: 2024년도에 확인한 내용이므로, 나중에 달라져 있을 수도 있음)
-------------google response------------
{
"id" : "00000000000000000000",
"email" : "sample@gmail.com",
"verified_email" : true,
"name" : "홍길동",
"given_name" : "길동",
"family_name" : "홍",
"picture" : "https://url 경로",
"locale" : "ko"
}
-------------kakao response------------
{
"id":00000000000000000000,
"properties":{
"nickname":"홍길동",
"profile_image":"https://url 경로"
},
"kakao_account":{
"email":"sample@gmail.com",
}
}
-------------naver response------------
{
"response": {
"email": "sample@gmail.com",
"nickname": "홍길동",
"profile_image": "https://url 경로"
"id": "00000000000000000000",
"name": "홍길동"
}
}
oAuth2UserRequest
를 보낸 후 사용자 정보를 OAuth2User
에 받아오고, getAttribute()
에서 실제 값을 Map 구조로 만들 수 있다. 참고로 oAuth2UserRequest
를 분석해서 공급자 정보 및 사용자 정보 엔드포인트 등도 알 수 있다.
@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2CustomUserService extends DefaultOAuth2UserService {
private static final String DEFAULT_STRING = "default";
private final MemberRepository memberRepository;
private final MemberService memberService;
/* OAuth2 로그인 요청에 필요한 정보(클라이언트 등록 정보, 사용자의 권한 부여 코드, 액세스 토큰)를 가지고 있는 객체 OAuth2UserRequest*/
@Override
public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
/*리소스 서버에 사용자 정보 요청 후 리소스 객체(OAuth2User) 받아오기*/
OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);
Map<String, Object> attributes = oAuth2User.getAttributes();
attributes.forEach((key, value) -> log.info(key + ": " + value));
/*공급자 정보인 registrationId(구글, 카카오, 네이버)*/
String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId();
/*사용자 정보 엔드포인트인 userInfoEndpoint*/
ClientRegistration.ProviderDetails.UserInfoEndpoint userInfoEndpoint = oAuth2UserRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint();
String password = DEFAULT_STRING;
String email = DEFAULT_STRING;
String picture = DEFAULT_STRING;
String nickname = DEFAULT_STRING;
String name = DEFAULT_STRING;
if (Objects.equals(registrationId, "google")){
password = (String) attributes.get("id");
email = (String) attributes.get("email");
picture = (String) attributes.get("picture");
nickname = (String) attributes.get("name");
name = (String) attributes.get("name");
}
if (Objects.equals(registrationId, "kakao")){
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
password = String.valueOf(attributes.get("id"));
email = (String) kakaoAccount.get("email");
picture = (String) properties.get("profile_image");
nickname = (String) properties.get("nickname");
name = (String) properties.get("nickname");
}
Member member;
/*이메일로 회원 가입 여부 확인*/
if (!memberRepository.existsByEmail(email) && !Objects.equals(password, DEFAULT_STRING)) {
// 이메일 알림 기능은 디폴트로 true -> 테스트 안 해볼 사람들에게 보내주기 위해서
memberService.createMember(email, password, picture, nickname, name, true, false);
}
member = memberRepository.findMemberByEmail(email);
return new AuthDetails(member, attributes, Collections.singleton(new SimpleGrantedAuthority(member.getMemberRole().getRole())));
}
}
사용자 정보를 받아온 후에는 이를 통해서 일반적인 Spring Security 로그인 및 회원가입을 처리해주면 된다. 나는 OAuth 로그인 후 만약 우리 서비스에 회원가입이 안되어 있는 사용자라면 자동으로 회원가입을 해주고(createMember
에서 Member 객체 생성 후 DB에 저장하는 로직), 그 후에 로그인 처리를 할 수 있게 만들었다. 리턴값으로는 AuthDetails(member, attributes, Collections.singleton(new SimpleGrantedAuthority(member.getMemberRole().getRole())));
를 통해서 AuthDetails를 생성해주었다. AuthDetails 객체를 반환하면 Spring Security가 이를 인증 컨텍스트에 저장하고 인증된 사용자로 간주할 수 있도록 처리해 주기 때문에 리턴값은 AuthDetails 객체로 해주어야 한다.
OAuth가 아니라 Spring Security를 구현한 코드를 보고 싶다면, 아래 경로를 참고하면 된다:
OAuthSuccessHandler.java 구현하기
OAuth 인증에 성공하면 시큐리티가 접근 시켜주는 클래스이다. SavedRequestAwareAuthenticationSuccessHandler
를 상속받고, onAuthenticationSuccess
를 구현해주면 된다. OAuth 로그인 성공시 일어나는 일련의 과정들은 무엇일까? 이는 우리가 일반적으로 JWT 토큰을 발행해주는 그 과정과 같다. 인증에 성공한 사용자에게 JWT 토큰을 발행해주고, (구현했다면) 리프레시 토큰을 저장하고, 그리고 JWT 토큰을 사용자에게 전달해주면 된다.
그 후에 redirect URI 부분이 좀 중요한데, 이제 프론트엔드에게 JWT 토큰을 돌려주어야 하므로 URL을 프론트엔드 도메인 네임으로 돌려주어야 한다. 이때 프론트엔드에서는 frontRedirectUrl + "/oauth/google/success/ing"
이런 임시적으로 JWT 토큰만 받는 경로를 만들어서 JWT 토큰을 받으면 된다.
@Slf4j
@RequiredArgsConstructor
public class OAuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
@Value("${frontend.redirect-url}")
private String frontRedirectUrl;
@Value("${backend.server-name}")
private String backServerName;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
/*인증에 성공한 사용자*/
AuthDetails oAuth2User = (AuthDetails) authentication.getPrincipal();
/* JwtToken 생성*/
JwtToken jwtToken = jwtTokenProvider.generateJwtToken(oAuth2User.getUsername());
/* RefreshToken 저장하기 */
refreshTokenService.saveRefreshToken(jwtToken.getJwtRefreshToken(), oAuth2User.getUsername());
/*JwtToken과 함께 리다이렉트*/
String targetUrl = UriComponentsBuilder.fromUriString(setRedirectUrl(request.getServerName()))
.queryParam("jwtAccessToken", jwtToken.getJwtAccessToken())
.queryParam("jwtRefreshToken", jwtToken.getJwtRefreshToken())
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
private String setRedirectUrl(String url) {
String redirect_url = null;
/* 백엔드 개발 환경 */
if (url.equals("localhost")) {
redirect_url = "http://localhost:8080/oauth/google/success";
}
/* 프론트 개발 또는 프로덕션 환경 */
else {
log.info("backServerName: " + backServerName);
log.info("url: " + url);
log.info("frontRedirectUrl: " + frontRedirectUrl);
redirect_url = frontRedirectUrl + "/oauth/google/success/ing";
}
return redirect_url;
}
}
이제 프론트엔드에서는 다음과 같은 코드만 호출해주면 백엔드가 만들어놓은 일련의 OAuth 로그인 과정 처리를 사용할 수 있다. 그리고 결과로 오는 JWT 토큰만 받아서 처리하면 된다.
window.location.href = "https://[백엔드 도메인 네임]/oauth2/authorization/[google 또는 kakao]"
프론트엔드가 JWT 토큰을 받아서 처리하는 코드는 아래를 참고하면 된다:
OAuthFailureHandler.java 구현하기
OAuth가 실패하면 시큐리티가 보내주는 클래스이다. AuthenticationFailureHandler
를 상속받고, onAuthenticationFailure
메소드를 구현하면 된다. 완성도를 높이기 위해서는 에러처리를 해주는 게 좋은데, 나는 OAuthVerificationException
라는 커스텀 에러를 만들어서 처리해주었다.
@Slf4j
@RequiredArgsConstructor
public class OAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
throw new OAuthVerificationException(exception.getMessage());
}
}
해당 에러 클래스 관련 코드를 보고 싶다면, 아래 경로에서 보면 된다:
OAuth2 Client를 통한 로그인/회원가입 과정
결론적으로, oauth2 client 라이브러리를 사용한 로그인 및 회원가입 과정은 다음과 같이 처리된다고 볼 수 있다. 클라이언트가 Authorization Code를 처리하는 부분이 없어져 프론트 개발자가 더 편하게 사용할 수 있고, 보안 측면에서도 더 낫다.