OAuth2.0 프로토콜을 사용한 SSO 구현기 - 코드 생산성을 높이자!
SSO 를 이해하고 OAuth2.0 을 이용하여 여러 소셜 로그인을 한번에 구현해요.
1. Rest Api 로 구현한 소셜 로그인 동작 과정
1.1. 로그인 화면 제공
- 사용자가 구글 계정 정보를 입력할 수 있는 로그인 폼을 제공합니다.
- 이 과정에서 프론트엔드는 사용자의 이메일과 비밀번호를 수집하여 백엔드로 전달합니다.
1.2. 자격 증명 전송
- 백엔드는 받은 정보를 사용하여 구글 서버에 직접 인증 요청을 보냅니다.
- 구글 서버로부터 응답을 받아 사용자가 유효한지 확인합니다.
1.3. 토큰 발급
- 구글 서버로부터 유효한 자격 증명이라는 것이 확인되면, 인증 토큰을 생성하여 클라이언트에 반환합니다.
- 이 토큰을 사용하여 이후 API 요청 시 사용자 인증을 처리합니다.
1.4. 데이터 저장 및 관리
- 인증이 성공하면 사용자의 정보를 데이터베이스에 저장하거나 세션을 관리합니다.
이렇게 RES Api 로 구현해도 되지만 아래와 같은 단점이 있습니다.
- 프론트가 중간에 끼어들게 되면서 비밀번호가 노출될 가능성이 있습니다.
- 사실 이것 만으로도 큰 리스크 입니다.
- 카카오 API 는 OAuth2.0 기반이라 괜찮긴 합니다.
- 소셜 로그인마다 토큰 발급 요청 시 보내야 하는 내용이 다릅니다.
- 유지 보수에 용이하지 않으며 효율성이 떨어집니다.
그래서 저는 OAuth2.0 프로토콜을 사용하여 SSO 를 구현하였습니다.
2. SSO(Single sign-on) 가 뭔데요? 🤔
SSO 가 있는 경우와 없는 경우를 도식화한 그림입니다. 벌써 부터 울고 있는데요.
SSO (Single sign-on) 의 줄임말 입니다. 말 그대로 하나의 통합 인증을 거쳐 여러 시스템을 사용할 수 있도록 하는 것이죠. 만약 중간에 길을 알려주는 역할을 하는 단계 없다면 어떻게 될까요?
여러 소셜 로그인을 구현해야 하는 경우를 생각해봅시다. 카카오, 네이버, 구글 등 다양한 소셜 정보를 이용하여 사용자 인증을 마치고 싶지만 SSO 로 구현하지 않는다면 위와같이 REST Api 를 이용해 각 소셜 서비스 별로 Service 를 만들어야 합니다. 위에서 언급했듯이 각 소셜 서버마다 인증을 위해 요구하는 내용이 다르고, 인증방식이 다르기 때문에 확장성이 떨어지며, 유지보수가 용이하지 않죠.
SSO 를 사용하면 어떨까요? SSO 의 경우 유저 정보를 중앙에서 관리하기 때문에 효율성이 높아집니다. 맨처음 진입점이 되며, 이후의 길을 알려주는 안내자가 되는 셈이죠. 이를 구현하기 위해서는 OAuth2.0 프로토콜을 사용합니다.
3. SSO 구현에 OAuth2.0 이 어떤 역할을 하는 걸까요?
OAuth2.0은 SSO 를 구현하는 방법 중의 하나로 인증을 위한 개방형 표준 프로토콜입니다. 이를 통해 사용자의 인증 정보를 관리하고, 다양한 서비스에 접근할 수 있는 권한을 부여할 수 있습니다.
3.1. Spring Security 와 OAuth 2.0 의 통합
스프링 시큐리티는 OAuth 2.0 을 쉽게 통합할 수 있는 기능을 제공합니다. 이를 통해 소셜 로그인을 간편하게 구현할 수 있습니다. 다양한 기능을 제공하지만 여기서는 스프링 시큐리티 OAuth2 client 에 대해서만 이야기하겠습니다. 이를 사용하면 application 이 OAuth 2.0 제공자 즉, 구글, 페이스북 등에 요청을 보내고 인증을 처리한 후 토큰을 받아올 수 있습니다. 그리고 받아온 정보를 이용하여 회원가입 → 로그인 처리를 한 후 Spring Security 를 사용해 프로그램의 다양한 서비스에 대한 인증 인가 절차를 밟을 수 있죠.
3.2. 개인 정보 관리에 대한 책임 위임
OAuth2.0 을 사용하면 로그인이나 개인 정보 관리에 대한 책임을 ‘Third-Party Application’에 위임합니다. 로그인과 관련된 보안을 카카오와 구글에 맡기는겁니다. 이들이 인증 정보를 관리하고 보호해주기 때문에 사용자의 비밀번호나 민감 정보를 저장하거나 처리할 필요가 없습니다. 따라서 보안 위험이 크게 줄어들게 됩니다. 🍀
4. OAuth 2.0 프로토콜을 사용한 소셜 로그인 동작 과정
4.1. OAuth 클라이언트 설정
- 구글 API 콘솔에서 프로젝트를 생성하고, 해당 프로젝트에 대한 OAuth 클라이언트를 설정합니다.
- 발급받은 client ID 와 client secret key 는 백엔드에서 사용합니다.
4.2. 구글 로그인 버튼 노출
- 구글 계정으로 로그인할 수 있는 버튼을 제공합니다.
- 버튼을 클릭하면 사용자는 구글의 OAuth2.0 인증 페이지로 redirect 됩니다.
4.3. 구글 인증 페이지로 Redirect
- 이때 구글 인증 요청 URL 에 client ID, scope, redirect URI 등을 넣어야 합니다.
4.4. 인증 후 Redirect
- 사용자가 인증을 마치면 구글 server 는 인증 코드를 frontend 에 전달합니다.
- 이때 미리 지정된 redirect URI 로 사용자를 redirect 합니다.
- 이 URI 에는 인증 코드가 쿼리 파라미터로 포함되어 있습니다.
4.5. 인증 코드 수신 및 처리
- 프론트엔드에서 구글이 전송한 인증코드를 백엔드에 전달합니다.
- 백엔드는 이 인증코드를 사용하여 구글의 OAuth server 에 access Token 을 요청합니다.
4.6. 토큰 교환
- 백엔드는 구글 OAuth 서버에 access code, client ID, client secret 등을 포함한 요청을 보냅니다.
- 구글 서버는 이 요청을 검증한 후, access token 과 refresh token 을 백엔드에 응답으로 보냅니다.
4.7. 사용자 정보 요청
- 백엔드는 access token 을 사용하여 구글 API 에 사용자 정보를 다시 요청합니다.
- 사용자의 이메일, 이름 등 기본 정보를 받아와서 처리합니다.
4.8. 토큰관리
- 백엔드는 access token 과 refresh token 을 관리하며, access token 이 만료되면 refresh token 을 사용하여 새로운 access token 을 발급 받습니다.
- 사용자가 다시 로그인을 할 필요없이 자동으로 토큰을 갱신할 수 있습니다.
5. 소셜 로그인 구현
5.1. build.gradle
아래와 같이 dependency 를 추가해줍니다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
5.2. application.yml
구글과 카카오에서 발급받은 key 들을 알맞게 넣어줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
spring:
security:
oauth2:
client:
registration:
google:
redirect-uri: ${GOOGLE_REDIRECT_URL}
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
kakao:
client-id: ${KAKAO_REST_API_KEY}
redirect-uri: ${KAKAO_REDIRECT_URL}
client-secret: ${KAKAO_SECRET_KEY}
client-name: Kakao
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
scope:
- account_email
- profile_nickname
- profile_image
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
5.3. CustomOAuth2User.java
OAuth2User 를 커스텀한 코드입니다. provider 를 통해 어느 소셜 로그인 계정인지를 구분하고, 알맞게 사용자 정보를 가져옵니다. 소셜 로그인마다 가져오는 방식이 약간씩 다르기에 구분해주는 과정이 필요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import com.gudgo.jeju.domain.user.entity.User;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
@Getter
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {
private final User user;
private final Map<String, Object> 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_USER"));
}
@Override
public String getName() {
if (user.getProvider().equals("google")) {
return attributes.get("name").toString();
} else if (user.getProvider().equals("kakao")) {
return ((Map<?, ?>) attributes.get("properties")).get("nickname").toString();
}
return null;
}
}
5.4. OAuth2UserInfo.java
다양한 소셜 로그인을 위해 interface 를 만들어 줍니다.

1
2
3
4
5
6
public interface OAuth2UserInfo {
String getEmail();
String getName();
String getPassword();
String getProfile();
}
5.4.1. GoogleUserInfo.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import lombok.RequiredArgsConstructor;
import java.util.Map;
@RequiredArgsConstructor
public class GoogleUserInfo implements OAuth2UserInfo {
private final Map<String, Object> attributes;
@Override
public String getProfile() {
return attributes.get("picture").toString();
}
@Override
public String getEmail() {
return attributes.get("email").toString();
}
@Override
public String getName() {
return attributes.get("name").toString();
}
@Override
public String getPassword() {
return null;
}
}
5.4.2. KakaoUserInfo.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import lombok.RequiredArgsConstructor;
import java.util.Map;
@RequiredArgsConstructor
public class KakaoUserInfo implements OAuth2UserInfo{
private final Map<String, Object> attributes;
@Override
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return kakaoAccount.get("email").toString();
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
return properties.get("nickname").toString();
}
@Override
public String getProfile() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
return properties.get("profile_image").toString(); }
@Override
public String getPassword() {
return null;
}
}
5.5. OAuth2UserInfoFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
@Slf4j
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuthUserInfo(String provider, Map<String, Object> attributes) {
if (provider.equals("google")) {
log.info("=============================================================");
log.info("Google login Request sent");
log.info("=============================================================");
return new GoogleUserInfo(attributes);
} else if (provider.equals("kakao")) {
log.info("=============================================================");
log.info("Kakao login Request sent");
log.info("=============================================================");
return new KakaoUserInfo(attributes);
}
return null;
}
}
5.6. OAuth2SignupService.java
소셜 로그인에서 받아온 정보를 이용해 실제 서비스에 회원가입을 하는 코드입니다. 이는 구현하려는 서비스에 따라 다르니 비즈니스 로직에 맞춰 변경해주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import com.gudgo.jeju.domain.user.entity.User;
import com.gudgo.jeju.domain.user.repository.UserRepository;
import com.gudgo.jeju.global.auth.oauth.entity.CustomOAuth2User;
import com.gudgo.jeju.global.auth.oauth.entity.OAuth2UserInfo;
import com.gudgo.jeju.global.auth.oauth.entity.OAuth2UserInfoFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final OAuth2SignupService oAuth2SignupService;
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(request);
log.info("============================================================================");
log.info("getAttributes: {}", oAuth2User.getAttributes());
log.info("============================================================================");
String provider = request.getClientRegistration().getRegistrationId();
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuthUserInfo(provider, oAuth2User.getAttributes());
Optional<User> user = userRepository.findByEmailAndProvider(provider, oAuth2UserInfo.getEmail());
if (user.isPresent()) {
return new CustomOAuth2User(user.get(), oAuth2User.getAttributes());
}
else {
User newUser = oAuth2SignupService.signup(provider, oAuth2UserInfo);
return new CustomOAuth2User(newUser, oAuth2User.getAttributes());
}
}
}
5.7. CustomOAuth2UserService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import com.gudgo.jeju.domain.user.entity.User;
import com.gudgo.jeju.domain.user.repository.UserRepository;
import com.gudgo.jeju.global.auth.oauth.entity.CustomOAuth2User;
import com.gudgo.jeju.global.auth.oauth.entity.OAuth2UserInfo;
import com.gudgo.jeju.global.auth.oauth.entity.OAuth2UserInfoFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final OAuth2SignupService oAuth2SignupService;
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(request);
log.info("============================================================================");
log.info("getAttributes: {}", oAuth2User.getAttributes());
log.info("============================================================================");
String provider = request.getClientRegistration().getRegistrationId();
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuthUserInfo(provider, oAuth2User.getAttributes());
Optional<User> user = userRepository.findByEmailAndProvider(provider, oAuth2UserInfo.getEmail());
if (user.isPresent()) {
return new CustomOAuth2User(user.get(), oAuth2User.getAttributes());
}
else {
User newUser = oAuth2SignupService.signup(provider, oAuth2UserInfo);
return new CustomOAuth2User(newUser, oAuth2User.getAttributes());
}
}
}
5.8. OAuth2AuthenticationSuccessHandler.java
모든 인증과정을 거치는 데 성공하면 실행되는 코드 입니다. 받은 정보를 가지고 회원가입이 완료되었을 시 강제 로그인 과정을 거칩니다. 그리고 프론트에게 필요한 정보를 request param 으로 건내줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gudgo.jeju.domain.profile.entity.Profile;
import com.gudgo.jeju.domain.profile.repository.ProfileRepository;
import com.gudgo.jeju.domain.user.dto.UserInfoResponseDto;
import com.gudgo.jeju.domain.user.entity.User;
import com.gudgo.jeju.domain.user.repository.UserRepository;
import com.gudgo.jeju.global.auth.oauth.entity.CustomOAuth2User;
import com.gudgo.jeju.global.jwt.token.TokenGenerator;
import com.gudgo.jeju.global.jwt.token.TokenType;
import com.gudgo.jeju.global.util.CookieUtil;
import com.gudgo.jeju.global.util.RedisUtil;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final ProfileRepository profileRepository;
private String PRE_FRONT_REDIRECT_URL = "http://localhost:5173";
private final TokenGenerator tokenGenerator;
private final CookieUtil cookieUtil;
private final RedisUtil redisUtil;
private final UserRepository userRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
String provider = customOAuth2User.getUser().getProvider();
User user = userRepository.findById(customOAuth2User.getUser().getId())
.orElseThrow(EntityNotFoundException::new);
Profile profile = profileRepository.findById(user.getProfile().getId())
.orElseThrow(EntityNotFoundException::new);
UserInfoResponseDto userInfoResponseDto = new UserInfoResponseDto(
user.getId(),
user.getEmail(),
user.getNickname(),
user.getName(),
user.getNumberTag(),
profile.getProfileImageUrl(),
user.getRole()
);
if (response.isCommitted()) {
log.info("============================================================================");
log.info("Social login response has been successfully sent.");
log.info("============================================================================");
}
log.info("============================================================================");
log.info("Social login successful. Social type is {}", provider);
log.info("============================================================================");
String accessToken = tokenGenerator.generateToken(TokenType.ACCESS, String.valueOf(customOAuth2User.getUser().getId()));
String refreshToken = tokenGenerator.generateToken(TokenType.REFRESH, String.valueOf(customOAuth2User.getUser().getId()));
response.setHeader("Authorization", "Bearer " + accessToken);
cookieUtil.setCookie("refreshToken", refreshToken, response);
redisUtil.setData(String.valueOf(customOAuth2User.getUser().getId()), refreshToken);
String frontendRedirectUrl = setRedirectUrl(accessToken, userInfoResponseDto);
response.sendRedirect(frontendRedirectUrl);
}
private String setRedirectUrl (String accessToken, UserInfoResponseDto userInfoResponseDto) {
String encodedUserId = URLEncoder.encode(String.valueOf(userInfoResponseDto.id()), StandardCharsets.UTF_8);
String encodedEmail = URLEncoder.encode(String.valueOf(userInfoResponseDto.email()), StandardCharsets.UTF_8);
String encodedNickname = URLEncoder.encode(String.valueOf(userInfoResponseDto.nickname()), StandardCharsets.UTF_8);
String encodedName = URLEncoder.encode(String.valueOf(userInfoResponseDto.name()), StandardCharsets.UTF_8);
String encodedNumberTag = URLEncoder.encode(String.valueOf(userInfoResponseDto.numberTag()), StandardCharsets.UTF_8);
String encodedProfiIeImageUrl = URLEncoder.encode(String.valueOf(userInfoResponseDto.profileImageUrl()), StandardCharsets.UTF_8);
String encodedUserRole = URLEncoder.encode(String.valueOf(userInfoResponseDto.userRole()), StandardCharsets.UTF_8);
String frontendRedirectURL = String.format(
"%s/oauth/callback?token=%s&userId=%s&email=%s&nickname=%s&name=%s&numberTag=%s&profileImageUrl=%s&userRole=%s",
PRE_FRONT_REDIRECT_URL, accessToken, encodedUserId, encodedEmail, encodedNickname, encodedName, encodedNumberTag, encodedProfiIeImageUrl, encodedUserRole
);
return frontendRedirectURL;
}
}
5.9. SecurityConfiguration.java
소셜 로그인 관련 url 가 security 필터를 거치지 않도록 설정해줍니다.
위에서 작성한 서비스와 핸들러를 등록해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> {})
.authorizeHttpRequests((authorizeRequest) ->
authorizeRequest
...
.requestMatchers("/oauth/**").permitAll()
.requestMatchers("/api/v1/oauth/**").permitAll()
...
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authorization -> authorization
.baseUri("/api/v1/oauth/authorize")
)
.redirectionEndpoint(redirection -> redirection
.baseUri("/oauth/callback")
)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
.successHandler(oAuth2AuthenticationSuccessHandler)
);
return httpSecurity.build();
5.10. JWTAuthenticationFilter.java
소셜 로그인 관련 url 가 jwt 필터를 거치지 않도록 설정해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import com.gudgo.jeju.global.jwt.token.TokenAuthenticator;
import com.gudgo.jeju.global.jwt.token.TokenExtractor;
import com.gudgo.jeju.global.jwt.token.TokenValidator;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JWTAuthenticationFilter extends OncePerRequestFilter {
private final TokenExtractor tokenExtractor;
private final TokenValidator tokenValidator;
private final TokenAuthenticator tokenAuthenticator;
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (
requestURI.startsWith("/api/v1/auth") ||
requestURI.startsWith("/api/v1/auth/token") ||
requestURI.startsWith("/api/v1/oauth") ||
...
5.11. 주의점
구글의 경우 도메인을 등록하지 않으면 개발자 계정으로 등록된 유저만 정상적으로 테스트가 됩니다.
6. 실제 구동 순서는 어떻게 될까?
해당 과정은 보이지 않는 인증 과정을 거치고, 인증이 완료된 후 받은 프로필 정보를 처리하는 과정입니다. 더 자세한 내용은 코드를 뜯어보고 알려드릴게요! 🤭
6.1. 소셜 로그인 요청
- /api/v1/oauth/authorize/… 을 통해 소셜 로그인 요청을 보냅니다.
- SecurityConfiguration.java 에 설정한대로 OAuth2 로그인 프로세스가 시작됩니다.
6.2. OAuth2 로그인
- OAuth2 공급자가 제공한 화면에서 로그인을 합니다.
- 성공하면 콜백 url 로 인증 정보가 옵니다.
6.3. OAuth2User 정보 로드
- CustomOAuthUserService.java 의 loadUser() 메서드를 호출합니다.
- DefaultOAuth2UserService 의 loadUser() 메서드가 호출되어 OAuth2 프로필 정보를 로드합니다.
6.4. OAuth2UserInfo Factory pattern 적용
- OAuth2UserInfoFactory.java 에서 공급자에 따라 적절한 OAuth2UserInfo 를 반환합니다.
- 이를 위해 GoogleUserInfo.java, KakaoUserInfo.java 를 만들었으며, 이를 통해 provider 에 따른 데이터 구조를 다를 수 있습니다.
6.5. 회원가입 처리
- CustomOAuth2UserService.java 의 OAuth2UserInfo 로부터 email 을 가져와 사용자 정보가 존재하는지 UserRepository 를 통해 확인합니다.
- 이미 존재하면 CustomOAuth2User 객체를 생성하여 반환합니다.
- 만약 존재하지 않으면 OAuth2SignupService.java 를 통해 회원 가입 절차를 거친 후 CustomOAuth2User 객체를 생성하여 반환합니다.
6.6. 인증 성공 후 처리
- 인증이 성공하면 OAuth2AuthenticationSuccessHandler.java 의 onAuthenticationSuccess() 가 호출 됩니다.
- 여기서 사용자의 정보를 바탕으로 JWT 토큰을 생성합니다.
- 클라이언트에게 request param 으로 필요한 정보를 전달합니다.
7. 프론트에서는 인증 후 받은 값을 어떻게 처리할까?
이전에 인증이 성공하면 아래와 같이 필요한 값들을 Request param 에 넣어서 Redirect 하기로 되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private String setRedirectUrl (String accessToken, UserInfoResponseDto userInfoResponseDto) {
String encodedUserId = URLEncoder.encode(String.valueOf(userInfoResponseDto.id()), StandardCharsets.UTF_8);
String encodedEmail = URLEncoder.encode(String.valueOf(userInfoResponseDto.email()), StandardCharsets.UTF_8);
String encodedNickname = URLEncoder.encode(String.valueOf(userInfoResponseDto.nickname()), StandardCharsets.UTF_8);
String encodedName = URLEncoder.encode(String.valueOf(userInfoResponseDto.name()), StandardCharsets.UTF_8);
String encodedNumberTag = URLEncoder.encode(String.valueOf(userInfoResponseDto.numberTag()), StandardCharsets.UTF_8);
String encodedProfiIeImageUrl = URLEncoder.encode(String.valueOf(userInfoResponseDto.profileImageUrl()), StandardCharsets.UTF_8);
String encodedUserRole = URLEncoder.encode(String.valueOf(userInfoResponseDto.userRole()), StandardCharsets.UTF_8);
String frontendRedirectURL = String.format(
"%s/oauth/callback?token=%s&userId=%s&email=%s&nickname=%s&name=%s&numberTag=%s&profileImageUrl=%s&userRole=%s",
PRE_FRONT_REDIRECT_URL, accessToken, encodedUserId, encodedEmail, encodedNickname, encodedName, encodedNumberTag, encodedProfiIeImageUrl, encodedUserRole
);
return frontendRedirectURL;
}
}
저의 경우 앞의 포트가 8080 이 아닌 프론트 포트로 두었습니다. 그리고 콜백 주소에 맞추어 라우터를 설정하고, 알맞은 값을 받은 뒤 적절한 처리를 해주었습니다.
7.1. 콜백 라우터 설정
7.1.1. index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import SignupPage from "../pages/authentication/SignupPage.vue";
import LoginPage from "../pages/authentication/LoginPage.vue";
import SocialCallback from "../pages/authentication/SocialCallback.vue";
import Layout from "../layout/Layout.vue";
import PlannerList from "../pages/planner/PlannerList.vue";
import PlannerSearch from "../pages/planner/PlannerSearch.vue";
import PlannerDetail from "../pages/planner/PlannerDetail.vue";
const routes: Array<RouteRecordRaw> = [
{
path: '/signup',
name: 'Signup',
component: SignupPage
},
{
path: '/login',
name: 'Login',
component: LoginPage
},
{
path: '/oauth/callback',
name: 'SocialLogin',
component: SocialCallback
},
...
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
7.2. 콜백 페이지 생성
7.2.1. SocialCallback.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {useUserStore} from "../../store/userStore.ts";
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
onMounted(() => {
const token = route.query.token as string;
const userId = Number(route.query.userId);
const nickname = route.query.nickname as string;
const userTag = Number(route.query.userTag);
const birthday = new Date(route.query.birthDay as string);
const email = route.query.email as string;
const profileImgUrl = route.query.profileImgUrl as string;
const isBirthday = route.query.isBirthday === 'true';
const sex = route.query.sex as string
localStorage.setItem("Authorization", token);
userStore.setUserInfo({
userId,
nickname,
userTag,
birthday,
email,
profileImgUrl,
isBirthday,
sex
});
router.push('/planners');
});
</script>
<style scoped lang="scss">
</style>
8. 완성 화면 🤭
아직 레이아웃을 좀 더 수정해야 하지만, 동작 과정을 보여드리기 위해 올립니다!