-
소셜 로그인 회고 (Spring Security)Java & Spring 2024. 2. 4. 22:24
사이드 프로젝트로 개발 중인 애플리케이션에 소셜 로그인을 도입하게 되었다.
일단은 구글 로그인을 개발해야 하는데, 일반적으로 쓰는 방법인
1. oauth2Login()을 사용한 서버에서 구글 로그인 페이지로 요청
2. 토큰 받고 다시 구글에서 내 서버로 redirect
하는 것이 아닌, 앱에서 일련의 과정을 처리 후 서버로 토큰만 보내주는 구조를 채택했다.
이유는?? 이라 묻는다면
트래픽과 처리량을 띵킹해본다...대규모 서비스가 될거라 상상하며 ㅋㅋ 로그인 과정에 있어 최대한 우리 서버에 부담을 덜고 싶었다. 앱에서 구글을 바로 찔러 토큰을 받아낸다면, 굳이 우리 서버에서 구글로 토큰 요청을 할 필요가 없을 것 같았다. 이런 요소 하나하나가 모이면... 적은 돈으로 가성비 좋게 만들 수 있지 않을까..?
아래 그림은 대략적인 로그인 처리 Flow를 보여준다.
우선 구글에서 제공받는 토큰은 3개로, 내가 이해한 내용은 아래와 같다. (https://cloud.google.com/docs/authentication/token-types?hl=ko#access)
- Access Token : 구글 Oauth API에 접근하기 위한 토큰
- ID Token : 구글 가입자의 상세 정보를 담고 있음. 애플리케이션에서 이 토큰을 디코딩해서 필요한 정보를 추출 가능.
- Refresh Token : Access Token은 만료기간이 짧기 때문에 Refresh Token을 이용해 재발급 받는 용도
SecurityConf.java (시큐리티 설정)
@Configuration @EnableWebSecurity public class SecurityConf extends WebSecurityConfigurerAdapter { //실질적인 로그인 요청 검증이 이루어지는 클래스다. (CustomAuthenticationProvider) @Bean public CustomAuthenticationProvider customAuthenticationProvider(){ return new CustomAuthenticationProvider(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception{ auth.authenticationProvider(customAuthenticationProvider()); } @Override protected void configure (HttpSecurity http) throws Exception{ List<String> skipPathList = new SkipPathList(); // 인증절차 없이 접근 허용할 목록 http .csrf().disable() .formLogin().disable() .rememberMe().disable() .httpBasic().disable() .authorizeRequests() .antMatchers(skipPathList.toArray(new String[0])).permitAll() .anyRequest().authenticated() .and() .logout() .logoutUrl("/logout") .invalidateHttpSession(true) .deleteCookies() .clearAuthentication(true) .logoutSuccessHandler(customLogoutSuccessHandler()) // 이 아래에 exceptionHandling 처리를 해주어야 함. ; // UsernamePasswordAuthenticationFilter를 상속받은 Custom Filter를 넣어주었다. // 이 필터말고도 Oauth2 관련 필터가 있는데 설계한 방식에 따라 사용하면 되겠다. http.addFilterAt(getCustomTokenValidationFilter(), UsernamePasswordAuthenticationFilter.class); } @Bean public CustomTokenValidationFilter getCustomTokenValidationFilter() throws Exception{ CustomTokenValidationFilter filter = new CustomTokenValidationFilter(); filter.setAuthenticationManager(getAuthenticationManager()); filter.setFilterProcessesUrl("/login"); // 로그인 요청 URI다. filter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler()); filter.setAuthenticationFailureHandler(appAuthenticationFailureHandler()); filter.setAllowSessionCreation(true); return filter; } @Bean @Primary protected AuthenticationManager getAuthenticationManager() throws Exception{ return super.authenticationManager(); } @Bean public AuthenticationSuccessHandler appAuthenticationSuccessHandler() { SuccessLoginHandler successLoginHandler = new SuccessLoginHandler(); return successLoginHandler; } @Bean public AuthenticationFailureHandler appAuthenticationFailureHandler() { return new FailLoginHandler(); } @Bean public CustomLogoutSuccessHandler customLogoutSuccessHandler(){ return new CustomLogoutSuccessHandler(); } }
Secuirty Configuration 클래스를 이렇게 작성했다면 로그인 요청을 "localhost:8080/login" 으로 했을 경우 CustomTokenValidationFilter가 /login 요청을 가로챈다.
CustomTokenValidationFilter.java
public class CustomTokenValidationFilter extends UsernamePasswordAuthenticationFilter { private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private UserRepository userRepository; private Map<String, Object> getRequestBody(HttpServletRequest request) throws IOException { Map<String, Object> jsonRequest = null; if (request.getContentType() != null && request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) && "POST".equals(request.getMethod())) { // JSON 데이터를 읽어오는 부분 jsonRequest = objectMapper.readValue(request.getReader(), Map.class); } return jsonRequest; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { Map<String, Object> requestBodyMap = null; String idTokenValue = ""; String refreshTokenValue = ""; GoogleIdTokenDto googleIdTokenDto = null; try { requestBodyMap = getRequestBody(request); // id token을 decode하는 과정이 있는데 이거 말고... Google API를 사용하여 개선했다. // TokenVerifier 클래스를 따로 만들었음. idTokenValue = decryptIdToken((String)requestBodyMap.get("idToken")); refreshTokenValue = (String)requestBodyMap.get("refreshToken"); } catch (IOException e) { throw new RuntimeException("Failed to extract payload of HttpServletRequest. getRequestBody() ", e); } // attemptAuthentication()에서 UsernamePasswordAuthenticationToken은 아직 인증을 받지 못한 토큰이다. // 때문에 여기선 인자값이 2개인 생성자를 쓰고, 추후 3개짜리는 인증이 끝난 경우 쓴다. UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(idTokenValue, refreshTokenValue); setDetails(request, authToken); return this.getAuthenticationManager().authenticate(authToken); } // 이렇게 디코딩하는게 무조건 틀린? 건 아닌듯한데, 일단 구글에서 지원하는 API를 최대한 활용!! private String decryptIdToken(String idToken){ byte[] decode = new Base64UrlCodec().decode(idToken.split("\\.")[1]); // payload 부분을 위해 split return new String(decode, StandardCharsets.UTF_8); } }
ID Token을 Request Body에서 추출한다. 앱에서 Post + JSON 형태로 보내주니 일단 꺼내고, decoding을 해준다.
참고로 request.getReader()를 사용한 순간, 이 HTTP 트랜잭션 안에서 더 이상 body값을 꺼낼 수 없다.
이럴 때 뭔가 Thread Local + 어노테이션을 이용해서... body의 필요한 값을 재사용할 수 있게 개선해보는것도 좋을 것 같다.
아래 나오는 코드는 Google API를 이용해서 앱으로부터 받은 토큰이 정말 유효한 google 토큰인지 검증하는 과정이다.
TokenVerifier.java
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.gson.GsonFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * 구글 토큰 유효성 검사 클래스 */ @Slf4j @Component public class TokenVerifier { private TokenVerifier(){ } private static String clientId; // 구글에 등록한 client id이다. @Value("${spring.security.oauth2.client.registration.google.client-id}") public void setClientId(String value) { clientId = value; } public static void verifyIdToken(String idTokenString) throws GeneralSecurityException, IOException { GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) .setAudience(Collections.singletonList(clientId)) // client id가 여러개면 이렇게 //.setAudience(Arrays.asList(CLIENT_ID_1, CLIENT_ID_2, CLIENT_ID_3)) .build(); GoogleIdToken idToken = verifier.verify(idTokenString); // 검증!! if (idToken != null) { log.info("It's Correct Id Token : " + idToken.toString()); } else { log.info("Invalid ID token."); } } }
이 과정에서 GoogleIdTokenVerifier.Builder() 의 인자값에 대한 설명이 Google API Reference에 나와있지 않다.
좀 불친절할 수 있겠지만... Builder()를 타고 타고 들어가보거나, Stack Overflow 같은데에 찾아보면 대략? 짐작이 간다.
NetHttpTransport() 같은 경우, 가장 많이 쓰이고, GsonFactory() 저거는 거의 고정인것 같았다.
최대한 구글에서 권장하는 방식을 사용하려 했고, 참고한 링크들은 조만간 다시 정리해보자.
'Java & Spring' 카테고리의 다른 글
API 에러 응답 논쟁 (2) 2024.09.19 EDI 구축 회고록 (1) 2024.08.29 Stream vs for loop (0) 2024.05.19