[SpringBoot] 1. Oauth 2.0 기반 소셜 로그인 구현해보기

[참고]

https://deeplify.dev/back-end/spring/oauth2-social-login

 

[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)

스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.

deeplify.dev

이전 자료 글이지만 정리 및 프로젝트 구성이 잘되어 있습니다.

구글만 기능구현하였으니, 다른 카카오 페이스북 등의 추가는 해당 링크를 통해 확인하시면 됩니다.

 

 

소셜 로그인은 사용자가 기존의 계정 정보를 입력하지 않고도 다른 플랫폼의 계정을 사용하여 로그인할 수 있는 기능입니다. 이는 사용자 경험을 향상시키고, 애플리케이션의 가입 절차를 간소화하는 데 도움이 됩니다. 

 

Spring 프레임워크에서는 OAuth 2.0을 지원하여 소셜 로그인을 구현할 수 있습니다. 

이번 글에서는 Spring을 사용하여 OAuth 2.0을 활용한 소셜 로그인을 구현하는 방법에 대해 알아보겠습니다.

 

 

OAuth 2.0 개요

OAuth 2.0은 사용자가 서드파티 애플리케이션에 자신의 데이터에 접근할 수 있는 권한을 부여하기 위한 프로토콜입니다. OAuth 2.0은 클라이언트-서버 모델을 기반으로하며, 사용자는 소셜 로그인 공급자에 로그인하여 액세스 토큰을 발급받아 애플리케이션에 제공합니다.

 

 

1. 구글 Oauth 2.0 등록

https://console.cloud.google.com/apis

사용자 API 설정에서 새 프로젝트 생성하기

원하는 프로젝트 이름 넣어서 만들기

 

사용자 인증정보 > 사용자 인증 정보 만들기

Oauth 클라이언트 ID 선택

구글 로그인 완료 후, 리다이렉트에 사용할 URL

http://localhost:8080/login/oauth2/code/google

 

정상 생성이 되면 아래 화면 처럼 나오고

클라이언트 ID, 클라이언트 보안 비밀번호는 별도로 저장해두기

 

OAuth 동의화면

 

승인에 OAuth 2.0을 사용하면 앱에서 Google 계정의 여러 액세스 범위에 대한 승인을 요청합니다. Google에서 사용자에게 프로젝트 및 정책 요약과 요청된 액세스 범위가 포함된 동의 화면을 표시합니다

 

조직은 따로 없이, 외부로 지정해서 동의화면 만들기

 

앱 정보 기입

 

범위

 

 

백엔드 OAuth 로그인

https://github.com/deepIify/oauth-login-be

 

GitHub - deepIify/oauth-login-be: OAuth login be spring boot project

OAuth login be spring boot project. Contribute to deepIify/oauth-login-be development by creating an account on GitHub.

github.com

 

 

1. build.gradle 

2021.08.28 작성 글 참조해서 일단 그대로 사용하였습니다.

- Spring boot 2.5.3

- JAVA 11

plugins {
    id 'org.springframework.boot' version '2.5.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-log4j2'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.mariadb.jdbc:mariadb-java-client:2.7.4'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

configurations {
    all*.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
    all*.exclude group: 'org.springframework.boot', module: 'logback-classic'
}

test {
    useJUnitPlatform()
}

 

주요 소스 기능 분석하기

SecurityConfig.java

  • Spring Security를 사용하여 보안 구성을 수행하는 SecurityConfig 클래스
  • 이 클래스는 WebSecurityConfigurerAdapter를 확장하여 사용자 정의 보안 구성을 정의
    configure(AuthenticationManagerBuilder auth): 인증(Authentication) 매니저를 구성합니다.  
  • userDetailsService를 사용하여 사용자 정보를 가져오고, passwordEncoder()를 사용하여 비밀번호를 암호화합니다.
  • configure(HttpSecurity http): HTTP 보안을 구성합니다. CORS 설정, 세션 관리, CSRF 보호 비활성화 등을 수행합니다. 또한 요청 권한 설정, OAuth2 인증 관련 엔드포인트 구성, 필터 설정 등을 수행합니다.
  • authenticationManager(): AuthenticationManager 빈을 생성합니다. 이 메서드를 오버라이드하여AuthenticationManager를 사용할 수 있도록 합니다.
  • passwordEncoder(): 비밀번호 인코더를 설정합니다. BCryptPasswordEncoder를 사용하여 비밀번호를 해시화합니다.
  • tokenAuthenticationFilter(): 토큰 인증 필터를 설정합니다. TokenAuthenticationFilter 인스턴스를 반환합니다.
  • oAuth2AuthorizationRequestBasedOnCookieRepository(): 쿠키 기반 인가 리포지토리를 설정합니다. 
  • CorsProperties에서 가져온 값을 기반으로 CorsConfiguration 객체를 생성하고, 이를 UrlBasedCorsConfigurationSource에 등록하여 CORS 구성을 반환합니다.

* 주의 이전 버전으로 작성된 코드로, 현재 WebSecurityConfigurerAdapter Deprecated 되었다.

   @configure -> SecurityFilterChain를 Bean으로 등록하는 형태로 변경해야 한다.

import com.simplesrm.api.repository.UserRefreshTokenRepository;
import com.simplesrm.config.properties.AppProperties;
import com.simplesrm.config.properties.CorsProperties;
import com.simplesrm.oauth.entity.RoleType;
import com.simplesrm.oauth.exception.RestAuthenticationEntryPoint;
import com.simplesrm.oauth.filter.TokenAuthenticationFilter;
import com.simplesrm.oauth.handler.OAuth2AuthenticationFailureHandler;
import com.simplesrm.oauth.handler.OAuth2AuthenticationSuccessHandler;
import com.simplesrm.oauth.handler.TokenAccessDeniedHandler;
import com.simplesrm.oauth.repository.OAuth2AuthorizationRequestBasedOnCookieRepository;
import com.simplesrm.oauth.service.CustomOAuth2UserService;
import com.simplesrm.oauth.service.CustomUserDetailsService;
import com.simplesrm.oauth.token.AuthTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CorsProperties corsProperties;
    private final AppProperties appProperties;
    private final AuthTokenProvider tokenProvider;
    private final CustomUserDetailsService userDetailsService;
    private final CustomOAuth2UserService oAuth2UserService;
    private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
    private final UserRefreshTokenRepository userRefreshTokenRepository;

    /*
     * UserDetailsService 설정
     * */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .exceptionHandling()
                .authenticationEntryPoint(new RestAuthenticationEntryPoint())
                .accessDeniedHandler(tokenAccessDeniedHandler)
                .and()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers("/api/**").hasAnyAuthority(RoleType.USER.getCode())
                .antMatchers("/api/**/admin/**").hasAnyAuthority(RoleType.ADMIN.getCode())
                .anyRequest().authenticated()
                .and()
                .oauth2Login()
                .authorizationEndpoint()
                .baseUri("/oauth2/authorization")
                .authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
                .and()
                .redirectionEndpoint()
                .baseUri("/*/oauth2/code/*")
                .and()
                .userInfoEndpoint()
                .userService(oAuth2UserService)
                .and()
                .successHandler(oAuth2AuthenticationSuccessHandler())
                .failureHandler(oAuth2AuthenticationFailureHandler());

        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    /*
     * auth 매니저 설정
     * */
    @Override
    @Bean(BeanIds.AUTHENTICATION_MANAGER)
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    /*
     * security 설정 시, 사용할 인코더 설정
     * */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /*
     * 토큰 필터 설정
     * */
    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(tokenProvider);
    }

    /*
     * 쿠키 기반 인가 Repository
     * 인가 응답을 연계 하고 검증할 때 사용.
     * */
    @Bean
    public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
        return new OAuth2AuthorizationRequestBasedOnCookieRepository();
    }

    /*
     * Oauth 인증 성공 핸들러
     * */
    @Bean
    public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
        return new OAuth2AuthenticationSuccessHandler(
                tokenProvider,
                appProperties,
                userRefreshTokenRepository,
                oAuth2AuthorizationRequestBasedOnCookieRepository()
        );
    }

    /*
     * Oauth 인증 실패 핸들러
     * */
    @Bean
    public OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
        return new OAuth2AuthenticationFailureHandler(oAuth2AuthorizationRequestBasedOnCookieRepository());
    }

    /*
     * Cors 설정
     * */
    @Bean
    public UrlBasedCorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();

        CorsConfiguration corsConfig = new CorsConfiguration();
        corsConfig.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(",")));
        corsConfig.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(",")));
        corsConfig.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(",")));
        corsConfig.setAllowCredentials(true);
        corsConfig.setMaxAge(corsConfig.getMaxAge());

        corsConfigSource.registerCorsConfiguration("/**", corsConfig);
        return corsConfigSource;
    }
}

 

 

2. AuthController

 

/api/v1/auth/login 엔드포인트는 사용자의 로그인을 처리합니다. 
요청의 본문에 있는 인증 정보를 확인하고, AuthenticationManager를 사용하여 사용자를 인증합니다. 
인증이 성공하면, 사용자의 ID와 권한 정보를 기반으로 AccessToken과 RefreshToken을 생성합니다. RefreshToken은 사용자의 DB에서 확인하고, DB에 없는 경우 새로 등록합니다. 
그리고 RefreshToken을 클라이언트로 전송하기 위해 쿠키에 저장합니다. 마지막으로, 성공적인 응답을 반환합니다.

/api/v1/auth/refresh 엔드포인트는 RefreshToken을 사용하여 AccessToken을 갱신합니다. 
먼저, 요청 헤더에서 AccessToken을 확인하고 유효성을 검사합니다. 
AccessToken이 만료되지 않았을 경우, 해당 토큰에서 사용자 ID와 권한 정보를 추출합니다. 
그리고 요청 쿠키에서 RefreshToken을 가져옵니다. 
RefreshToken이 유효하지 않거나 DB에서 사용자와 일치하지 않는 경우, 유효하지 않은 RefreshToken으로 처리합니다. 
그렇지 않으면, 새로운 AccessToken을 생성합니다. 
RefreshToken의 유효기간이 3일 이하로 남아있을 경우, RefreshToken을 갱신하고 쿠키에 새로운 RefreshToken을 설정합니다. 
최종적으로, 갱신된 AccessToken을 반환.

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AppProperties appProperties;
    private final AuthTokenProvider tokenProvider;
    private final AuthenticationManager authenticationManager;
    private final UserRefreshTokenRepository userRefreshTokenRepository;

    private final static long THREE_DAYS_MSEC = 259200000;
    private final static String REFRESH_TOKEN = "refresh_token";

    @PostMapping("/login")
    public ApiResponse login(
            HttpServletRequest request,
            HttpServletResponse response,
            @RequestBody AuthReqModel authReqModel
    ) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        authReqModel.getId(),
                        authReqModel.getPassword()
                )
        );

        String userId = authReqModel.getId();
        SecurityContextHolder.getContext().setAuthentication(authentication);

        Date now = new Date();
        AuthToken accessToken = tokenProvider.createAuthToken(
                userId,
                ((UserPrincipal) authentication.getPrincipal()).getRoleType().getCode(),
                new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
        );

        long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();
        AuthToken refreshToken = tokenProvider.createAuthToken(
                appProperties.getAuth().getTokenSecret(),
                new Date(now.getTime() + refreshTokenExpiry)
        );

        // userId refresh token 으로 DB 확인
        UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserId(userId);
        if (userRefreshToken == null) {
            // 없는 경우 새로 등록
            userRefreshToken = new UserRefreshToken(userId, refreshToken.getToken());
            userRefreshTokenRepository.saveAndFlush(userRefreshToken);
        } else {
            // DB에 refresh 토큰 업데이트
            userRefreshToken.setRefreshToken(refreshToken.getToken());
        }

        int cookieMaxAge = (int) refreshTokenExpiry / 60;
        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
        CookieUtil.addCookie(response, REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);

        return ApiResponse.success("token", accessToken.getToken());
    }

    @GetMapping("/refresh")
    public ApiResponse refreshToken (HttpServletRequest request, HttpServletResponse response) {
        // access token 확인
        String accessToken = HeaderUtil.getAccessToken(request);
        AuthToken authToken = tokenProvider.convertAuthToken(accessToken);
        if (!authToken.validate()) {
            return ApiResponse.invalidAccessToken();
        }

        // expired access token 인지 확인
        Claims claims = authToken.getExpiredTokenClaims();
        if (claims == null) {
            return ApiResponse.notExpiredTokenYet();
        }

        String userId = claims.getSubject();
        RoleType roleType = RoleType.of(claims.get("role", String.class));

        // refresh token
        String refreshToken = CookieUtil.getCookie(request, REFRESH_TOKEN)
                .map(Cookie::getValue)
                .orElse((null));
        AuthToken authRefreshToken = tokenProvider.convertAuthToken(refreshToken);

        if (authRefreshToken.validate()) {
            return ApiResponse.invalidRefreshToken();
        }

        // userId refresh token 으로 DB 확인
        UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserIdAndRefreshToken(userId, refreshToken);
        if (userRefreshToken == null) {
            return ApiResponse.invalidRefreshToken();
        }

        Date now = new Date();
        AuthToken newAccessToken = tokenProvider.createAuthToken(
                userId,
                roleType.getCode(),
                new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
        );

        long validTime = authRefreshToken.getTokenClaims().getExpiration().getTime() - now.getTime();

        // refresh 토큰 기간이 3일 이하로 남은 경우, refresh 토큰 갱신
        if (validTime <= THREE_DAYS_MSEC) {
            // refresh 토큰 설정
            long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

            authRefreshToken = tokenProvider.createAuthToken(
                    appProperties.getAuth().getTokenSecret(),
                    new Date(now.getTime() + refreshTokenExpiry)
            );

            // DB에 refresh 토큰 업데이트
            userRefreshToken.setRefreshToken(authRefreshToken.getToken());

            int cookieMaxAge = (int) refreshTokenExpiry / 60;
            CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
            CookieUtil.addCookie(response, REFRESH_TOKEN, authRefreshToken.getToken(), cookieMaxAge);
        }

        return ApiResponse.success("token", newAccessToken.getToken());
    }
}

 

 

프론트 OAuth 로그인

아래의 소스를 그대로 사용하였습니다.

https://github.com/deepIify/oauth-login-fe

 

 

실행 결과 

구글로그인만 구현하여, 구글 계정으로 로그인

 

 

 

 

추후 프로젝트 진행사항

해당 로그인 기능을 바탕으로 해서,

하나씩 Spring 을 통해 간단 SRM(현재 운영 업무 중인 SRM의 경우 20년이 넘었다..) 과 같은 사이트를 구성해보려고 한다.