Post

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. 완성 화면 🤭


아직 레이아웃을 좀 더 수정해야 하지만, 동작 과정을 보여드리기 위해 올립니다!

This post is licensed under CC BY 4.0 by the author.