사실 소셜 로그인은 이전에 구현해둔 상태였지만 계속 다른 일에 밀려서 관련 블로그 글을 작성하는게 늦춰지고 있었다. 이번에 리액트에서도 동작하도록 만드느라 코드와 일부 내용이 수정되어서 겸사겸사 글을 작성해보려고 한다.
소셜 로그인은 스프링 부트의 라이브러리인 spring-boot-starter-oauth2-client
을 사용했는데 애플은 구현 형태가 조금 달라서 별도로 구현했다.
클라이언트(위 그림에서는 Resource Owner
) 입장에서는 소셜 로그인 요청 후 그에 대한 결과나 응답을 받을 수 있어야 한다. OAuth 2.0 인증 과정은 기본적으로 클라이언트 입장에서 외부에서 이루어지기 때문에 이에 대한 별도의 방법이 필요하다.
네이티브 앱에서는 크게 두 가지 방법이 있는데 하나는 Service Provider
에서 제공하는 SDK를 사용하는 것이고, 다른 하나는 WebView
를 사용하여 외부 페이지로부터 응답 데이터를 가져온다. 전자는 외부 SDK에 의존해야 하고 지원을 하지 않는 경우도 있을 수 있기 때문에 우리는 후자의 방법을 사용하고 있다.
만약 스프링이 Thymeleaf 같은 툴을 사용하여 웹서버 역할을 겸하고 있다면 소셜 로그인 과정을 모두 끝마치고 그 결과에 맞는 페이지로 리디렉션 시켜주면 된다. 이는 스프링이나 Thymeleaf가 아닌 다른 프레임워크, 라이브러리 등을 사용해도 마찬가지일 것이다.
그러나 우리는 현재 스프링을 API 서버로 쓰고 있고, 프론트에서는 리액트를 사용 중이므로 리액트 서버의 URL로 리디렉션 시켜주면 된다. 이 때문에 서버 입장에서는 네이티브 앱과 리액트에서의 요청을 구분해야 하고, 리액트에서 요청한 경우 redirect uri
(OAuth 2.0에서 사용하는 것과는 별개임)도 어딘가에 저장을 해뒀다가 사용해야 한다.
이 때 사용하는 redirect uri는 OAuth 2.0에서 사용자가 인증 과정을 마치면 인가 코드(authorization code)와 함께 리디렉션 되는 uri와는 다른 것이다. 이 둘이 헷갈리지 않도록 주의하자.
OAuth 2.0에서 state
파라미터는 인가 코드 받기 API 요청 시 전달한 값을 Redirect URI에 전달하여 인가 요청 출처를 확인하는 데 사용한다. 이는 CSRF(Cross-Site Request Forgery)
공격 방지용으로 활용할 수 있다.
로그인을 시도하는 각 사용자의 로그인 요청에 대한 state
값을 중복되지 않는 고유한 난수로 설정하고, Redirect URI에 전달된 값과 일치하는지 검증하는 방식으로 사용한다. state 값은 세션 또는 동일 출처 정책에 의해 보호되는 쿠키 등 제3자가 접근할 수 없는 위치에 보관하여 사용해야 한다. (참고: RFC 6749 10.12.)
이렇게 CSRF 공격 방지용으로 활용되는 state
파라미터는 리액트 측에서 전달하는 redirect uri를 쿠키에 저장해뒀다가, 로그인을 성공적으로 마치고 success handler에서 무언가 처리를 할 때 다시 꺼내어 사용할 수 있다.
CSRF(Cross-Site Request Forgery)
는 악의적인 사용자가 인증된 사용자의 권한을 이용하여 특정 웹 애플리케이션에 대해 비인가 요청을 보내는 공격이다. 이 공격은 사용자가 자신의 의지와 무관하게 공격자가 의도한 요청을 악용하여 이루어진다. 이 공격을 방지하기 위해 웹 애플리케이션에서 발급하는 랜덤한 토큰인 CSRF 토큰을 사용한다.
OAuth 2.0에서는 공격자가 인가 서버를 가장하고 클라이언트 애플리케이션(스프링 서버)에게 CSRF 공격하는 것을 방지하기 위해 state
파라미터를 사용한다.
프로젝트에서 소셜 로그인 구현을 위해 spring-boot-starter-oauth2-client
라이브러리를 사용 중이다. 이는 스프링 부트의 일부인 스프링 시큐리티에 대한 의존성을 포함하는 라이브러리로, OAuth 2.0과 관련된 처리를 도와준다.
이 라이브러리는 소셜 로그인 요청 URL을 받으면 해당 인증 페이지로 리디렉션 시켜주고, 인가 코드를 받아서 access token을 발급받는 등의 동작을 알아서 수행한다. 때문에 AuthorizationRequestRepository
인터페이스를 implements 해서 구현해야 한다.
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
// 쿠키에 REDIRECT URL 첨부
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
위와 같이 AuthorizationRequestRepository
를 implements한 클래스에서 클라이언트에서 전달된 redirect uri를 쿠키에 저장한다.
애플은 상황이 좀 다르다. 애플은 Oauth 2.0 표준을 베이스로 한 자체 인증 메커니즘(Sign in with Apple
)을 제공한다. 이 때문에 spring-boot-starter-oauth2-client
를 사용해서 구현한 네이버, 카카오, 구글 로그인과 달리 애플 로그인은 자체적으로 구현해야 했다. (애플 로그인 구현 게시글은 이전에 올렸었다.)
그렇지만 애플에도 마찬가지로 CSRF 공격을 방지하기 위한 state 파라미터가 있다. 쿠키가 아니라 인증 페이지에 연결할 때 파라미터로 넘겨줄 수 있다.
String loginUrl = APPLE_AUTH_URL + "/auth/authorize"
+ "?client_id=" + appleProperties.getClientId()
+ "&redirect_uri=" + appleProperties.getRedirectUrl()
+ "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";
if (redirectUri != null && !redirectUri.isEmpty()) {
loginUrl = loginUrl + "&state=" + redirectUri;
log.info("리액트에서 애플 로그인 요청 시도 : redirect_uri를 state 파라미터에 추가");
}
나는 redirectUri만 전달하면 되기 때문에 String으로 넣었지만, 여러 데이터가 필요한 경우 JSON Object로 만들어서 인코딩 데이터를 넣어주면 된다. 이후 인가 코드를 받을 때 code
와 함께 state
를 파라미터로 받아볼 수 있다.
dependencies {
...
// 보안
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
...
}
의존성으로 스프링 시큐리티와 oauth2-client를 추가해준다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: {google-client-id}
client-secret: {google-client-secret}
redirect-uri: {base-url}/login/oauth2/code/google # 인증 완료 후 인가 코드를 받을 URI
authorization-grant-type: authorization_code
scope: profile, email # 인가 후 받아올 사용자 정보 범위
kakao:
client-id: {kakao-client-id}
redirect-uri: {base-url}/login/oauth2/code/kako
client-authentication-method: POST
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image
client-name: Kakao
naver:
client-id: {naver-client-id}
client-secret: {naver-client-secret}
redirect-uri: {base-url}/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope: nickname, email, profile_image
client-name: Naver
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:
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
apple:
team-id: {apple-team-id}
login-key: {apple-login-key}
client-id: {apple-client-id}
redirect-url: {base-url}/api/callback/apple
key-path: "key/AuthKey.p8"
/login/oauth2/code
엔드포인트는 spring-boot-starter-oauth2-client
에서 제공하는 기본 OAuth 2.0 로그인 엔드포인트 중 하나이다.redirect-uri
에 이렇게 설정해두면 라이브러리에서 인가 코드를 수신하고 provider
정보의 token_uri
로 액세스 토큰 요청을 보낸다.OAuth2LoginAuthenticationFilter
클래스에서 수행한다고 한다.@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
...
// 소셜 로그인 관련
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
private final CustomOAuth2UserService customOAuth2UserService;
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin().disable()
.httpBasic().disable()
.csrf().disable()
.cors().configurationSource(corsConfigurationSource()).and()
.headers().frameOptions().disable()
.and()
...
// 아래 URL로 들어오는 요청들은 Filter 검사에서 제외됨
.requestMatchers(
"/api/signup",
"/api/user/validation/**",
"/api/callback/**",
"/api/test/**",
"/aws",
"/login/**", // 소셜 로그인 redirect url
"/oauth2/login/apple",
"/h2-console/**")
.permitAll()
// 이 외 나머지 요청은 보안 처리
.anyRequest().authenticated()
.and()
//== 소셜 로그인 설정 ==//
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository())
.and()
.successHandler(oAuth2LoginSuccessHandler) // 동의하고 계속하기를 눌렀을 때 Handler 설정
.failureHandler(oAuth2LoginFailureHandler) // 소셜 로그인 실패 시 핸들러 설정
.userInfoEndpoint().userService(customOAuth2UserService); // customUserService 설정
return http.getOrBuild();
}
...
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("*"); // 허용할 Origin 설정, *은 모든 Origin을 허용하는 것이므로 실제 환경에서는 제한 필요
configuration.addAllowedMethod("*"); // 허용할 HTTP Method 설정
configuration.addAllowedHeader("*"); // 허용할 HTTP Header 설정
configuration.addExposedHeader("Authorization");
configuration.addExposedHeader("Authorization-refresh");
configuration.setAllowCredentials(false); // Credentials를 사용할지 여부 설정
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용
return source;
}
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
...
}
SecurityConifg.java
파일의 일부만 가져왔다./login/**
나 /oauth2/login/apple
등의 URI는 접근 권한이 없어도 통과할 수 있도록 했다.authorizationEndpoint()
에서 AuthorizationRequestRepository
를 implements한 클래스를 붙여준다./oauth2/authorization/{provider}
) 또는 인증 서버에서 인가 코드와 함께 리디렉션 해줄 때(/login/oauth2/code/{provider}
) 호출된다. 실제 우리가 구현한 클래스들이 어떻게, 어떤 순서로 실행되는지 간단하게 짚어보자.
/oauth2/authorization/{provider}?redirect_uri=<redirect_uri_after_login}
으로 접속하는 것으로 시작된다./oauth2/authorization/{provider}
에 접속하면 해당 provider에 맞는 인증 페이지로 리디렉션 시켜준다. 이를 변경하고 싶다면 위의 SecurityConfig.java
에서 엔드포인트를 수정하면 된다.provider
는 google, naver, kakao 등이 될 수 있다.redirect_uri
는 있어도 되고 없어도 된다. 우리는 이것의 유무로 리액트에서의 요청과 그 외의 요청을 구분하기 때문에 리액트에서 소셜 로그인을 하려면 redirect_uri
파라미터가 있어야 한다.redirect_uri
는 provider
의 어플리케이션에서 사전에 설정하고 yml 파일에서 지정한 redirect_uri
와는, OAuth에서 사용되는 것과는 다르다. provider
의 authorizationUri
로 리디렉션 시킨다.authorizationUri
는 oauth-client 라이브러리 내부에 지정되어 있거나, yml에서 provider
설정을 했던 uri이다.OAuth2AuthorizationRequest
객체에서 가지고 있다.SecurityConfig
에서 지정된 HttpCookieOAuth2AuthorizationRequestRepository
를 사용하여 저장된다.provider
가 제공하는 페이지에서 앱에 대한 권한을 허용/거부한다. 사용자가 앱에 대한 권한을 허용하면 공급자는 사용자를 인증 코드오 함께 콜백 URL /login/oauth2/code/{provider}
로 리디렉션 시킨다. OAuth2LoginFailureHandler
가 호출된다.access token
을 발급받기 위해 authorization_code
를 교환하고, SecurityCofnig
에서 지정한 customOAuth2UserService
을 호출한다..userInfoEndpoint().userService(customOAuth2UserService)
customOAuth2UserService
에서는 Resource Server
로부터 받아온 사용자 정보가 기존 DB에 있는지 검색하고 결과에 따라 신규 데이터 저장 또는 업데이트를 수행한다.OAuth2LoginSuccessHandler
가 호출된다. 여기서 redirect_uri 정보가 쿠키에 저장되어 있는지 여부에 따라 리액트와 그 외(네이티브 앱) 요청을 구분하여 리디렉션 또는 유저 정보 객체 반환 등의 작업을 수행한다.위에서 설명했던 것처럼 OAuth 2.0에서는 CSRF 공격을 방지하기 위해 state
파라미터 사용을 권장한다. 클라이언트 애플리케이션(스프링 API 서버)은 인증 요청에서 이 매개 변수를 전송하고, OAuth2 공급자는 OAuth2 콜백에서 변경되지 않은 이 매개 변수를 리턴한다.
클라이언트 애플리케이션은 OAuth2 공급자에서 반환 된 state
매개 변수의 값을 초기에 보낸 값과 비교한다. 일치하지 않으면 인증 요청을 거부한다.
이 흐름을 얻기 위해서는 클라이언트 애플리케이션이 인가 서버에서 반환된 상태와 비교할 수 있도록 state
매개 변수를 어딘가에 저장해야 한다. 아래 클래스에서는 인증 요청을 쿠키에 저장하고 검색하는 기능을 제공한다.
saveAuthorizationRequest(...)
메소드는 사용자가 /oauth2/authorization/{provider}
에 접속하면 호출되며, 리디렉션 시키기 전에 쿠키에 정보를 저장한다. 이 때 /oauth2/authorization/{provider}?redirect_uri=<redirect-uri-after-login>
와 같이 요청이 오면 redirect uri
도 쿠키에 함께 저장한다.removeAuthorizationRequest(...)
메소드는 서비스 provider에서 설정한 redirect uri로 인가 코드를 받고 나서 호출된다.@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
// 쿠키에 REDIRECT URL 첨부
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
}
OAuth2UserService를 implements한 이 클래스는 loadUser(...)
메소드를 오버라디딩하며, provider
로부터 access token을 얻은 후에 호출된다. 인증된 사용자의 정보를 확인해서 DB에 이미 있다면 업데이트하고, 없다면 새로 사용자를 등록한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserAccountRepository userRepository;
private static final String NAVER = "naver";
private static final String KAKAO = "kakao";
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("CustomOAuth2UserService.loadUser() 실행 - OAuth2 로그인 요청 진입");
/**
* DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환
* DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서
* 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다.
* 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저
*/
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
/**
* userRequest에서 registrationId 추출 후 registrationId으로 AuthProvider 저장
* http://localhost:8080/login/oauth2/code/kakao에서 kakao가 registrationId
* userNameAttributeName은 이후에 nameAttributeKey로 설정된다.
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
AuthProvider authProvider = getSocialType(registrationId);
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값
Map<String, Object> attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들)
String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();
// socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성
OAuthAttributes extractAttributes = OAuthAttributes.of(authProvider, userNameAttributeName, attributes);
UserAccount createdUser = getUser(extractAttributes, authProvider, oauth2AccessToken); // getUser() 메소드로 User 객체 생성 후 반환
// DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())),
attributes,
extractAttributes.getNameAttributeKey(),
createdUser.getEmail(),
createdUser.getRole()
);
}
private AuthProvider getSocialType(String registrationId) {
if (NAVER.equals(registrationId)) {
return AuthProvider.NAVER;
}
if (KAKAO.equals(registrationId)) {
return AuthProvider.KAKAO;
}
return AuthProvider.GOOGLE;
}
/**
* SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
* 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 saveUser()를 호출하여 회원을 저장한다.
*/
private UserAccount getUser(OAuthAttributes attributes, AuthProvider socialType, String accessToken) {
UserAccount findUser = userRepository.findByAuthProviderAndSocialId(socialType,
attributes.getOauth2UserInfo().getId()).orElse(null);
// 신규 회원가입의 경우 DB에 저장
if (findUser == null) {
return saveUser(attributes, socialType, accessToken);
}
// 기존 회원의 경우 access token 업데이트를 위해 DB에 저장
findUser.setOauth2AccessToken(accessToken);
return userRepository.save(findUser);
}
/**
* OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환
* 생성된 User 객체를 DB에 저장 : socialType, socialId, email, role 값만 있는 상태
*/
private UserAccount saveUser(OAuthAttributes attributes, AuthProvider authProvider, String accessToken) {
UserAccount createdUser = attributes.toEntity(authProvider, attributes.getOauth2UserInfo(), accessToken);
return userRepository.save(createdUser);
}
}
CustomOAuth2UserService
와 OAuth2LoginSuccessHandler
사이에서 이메일과 Role 필드를 추가로 가지는 클래스를 만들기 위해서 DefaultOAuth2User
를 상속한 클래스다.
/**
* DefaultOAuth2User를 상속하고, email과 role 필드를 추가로 가진다.
* 최초 로그인 이후 성별, 연령대 등의 정보를 추가로 얻기 위해 role을 구분함
*/
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {
private String email;
private Role role;
/**
* Constructs a {@code DefaultOAuth2User} using the provided parameters.
*
* @param authorities the authorities granted to the user
* @param attributes the attributes about the user
* @param nameAttributeKey the key used to access the user's "name" from
* {@link #getAttributes()}
*/
public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities,
Map<String, Object> attributes, String nameAttributeKey,
String email, Role role) {
super(authorities, attributes, nameAttributeKey);
this.email = email;
this.role = role;
}
}
모든 OAuth provider는 access token을 사용해 인증된 사용자의 세부 정보를 가져올 때 JSON 데이터를 반환한다. Spring Security는 key-value 쌍의 일반 Map 형식으로 응답을 분석한다.
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id"
public abstract String getNickname();
public abstract String getImageUrl();
}
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 getNickname() {
return (String) attributes.get("name");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getNickname() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
if (account == null || profile == null) {
return null;
}
return (String) profile.get("nickname");
}
@Override
public String getImageUrl() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
if (account == null || profile == null) {
return null;
}
return (String) profile.get("thumbnail_image_url");
}
}
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("id");
}
@Override
public String getNickname() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("nickname");
}
@Override
public String getImageUrl() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("profile_image");
}
}
OAuth 인증과 사용자 정보를 얻어오는게 모두 성공적으로 이루어졌을 때 호출된다.
로그인이 성공했을 때 JWT 토큰, access token과 refresh token을 생성한다. 이후 쿠키를 확인하여 redirect uri
여부에 따라 처리한다.
redirect uri
가 있는 경우redirect uri
가 없는 경우UserInfoResponse
DTO 클래스 형태로 변환시킨 후 반환한다.@Slf4j
@EnableConfigurationProperties({ JwtProperties.class })
@RequiredArgsConstructor
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final UserRetrievalService userRetrievalService;
private final JwtService jwtService;
private final JwtProperties jwtProperties;
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
log.info("OAuth2 Login 성공!");
CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
String accessToken = jwtService.createAccessToken(oAuth2User.getEmail());
String refreshToken = jwtService.createRefreshToken();
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken);
Optional<String> cookieRedirectUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
// 리액트에서 로그인 시도한 경우
if (cookieRedirectUrl.isPresent()) {
String redirectUrl = UriComponentsBuilder.fromUriString(cookieRedirectUrl.get())
.path("/success")
.queryParam("token", accessToken)
.queryParam("refresh_token", refreshToken)
.build().toUriString();
log.info("리액트 서버의 소셜 로그인 요청 : redirect 작업을 수행합니다.");
redirectStrategy.sendRedirect(request, response, redirectUrl);
} else {
// 로그인 시 response에 유저 정보 첨부
UserAccount user = userRetrievalService.getUserFromAccessToken(accessToken);
UserInfoResponse userInfoResponse = UserInfoResponse.toDTO(user);
log.info("모바일 애플리케이션의 소셜 로그인 요청 : 유저 정보를 첨부하여 응답합니다.");
Utils.sendLoginSuccessResponseWithUserInfo(response, userInfoResponse);
}
log.info("로그인에 성공하였습니다.");
log.info("이메일 : {}", oAuth2User.getEmail());
log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken);
log.info("로그인에 성공하였습니다. RefreshToken : {}", refreshToken);
log.info("발급된 AccessToken 만료 기간 : {}", LocalDateTime.now().plusSeconds(jwtProperties.getAccess().getExpiration() / 1000));
log.info("발급된 RefreshToken 만료 기간 : {}", LocalDateTime.now().plusSeconds(jwtProperties.getRefresh().getExpiration() / 1000));
}
// TODO : 소셜 로그인 시에도 무조건 토큰 생성하지 말고 JWT 인증 필터처럼 RefreshToken 유/무에 따라 다르게 처리해보기
}
OAuth 인증 중 오류가 발생하면 Spring Security는 SecurityConfig
에서 설정한 failure handler를 호출한다. 쿼리 파라미터로 에러 메세지를 첨부해서 리디렉션을 시킬 수도 있다.
인증이 성공했을 때와 마찬가지로 리액트와 그 외 케이스를 구분해서 리디렉션 또는 유저 정보 반환을 수행한다.
@Slf4j
@Component
public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
Optional<String> cookieRedirectUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
// 리액트에서 로그인 시도한 경우
if (cookieRedirectUrl.isPresent()) {
String redirectUrl = UriComponentsBuilder.fromUriString(cookieRedirectUrl.get())
.path("/fail")
.build().toUriString();
log.info("리액트 서버의 소셜 로그인 실패 : redirect 수행");
redirectStrategy.sendRedirect(request, response, redirectUrl);
} else {
// 로그인 실패 response 생성
Utils.sendErrorResponse(response, HttpServletResponse.SC_BAD_REQUEST, ResponseCode.LOGIN_FAILURE);
log.info("모바일 애플리케이션의 소셜 로그인 실패");
}
log.info("소셜 로그인에 실패했습니다. 에러 메시지 : {}", exception.getMessage());
}
}
쿠키를 저장하고 꺼낼 때 사용하는 Util 클래스다.
public class CookieUtils {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie: cookies) {
if (cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object object) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(object));
}
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
애플은 OAuth 2.0 표준을 베이스로 한 자체 인증 메커니즘(Sign in with Apple)을 제공한다. 때문에 spring-boot-starter-oauth2-client
에서는 공식적으로 지원을 해주지 않는 것으로 알고 있다. 이 라이브러리를 사용해서 애플 로그인을 어떻게 구현할 수는 있다고 들었는데, 문서나 코드는 찾지 못해서 직접 구현했다. 해당 내용은 아래 글을 참고 바란다.
위 글의 코드를 기반으로 컨트롤러와 서비스의 일부분만 수정했다.
애플 로그인이 혼자서 자체적인 메커니즘을 가지고 있다고 해도, OAuth 2.0 기반이기 때문에 마찬가지로 CSRF 공격을 방지하기 위한 state
파라미터를 지원한다. 사용 방법도 간단하다.
애플의 인증 페이지에 접속하기 위해서는 https://appleid.apple.com/auth/authorize
뒤에 쿼리 파라미터로 client id, redirect uri(인가 코드를 받을 callback uri), response type, scope, response mode 등을 설정해줘야 한다. 여기서 state
파라미터를 추가하고 그 값을 사용하면 된다.
기존에 네이티브 앱과만 통신할 때에는 인증 페이지의 URL이 항상 고정이었기 때문에, 사용자가 직접 해당 링크로 접속하는 방식이었다. 그러나 링크가 복잡하기도 하고 리액트는 state 파라미터에 서버 주소에 따른 가변적인 redirect uri
값이 들어가야 하기 때문에 서버에서 리디렉션 해주는 방향으로 수정했다.
/oauth2/login/apple
: 해당 URL로 사용자가 접속하면 쿼리 파라미터로 redirect_uri
가 있는지 확인하고, 그에 따른 URL로 리디렉션 시킨다./api/callback/apple
code
파라미터에서 인가 코드를 읽어오고, state
값이 설정되어 있다면 해당 값도 읽어들인다.Resource Server
로부터 사용자 정보를 얻어온다. 이를 통해 로그인의 성공 여부를 알 수 있고, 리액트와 그 외의 로그인 시도를 구분해서 리디렉션 또는 DTO 객체 반환을 수행한다.@RestController
@RequiredArgsConstructor
public class AppleController {
private final AppleService appleService;
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@GetMapping("/oauth2/login/apple")
public void loginRequest(HttpServletRequest request, HttpServletResponse response,
@RequestParam(value = "redirect_uri", required = false) String redirectUri) throws IOException {
redirectStrategy.sendRedirect(request, response, appleService.getAppleLoginUrl(redirectUri));
}
@PostMapping("/api/callback/apple")
public ApiResponse<?> callback(HttpServletRequest request, HttpServletResponse response) throws IOException {
String redirectUri = request.getParameter("state");
UserAccount user = appleService.login(request.getParameter("code"));
boolean isValidRedirectUri = (redirectUri != null && !redirectUri.isEmpty());
// 로그인 성공
if (user != null) {
// 리액트로 로그인한 경우
if (isValidRedirectUri) {
redirectStrategy.sendRedirect(request, response, appleService.determineSuccessRedirectUrl(user, redirectUri));
return null;
}
// 네이티브 앱이나 기타 경로에서 로그인한 경우
appleService.loginSuccess(user, response);
return ApiResponse.createSuccess(ResponseCode.LOGIN_SUCCESS, UserInfoResponse.toDTO(user));
}
// 로그인 실패
else {
// 리액트로 로그인한 경우
if (isValidRedirectUri) {
redirectStrategy.sendRedirect(request, response, appleService.determineFailureRedirectUrl(redirectUri));
return null;
} else {
return ApiResponse.createError(ResponseCode.LOGIN_FAILURE);
}
}
}
}
AppleService
는 기존의 코드에서 변경된 것이 거의 없고, 결과에 대한 처리도 위의 일반 소셜 로그인과 동일하다.
state
파라미터 유무에 따라 redirect uri
를 붙여서 인증 페이지 URL 링크를 반환한다. 로그인 후 사용자 정보가 DB에 없다면 신규 저장하고, 있으면 업데이트한다. 로그인 성공 시 JWT 토큰과 유저 정보 등을 함께 첨부한다.
@Slf4j
@EnableConfigurationProperties({ AppleProperties.class })
@RequiredArgsConstructor
@Service
public class AppleService {
private final UserAccountRepository userRepository;
private final JwtService jwtService;
private final AppleProperties appleProperties;
private final static String APPLE_AUTH_URL = "https://appleid.apple.com";
public String getAppleLoginUrl(String redirectUri) {
String loginUrl = APPLE_AUTH_URL + "/auth/authorize"
+ "?client_id=" + appleProperties.getClientId()
+ "&redirect_uri=" + appleProperties.getRedirectUrl()
+ "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";
if (redirectUri != null && !redirectUri.isEmpty()) {
loginUrl = loginUrl + "&state=" + redirectUri;
log.info("리액트에서 애플 로그인 요청 시도 : redirect_uri를 state 파라미터에 추가");
}
log.info("애플 로그인 URL 반환");
return loginUrl;
}
public UserAccount login(String code) {
String userId;
String email;
String accessToken;
UserAccount user;
try {
JSONParser jsonParser = new JSONParser();
JSONObject jsonObj = (JSONObject) jsonParser.parse(generateAuthToken(code));
accessToken = String.valueOf(jsonObj.get("access_token"));
// ID TOKEN을 통해 회원 고유 식별자 받기
SignedJWT signedJWT = SignedJWT.parse(String.valueOf(jsonObj.get("id_token")));
ReadOnlyJWTClaimsSet getPayload = signedJWT.getJWTClaimsSet();
ObjectMapper objectMapper = new ObjectMapper();
JSONObject payload = objectMapper.readValue(getPayload.toJSONObject().toJSONString(), JSONObject.class);
userId = String.valueOf(payload.get("sub"));
email = String.valueOf(payload.get("email"));
UserAccount findUser = userRepository
.findByAuthProviderAndSocialId(AuthProvider.APPLE, userId)
.orElse(null);
if (findUser == null) {
// 신규 회원가입의 경우 DB에 저장
logWithOauthProvider(AuthProvider.APPLE, "신규 회원가입 DB 저장");
user = userRepository.save(
UserAccount.builder()
.authProvider(AuthProvider.APPLE)
.socialId(userId)
.email(email)
.role(Role.GUEST)
.oauth2AccessToken(accessToken)
.refreshToken(jwtService.createRefreshToken())
.build()
);
} else {
// 기존 회원의 경우 access token 업데이트를 위해 DB에 저장
logWithOauthProvider(AuthProvider.APPLE, "기존 회원 DB 업데이트");
findUser.setOauth2AccessToken(accessToken);
user = userRepository.save(findUser);
}
return user;
} catch (ParseException | JsonProcessingException e) {
throw new RuntimeException("Failed to parse json data");
} catch (IOException | java.text.ParseException e) {
throw new RuntimeException(e);
}
}
public void loginSuccess(UserAccount user, HttpServletResponse response) {
String accessToken = jwtService.createAccessToken(user.getEmail());
String refreshToken = jwtService.createRefreshToken();
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
jwtService.updateRefreshToken(user.getEmail(), refreshToken);
}
public String determineSuccessRedirectUrl(UserAccount user, String baseUrl) {
String accessToken = jwtService.createAccessToken(user.getEmail());
String refreshToken = jwtService.createRefreshToken();
jwtService.updateRefreshToken(user.getEmail(), refreshToken);
return UriComponentsBuilder.fromUriString(baseUrl)
.path("/success")
.queryParam("token", accessToken)
.queryParam("refresh_token", refreshToken)
.build().toUriString();
}
public String determineFailureRedirectUrl(String baseUrl) {
return UriComponentsBuilder.fromUriString(baseUrl)
.path("/fail")
.build().toUriString();
}
public String generateAuthToken(String code) throws IOException {
if (code == null) throw new IllegalArgumentException("Failed get authorization code");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", appleProperties.getClientId());
params.add("client_secret", createClientSecretKey());
params.add("code", code);
params.add("redirect_uri", appleProperties.getRedirectUrl());
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
APPLE_AUTH_URL + "/auth/token",
HttpMethod.POST,
httpEntity,
String.class
);
return response.getBody();
} catch (HttpClientErrorException e) {
throw new IllegalArgumentException("Apple Auth Token Error");
}
}
public String createClientSecretKey() throws IOException {
// headerParams 적재
Map<String, Object> headerParamsMap = new HashMap<>();
headerParamsMap.put("kid", appleProperties.getLoginKey());
headerParamsMap.put("alg", "ES256");
// clientSecretKey 생성
return Jwts
.builder()
.setHeaderParams(headerParamsMap)
.setIssuer(appleProperties.getTeamId())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 30)) // 만료 시간 (30초)
.setAudience(APPLE_AUTH_URL)
.setSubject(appleProperties.getClientId())
.signWith(SignatureAlgorithm.ES256, getPrivateKey())
.compact();
}
public String getAppleClientId() {
return appleProperties.getClientId();
}
private PrivateKey getPrivateKey() throws IOException {
ClassPathResource resource = new ClassPathResource(appleProperties.getKeyPath());
String privateKey = new String(resource.getInputStream().readAllBytes());
Reader pemReader = new StringReader(privateKey);
PEMParser pemParser = new PEMParser(pemReader);
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
return converter.getPrivateKey(object);
}
}
몇달 전에 다른 사람이 작성한 코드를 가져와서 적용하거나 직접 짜고, 지금 또 추가적으로 수정하니 전체적으로 코드가 어수선한 감이 있다. 당시에는 OAuth 2.0과 코드를 제대로 이해하지 못한 것도 있어 더욱 그런 것 같다. 다음 프로젝트에서도 소셜 로그인을 구현하게 된다면 코드를 다듬고 좀 더 깔끔하게 정리해서 글을 쓸 예정이다.