이 글은 OAuth2에 대한 개념과 Spring Security에 소셜 로그인을 적용하는 과정에 대해 설명한다.
OAuth2란?
OAuth2는 인터넷 사용자들이 자신의 아이디와 비밀번호를 공유하지 않고도 타사 애플리케이션이나 웹사이트에 자신의 정보에 대한 접근 권한을 부여할 수 있도록 하는 오픈 표준 프로토콜이다.
예를 들어 우리가 운영하는 서비스가 사용자 대신 구글 캘린더에 일정을 추가하거나, 페이스북 또는 트위터 같은 SNS에 글을 남기는 기능을 가장 간단하게 구현하는 방법은 사용자에게 해당 서비스의 Id와 Password를 직접 받아서 서비스에 저장하고 활용하는 방법일 것이다.
그러나 이런 방법은 서비스에서 사용자의 민감한 정보를 직접 저장하고 관리해야 한다는 큰 부담이 있고, 구글, 페이스북, 트위터 또한 자신의 고객 정보를 신뢰할 수 없는 제3자에게 맡긴다는 것이 불만족스러울 것이다.
이러한 문제를 해결하기 위해 OAuth 라는 개념이 등장했다. 최초 1.0 버전은 2006년 트위터와 Ma.gnolia가 주도적으로 개발했으며 모바일 애플리케이션 안정성을 보완하고 기존보다 단순화한 OAuth2.0 버전이 2012년에 등장하게 되었다.
OAuth2 주요 구성 요소
- 리소스 소유자 (Resource Owner)
접근 권한을 부여하는 사용자
- 클라이언트 (Client)
리소스 소유자의 승인을 받아 보호된 자원에 접근하려는 애플리케이션
- 리소스 서버 (Resource Server)
보호된 자원을 호스팅하고 있는 서버
ex) 구글, 페이스북, 트위터
- 인증 서버 (Authorization Server)
리소스 소유자의 인증을 수행하고 접근 토큰을 발급하는 서버
OAuth2의 작동 원리
1. 인증 요청
클라이언트는 리소스 소유자에게 인증 및 권한 승인을 요청한다.
- 우리의 서비스는 사용자에게 로그인을 요청한다.
2. 사용자 인증 및 승인
리소스 소유자는 인증 서버에서 Id, Password를 통해 인증하고, 클라이언트에게 필요한 권한을 승인한다.
- 사용자는 구글에서 로그인을 하고, 우리의 서비스에게 필요한 권한을 승인한다.
3. 인가 코드 발급
인증 서버는 클라이언트에게 인가 코드를 제공한다.
- 구글은 우리의 서비스에게 인가 코드를 제공한다.
4. Access 토큰 요청
클라이언트는 인가 코드를 이용해 인증 서버에 액세스 토큰을 요청한다.
- 우리의 서비스는 인가 코드를 이용해 구글에 Access 토큰을 요청한다.
5. Access 토큰 발급
인증 서버는 인가 코드를 검증하고 Access 토큰을 발급한다.
- 구글은 인가 코드를 검증하고 Access 토큰을 발급한다.
6. 자원 접근
클라이언트는 Access 토큰을 사용하여 리소스 서버에 보호된 자원을 요청한다.
- 우리의 서비스는 Access 토큰을 사용하여 구글에 보호된 자원을 요청한다.
7. 자원 제공
리소스 서버는 Access 토큰의 유효성을 확인하고 요청된 자원을 제공한다.
- 구글은 Access 토큰을 검증하고 필요한 자원을 제공한다.
Spring Security + OAuth2
구글, 카카오 로그인을 구현해 보겠다.
구글과 카카오에 애플리케이션을 등록하여 클라이언트 Id와 시크릿을 발급받는 작업이 선행되어야 한다.
해당 작업은 설명을 생략하겠다.
SecurityConfig 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, OAuth2SuccessHandler oAuth2SuccessHandler) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
// jwt 로그인
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// 권한 설정
.authorizeHttpRequests((authorize) -> authorize
// 인증 불필요
.requestMatchers("/signup", "/", "/login/form", "/login/sns")
.permitAll()
.anyRequest()
.hasRole("USER")
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login/form")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
)
// custom filter
.addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class)
// logout
.logout((logout) -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.addLogoutHandler(((request, response, authentication) -> {
// session invalidate
HttpSession session = request.getSession();
session.invalidate();
// destroy refresh token
String accessToken = jwtService.extractAccessToken(request);
String userId = jwtService.extractUserId(accessToken);
jwtService.destroyRefreshToken(userId);
}))
.invalidateHttpSession(true))
// stateless
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
- oauth2Login()
커스텀 로그인 페이지, 인증 성공시 리다이렉트될 페이지, 실패시 리다이렉트될 페이지, 사용자 정보를 처리하는 Service, 핸들러를 등록할 수 있다.
CustomOAuth2UserService
OAuth2 인증 후 사용자 정보를 처리하고, 필요한 경우 회원 가입이나 토큰 관련 작업을 수행한다.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserMapper userMapper;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User user = super.loadUser(userRequest);
try {
return this.process(userRequest, user);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
ex.printStackTrace();
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
ProviderType providerType = ProviderType.fromValue(userRequest.getClientRegistration().getRegistrationId());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
UserDto savedUser = userMapper.selectUserByUserId(userInfo.getEmail());
if (savedUser != null) {
// 이미 가입한 경우
if (!providerType.getValue().equals(savedUser.getProviderTypeCode())) {
// 다른 방법으로 가입한 경우
throw new UserException(
"Looks like you're signed up with " + providerType +
" account. Please use your " + savedUser.getProviderTypeCode() + " account to login."
);
}
} else {
// 가입 정보가 없는 경우
savedUser = createUser(userInfo, providerType);
}
return UserPrincipal.create(savedUser, user.getAttributes());
}
private UserDto createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
LocalDateTime now = LocalDateTime.now();
UserDto user = UserDto.builder()
.userId(userInfo.getEmail())
.grdId("Bronze")
.userName(userInfo.getName())
.providerTypeCode(providerType.getValue())
.role(Role.USER.getValue())
.build();
userMapper.insertUser(user);
return user;
}
}
Info
OAuth2UserInfoFactory
OAuth2 제공자 타입(provider type)에 따라 적절한 OAuth2UserInfo 구현체를 생성하는 팩토리 클래스이다.
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map<String, Object> attributes) {
switch (providerType) {
case GOOGLE:
return new GoogleOAuth2UserInfo(attributes);
case KAKAO:
return new KakaoOAuth2UserInfo(attributes);
default:
throw new UserException(UserExceptionMessage.INVALID_OAUTH_TYPE.getMessage());
}
}
}
OAuth2UserInfo
각 OAuth2 제공자가 상속받아 구현해야 하는 추상 클래스이다.
공통으로 필요한 메서드를 정의하고, 이를 구체적인 구현 클래스에서 구현하도록 했다.
사용자 정보(attributes)를 저장하고 제공한다.
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}
GoogleOAuth2UserInfo
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
KakaoOAuth2UserInfo
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return attributes.get("id").toString();
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("account_email");
}
@Override
public String getImageUrl() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("thumbnail_image");
}
}
Test.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Hello Spring Boot</h1>
<a class="btn btn-success active" href="/oauth2/authorization/google" role="button">Google Login</a>
<a class="btn btn-secondary active" href="/oauth2/authorization/kakao" role="button">Kakao Login</a>
</body>
</html>
OAuth2 인증 과정에서의 필터
OAuth2 로그인 처리시 Spring Security에서 기본으로 제공하는 필터가 동작하며, 주요 필터들은 다음과 같다.
- OAuth2AuthorizationRequestRedirectFilter
사용자를 SNS 제공자의 인증 페이지로 리다이렉트한다.
/oauth2/authorization/{registrationId} 경로의 요청을 처리한다.
- OAuth2LoginAuthenticationFilter
SNS 제공자로부터 리디렉션된 인증 응답을 처리한다.
인가 코드 또는 Access 토큰을 받아와서 인증을 수행한다.
/login/oauth2/code/* 경로의 요청을 처리한다.
'Spring' 카테고리의 다른 글
Mockito를 사용한 Spring Boot Unit Test (0) | 2024.09.24 |
---|---|
Spring Security 403 에러 해결 과정 (0) | 2024.09.22 |
Spring Security에 JWT 적용하기 (0) | 2024.09.19 |
Spring Security 개념, 동작 방식, 인증 과정 (0) | 2024.09.13 |