개요
지난 글에서 OAuth에 대한 이론 부분을 학습했고, 이번에는 OAuth 2.0을 사용한 소셜 로그인 구현 방법과 Indian Frog에 사용한 프로젝트 코드를 분석해 볼 것이다.
소셜 로그인 구현
OAuth 2 애플리케이션 생성
OAuth 2 연동을 위해 먼저 OAuth 2 제공자인 구글, 네이버, 카카오에서 OAuth 2 애플리케이션을 생성해야 한다.
각 서비스 별로 애플리케이션을 생성하는 방법은 비슷하며, 애플리케이션을 생성하면 Client ID와 Client Secret이 생성된다.
이 두 개의 값은 Spring Application에서 사용된다.
구글
1. https://console.cloud.google.com에서 상단의 프로젝트 선택을 누르고 프로젝트를 선택한다.
- 처음에는 프로젝트가 없으므로 새 프로젝트를 선택하고 생성한다.
2. 프로젝트 이름을 입력하여 생성한다.
3. 상단에서 생성한 프로젝트를 선택하고 메뉴 → API 및 서비스 → OAuth 동의 화면을 선택
- User Type은 외부를 선택하고 동의 화면을 만든다.
4. 앱 정보에 앱 이름, 사용자 지원 이메일, 개발자 연락처 정보를 입력한다.
5. 범위에서 email, profile, openid를 선택한다.
6. 테스트 사용자를 입력한다(이메일 주소)
7. 메뉴 → API 및 서비스 → 사용자 인증 정보로 이동 후 사용자 인증 정보 만들기 → OAuth 클라이언트 ID를 선택한다.
8. 애플리케이션 유형은 웹 애플리케이션, 애플리케이션 이름을 입력한다.
- 승인된 리디렉션 URI에 주소(예: http://localhost:8080/login/oauth2/code/google)을 입력하여 OAuth 클라이언트 ID를 생성한다.
9. 클라이언트 ID가 Client ID 값이고 클라이언트 보안 비밀번호는 Client Secret 값이다.
- 나중에 사용되므로 저장해 둔다.
네이버
1. https://developers.naver.com에서 Application → 애플리케이션 등록을 선택한다.
2. 애플리케이션 이름을 입력하고, 사용 API는 네이버 로그인을 선택한다.
- 필수 정보로 이름, 이메일, 별명, 프로필 사진을 선택한다.
- 환경은 PC 웹 환경을 선택하고 서비스 URL과 네이버 로그인 Callback URL을 입력한다
- 예) http://localhost:8080 , http://localhost:8080/login/oauth2/code/naver
3. 애플리케이션을 생성하면 Client ID와 Client Secret 정보를 발급받는데 유출되지 않도록 주의한다.
카카오
1. https://developers.kakao.com에서 내 애플리케이션 → 애플리케이션 추가하기를 선택한다.
2. 애플리케이션 이름과 사업자명을 입력하여 애플리케이션을 생성한다.
3. 메뉴에서 앱 설정 → 앱 키를 선택하면 나오는 REST API 키가 Client ID 값이다.
4. 메뉴에서 제품 설정 → 카카오 로그인을 선택하고, 활성화 설정을 ON으로 설정한다.
- Redirect URI를 입력한다(예: http://localhost:8080/login/oauth2/code/kakao)
5. 메뉴에서 제품 설정 → 카카오 로그인 → 동의 항목을 선택한다.
- 닉네임, 프로필 사진, 카카오 계정(이메일)을 필수 동의 및 선택 동의로 설정한다.
- 카카오 계정(이메일)은 애플리케이션 검수를 받아야 설정할 수 있다.
6. 메뉴에서 제품 설정 → 카카오 로그인 → 보안을 선택하고 코드 생성 버튼을 선택해 Client Secret을 생성하고 활성화 상태를 사용함으로 설정한다.
프로젝트 코드 분석
Indian Frog 프로젝트의 애플리케이션에서 OAuth 2를 사용해 소셜 로그인을 구현한 코드를 분석하며 이해하는 과정을 통해 추후 다른 프로젝트에서 구현할 수 있도록 할 것이다.
Gradle
build.gradle
//소셜 로그인 관련 dependencies만 기술
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// jwt
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'
// oauth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// email
implementation 'org.springframework.boot:spring-boot-starter-mail'
- Redis 사용 이유 : Access Token 및 Refresh Token 저장
Properties/yml
application-oauth.yml
spring:
security:
oauth2:
client:
registration:
naver:
#registration
client-name: naver
client-id: jUJgXu_8qTwf1KAPLbex
client-secret: jAAie4AFMN
# redirect-uri: http://localhost:8081/login/oauth2/code/naver
redirect-uri: https://api.indianfrog.com/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope: name,email
#registration 우릴특정
google:
client-name: google
client-id: 880021815690-e2epiegqo22373ha72bvpf7k4tbv619o.apps.googleusercontent.com
client-secret: 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
# redirect-uri: http://localhost:8081/login/oauth2/code/google
redirect-uri: https://api.indianfrog.com/login/oauth2/code/google
authorization-grant-type: authorization_code
scope: profile,email
kakao:
client-id: 41032ee77aa5b7e6e60d3a807e3e4b8f
# redirect-uri: "http://localhost:8081/login/oauth2/code/kakao"
redirect-uri: "https://api.indianfrog.com/login/oauth2/code/kakao"
client-authentication-method: POST
authorization-grant-type: authorization_code
scope: profile_nickname, account_email
#openauth 관련정보
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
naver:
# oauth-provider
authorization-uri: https://nid.naver.com/oauth2.0/authorize #서비스 로그인 창 주소
token-uri: https://nid.naver.com/oauth2.0/token #토큰 발급 서버 주소
user-info-uri: https://openapi.naver.com/v1/nid/me #사용자 정보 획득 주소
user-name-attribute: response #응답 데이터 변수
- application.yml 파일을 여러 개로 분리하여 사용
Naver, Google, Kakao를 통해 OAuth2 인증을 설정하는 파일로 각 제공자에 대한 클라이언트 ID, 클라이언트 Secret, 리디렉션 URI, 인증 유형 및 요청할 권한 범위를 지정
- spring.security.oauth2.client.registration
- 각 OAuth2 클라이언트의 등록 정보를 설정(Naver, Google, Kakao)
- client-name : 클라이언트 이름
- client-id : OAuth2 제공자(Naver, Google, Kakao)가 발급한 클라이언트 ID
- client-secret : OAuth2 제공자가 발급한 클라이언트 시크릿
- redirect-uri : OAuth2 인증 후 리디렉션될 URI
- authorization-grant-type : 인증코드 타입
- scope : 요청할 권한 범위
- spring.security.oauth2.client.provider
- 각 OAuth2 제공자의 정보를 설정하고 이 정보는 인증과정에서 사용된다
- authorization_uri : 카카오/네이버 인증 uri
- token_uri : 카카오/네이버 토큰 uri
- user-info-uri : 사용자 정보 uri
- user_name_attribute : 사용자 이름 속성
Config
WebSecurityConfig
package com.service.indianfrog.global.config;
import com.service.indianfrog.domain.user.repository.UserRepository;
import com.service.indianfrog.global.jwt.JwtUtil;
import com.service.indianfrog.global.security.JwtAuthenticationFilter;
import com.service.indianfrog.global.security.JwtAuthorizationFilter;
import com.service.indianfrog.global.security.UserDetailsServiceImpl;
import com.service.indianfrog.global.security.oauth2.CustomOAuth2UserService;
import com.service.indianfrog.global.security.oauth2.MyAuthenticationFailureHandler;
import com.service.indianfrog.global.security.oauth2.OAuth2AuthenticationSuccessHandler;
import com.service.indianfrog.global.security.token.TokenBlacklistService;
import com.service.indianfrog.global.security.token.TokenService;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler customSuccessHandler;
private final MyAuthenticationFailureHandler authenticationFailureHandler;
private final UserRepository userRepository;
private final TokenBlacklistService tokenBlacklistService;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService,
AuthenticationConfiguration authenticationConfiguration,
CustomOAuth2UserService customOAuth2UserService,
OAuth2AuthenticationSuccessHandler customSuccessHandler,
MyAuthenticationFailureHandler authenticationFailureHandler, UserRepository userRepository,
TokenBlacklistService tokenBlacklistService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
this.customOAuth2UserService = customOAuth2UserService;
this.customSuccessHandler = customSuccessHandler;
this.authenticationFailureHandler = authenticationFailureHandler;
this.userRepository = userRepository;
this.tokenBlacklistService = tokenBlacklistService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService, userRepository,tokenBlacklistService);
}
// 시큐리티 CORS 설정
// @Bean
// CorsConfigurationSource corsConfigurationSource() {
// CorsConfiguration configuration = new CorsConfiguration();
// // 배포시 허용할 출처 추가하기
// configuration.addAllowedOriginPattern("*");
// configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
// configuration.addAllowedHeader("*");
// configuration.setExposedHeaders(List.of("Authorization","Set-Cookie"));
// configuration.setAllowCredentials(true);
// configuration.setAllowedHeaders(Arrays.asList("Authorization", "TOKEN_ID", "X-Requested-With", "Content-Type", "Content-Length", "Cache-Control","Set-Cookie"));
// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// source.registerCorsConfiguration("/**", configuration);
// return source;
// }
public CorsConfigurationSource configurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.addAllowedOriginPattern("*");
configuration.setAllowCredentials(true);
configuration.setExposedHeaders(
List.of("Authorization", "Set-Cookie", "Cache-Control", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 시큐리티 CORS 빈 설정
http.cors((cors) -> cors.configurationSource(configurationSource()));
// JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
/*oauth2*/
http
.oauth2Login((oauth2) -> oauth2
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
.userService(customOAuth2UserService))// OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
// .failureHandler(authenticationFailureHandler)
.successHandler(customSuccessHandler));// OAuth2 로그인 성공 시, 후작업을 진행할 UserService 인터페이스 구현체 등록
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.permitAll() // resources 접근 허용 설정
.requestMatchers("/", "/user/**",
"/login/**", "/oauth2/**", "/token/**"
, "/**", "/indian-frog-management/prometheus/**", "/error/**", "/monitoring/grafana/**")
.permitAll() // 메인 페이지 요청 허가
.requestMatchers("/ws/**").permitAll() // WebSocket 경로 허가
.requestMatchers("/topic/**").permitAll() // WebSocket 메시지 브로커 경로 허가
// .requestMatchers("/app/**").permitAll()
// .requestMatchers("/user/queue/**").permitAll()
.requestMatchers("/api-docs/**", "/swagger-ui/**").permitAll()
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin(AbstractHttpConfigurer::disable);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 예외 핸들러
//http.exceptionHandling(handler -> handler.authenticationEntryPoint(new CustomAuthenticationEntryPoint()));
return http.build();
}
}
- Spring Security를 사용하여 애플리케이션 보안 설정, JWT 기반의 인증 및 인가 사용, OAuth2 로그인 처리 및 CORS 설정
클래스 구성 요소
- jwtUtil : JWT 유틸리티 클래스
- userDetailsService : 사용자 세부 정보 서비스 구현체
- authenticationConfiguration : 인증 구성 객체
- customOAuth2UserService : 커스텀 OAuth2 사용자 서비스
- customSuccessHandler : OAuth2 인증 성공 핸들러
- authenticationFailureHandler : OAuth2 인증 실패 핸들러
- userRepository : 사용자 저장소
- tokenBlacklistService : 토큰 블랙리스트 서비스
메서드 별 기능
- passwordEncoder : 비밀번호 인코더를 설정, BCryptPasswordEncoder 사용
- authenticationManager : authenticationManager 빈을 생성
- jwtAuthenticationFilter : jwtAuthenticationFilter를 설정하고 반환
- securityFilterChain
- CSRF 보호 비활성화
- CORS 설정
- 세션 관리 설정(상태 비저장)
- OAuth2 로그인 설정
- 요청 권한 설정
- 기본 로그인 폼 비활성화
- JWT 필터 설정
기타 회원가입 및 로그인 관련 Controller, Service 클래스 파일은 분석에서 제외
- OAuth2 로그인과 큰 연관은 없고 코드 분석 없이도 충분히 구현 가능함
소셜 로그인 관련 기술적 의사결정 및 트러블 슈팅
기술적 의사결정
Redis
인메모리 DB로 로그인 인증 토큰 발급 시 빠르게 접근이 가능하고 상대적으로 인증 토큰은 휘발되어도 리스크가 적어 Redis를 도입
Spring Security
JWT를 통한 로그인 방법으로 접근을 하게 되었으며, Spring에서 제공하는 하위 프레임워크로서 유용한 인터페이스를 구현하여 인증을 할 수 있어 도입
JWT
세션 방식을 통한 로그인 처리는 DB에 세션 값을 저장해야 하므로 JWT 토큰을 통해 로그인을 처리하고 Refresh Token을 사용하는 것으로 JWT의 단점을 줄이고 Rotate를 통한 보완을 하기로 하고 도입
OAuth2
사용자 경험 향상을 위해 소셜 로그인 기능 도입
트러블 슈팅
Refresh Token 사용 시 Non Null Key Error
- 문제 : Refresh Token이 재발급 되지 않고 400 non null key 에러가 발생함
- 원인 :
- Redis에 비밀번호 설정이 안 되서 해킹을 당해서 key가 사라짐
- 지속적 배포 시 docker-compose down 이후 docker-compose up 하는 과정에서 Redis에 저장된 값들이 계속 삭제됨
- 해결 :
- Redis 비밀번호 설정
- Redis에 volume을 만들고, appendonly를 yes로 설정