Java
google
spring-security
spring-boot

Spring Boot + Spring Security な REST API で Google Sign-In してみた

Spring Bootで作ったREST APIサーバに、Google Sign-Inでログインできるよう対応したので、やったことを書いておきます。

使っているSpring Bootのバージョンは1.5.7です。
なお、以下でやっていることのほとんどは@EnableOAuth2Ssoで実現できてしまうかも知れないですが、それでは面白くないというか、アレなので。

準備(認証のREST API化)

Google Sign-Inの対応の前に、Spring Securityのデフォルト動作をRESTな感じに変更します。

参考サイト:http://www.baeldung.com/securing-a-restful-web-service-with-spring-security

エントリーポイント

標準的なWebアプリでは、未認証状態でセキュアなリソースへアクセスすると自動的に認証を促す動作となりますが、RESTサービスでは明示的に認証を行うため、このような場合には単に401を返却する動作とします。

RestAuthenticationEntryPoint.java
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
  }
}

ログイン成功時に301ではなく200を返す

デフォルトではログイン成功時に301を返してログイン後の画面へ誘導する動作となっていますが、RESTでは単に200を返すのみとします。
SavedRequestAwareAuthenticationSuccessHandlerを参考にリダイレクトしないAuthenticationSuccessHandlerを作成します。

RestSavedRequestAwareAuthenticationSuccessHandler.java
@Component
public class RestSavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private RequestCache requestCache = new HttpSessionRequestCache();

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request,
                                      HttpServletResponse response, Authentication authentication) {
    SavedRequest savedRequest = requestCache.getRequest(request, response);

    if (savedRequest == null) {
      clearAuthenticationAttributes(request);
      return;
    }
    String targetUrlParameter = getTargetUrlParameter();
    if (isAlwaysUseDefaultTargetUrl()
      || (targetUrlParameter != null && StringUtils.hasText(request
      .getParameter(targetUrlParameter)))) {
      requestCache.removeRequest(request, response);
      clearAuthenticationAttributes(request);
      return;
    }

    clearAuthenticationAttributes(request);
  }
}

ログイン失敗時に302ではなく401を返す

同様に、ログイン失敗時は単に401を返すようにします。
こちらはSpringのSimpleUrlAuthenticationFailureHandlerがそのまま使えます。

ログアウト時も200を返すだけ

同じく、ログアウト時もリダイレクトせず200を返すのみとします。
こちらも、SpringのSimpleUrlLogoutSuccessHandlerが使えます。

SecurityConfig

ここまでの状態でConfigurationを作成します。

SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  @Autowired
  private RestAuthenticationEntryPoint authenticationEntryPoint;

  @Autowired
  private RestSavedRequestAwareAuthenticationSuccessHandler authenticationSuccessHandler;

  @Autowired
  private SimpleUrlAuthenticationFailureHandler authenticationFailureHandler;

  @Autowired
  private SimpleUrlLogoutSuccessHandler logoutSuccessHandler;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .csrf().disable()
      .authorizeRequests()
        .antMatchers("/api/**").authenticated()
        .and()
      .exceptionHandling()
        .authenticationEntryPoint(authenticationEntryPoint)
        .and()
      .formLogin()
        .successHandler(authenticationSuccessHandler)
        .failureHandler(authenticationFailureHandler)
        .and()
      .logout()
        .logoutSuccessHandler(logoutSuccessHandler)
    ;
  }

  @Bean
  public SimpleUrlAuthenticationFailureHandler simpleUrlAuthenticationFailureHandler() {
    return new SimpleUrlAuthenticationFailureHandler();
  }

  @Bean
  public SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler() {
    return new SimpleUrlLogoutSuccessHandler();
  }
}

Google Sign-Inを実装する

いよいよ本題。
ココココを参考に。
Webアプリ(フロント側)からは、サインイン時に取得できるid_tokenx-www-form-urlencoded/loginにPOSTするよう実装した状態。
パラメーター名はgoogle_id_tokenとしました。

SecurityConfigを修正

作成済みのSecurityConfig#configure(HttpSecurity http)formLogin()の箇所を以下のようにします。

SecurityConfig.java
      .formLogin()
        .passwordParameter("google_id_token")
        .successHandler(authenticationSuccessHandler)

passwordParameter("google_id_token")を追加しただけです。
これで、デフォルトであるユーザー名とパスワードによる認証のパスワードとしてid_tokenを受け取ります。

AuthenticationProviderを実装

ここまででid_tokenを受け取れるようになっているので、後は認証を実装するだけです。

GoogleIdAuthenticationProvider.java
@Component
public class GoogleIdAuthenticationProvider implements AuthenticationProvider {

  private final String clientId = "XXX.apps.googleusercontent.com";

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String tokenString = (String) authentication.getCredentials();
    GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(UrlFetchTransport.getDefaultInstance(), JacksonFactory.getDefaultInstance())
      .setAudience(singletonList(clientId))
      .build();
    GoogleIdToken idToken;
    try {
      idToken = verifier.verify(tokenString);
      if (idToken == null) {
        throw new BadCredentialsException("Failed to verify token");
      }
    } catch (GeneralSecurityException|IOException e) {
      throw new AuthenticationServiceException("Failed to verify token", e);
    }

    GoogleIdToken.Payload payload = idToken.getPayload();
    String userId = payload.getSubject();
    String name = (String) payload.get("name");
    GoogleUser user = new GoogleUser(userId, name);
    List<GrantedAuthority> authorities = singletonList(new SimpleGrantedAuthority("ROLE_USER"));
    return new UsernamePasswordAuthenticationToken(user, tokenString, authorities);
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.equals(authentication);
  }
}

特定ドメインに制限したい場合は、payload.getHostedDomain()をチェックすることで実現できます。

GoogleUserクラスは以下の通り。

GoogleUser.java
@Data
@RequiredArgsConstructor
@AllArgsConstructor
public class GoogleUser implements UserDetails {

  @NotNull
  @Setter(AccessLevel.NONE)
  private String userId;

  private String username;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  public String getPassword() {
    return null;
  }

  @Override
  public boolean isAccountNonExpired() {
    return false;
  }

  @Override
  public boolean isAccountNonLocked() {
    return false;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return false;
  }

  @Override
  public boolean isEnabled() {
    return false;
  }
}

これでGoogle Sign-Inに対応できました。