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`を返却する動作とします。

```java: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`を作成します。

```java: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を作成します。

```java: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を実装する
いよいよ本題。
[ココ](https://developers.google.com/identity/sign-in/web/sign-in)と[ココ](https://developers.google.com/identity/sign-in/web/backend-auth)を参考に。
Webアプリ（フロント側）からは、サインイン時に取得できる`id_token`を`x-www-form-urlencoded`で`/login`にPOSTするよう実装した状態。
パラメーター名は`google_id_token`としました。

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

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

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

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

```java: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`クラスは以下の通り。

```java: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に対応できました。