Spring SecurityにFacebookでOpenID Connectする方法

はじめに

Facebook APIを使ってSpring Securityを利用したウェブサイトの認証をやろうという企画の「本編」です。かなりニッチな気がしますがウェブ上に情報がないようなので参考までに。
amarec(20160718-183021)

関連記事

要約

一言でいえば、SpringにFacebook(Twitter, Github, LinkedIn)のOpenID Connectのライブラリあります。ただ、Spring Securityを使ったWebサービスの実装形態によっては多少の工夫が必要だと思います。こちらこちらの記事を参考にしました。

開発環境、前提条件

開発環境は

  • Java1.8以上
  • Maven3.0以上

前提条件となるSpringのウェブサービスは

  • spring-framework 4.0.9
  • spring-security 3.2.9
  • spring-boot 1.2.8
  • hibernate 4.3.11

今回、新たに追加したライブラリはこちら

    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-validator</artifactId>
      <version>5.1.3.Final</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.social</groupId>
      <artifactId>spring-social-web</artifactId>
      <version>1.1.4.RELEASE</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.social</groupId>
      <artifactId>spring-social-config</artifactId>
      <version>1.1.4.RELEASE</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.social</groupId>
      <artifactId>spring-social-security</artifactId>
      <version>1.1.4.RELEASE</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.social</groupId>
      <artifactId>spring-social-facebook</artifactId>
      <version>2.0.3.RELEASE</version>
    </dependency>

依存関係を見れば一目瞭然ですが、Spring-Socialという便利なライブラリがあります。これを組み込めばSpring-MVC + Spring-Securityの組み合わせのウェブサービスの認証にFacebookなどのOpenID Connectを組み込めます。

テーブルの追加

Spring SocialのOpenID Connectでは、新たに「USERCONNECTION」というテーブルをDBに追加する必要があります。

CREATE TABLE userconnection (
  userId varchar(255) not null,
  providerId varchar(255) not null,
  providerUserId varchar(255),
  rank int not null,
  displayName varchar(255),
  profileUrl varchar(512),
  imageUrl varchar(512),
  accessToken varchar(255) not null,
  secret varchar(255),
  refreshToken varchar(255),
  expireTime bigint,
  primary key (userId, providerId, providerUserId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE UNIQUE INDEX userconnectionrank on userconnection(userId, providerId, rank);

実装部分

追加するコードや変更するコードを解説します。かなり試行錯誤(3日)しましたが、完成してみるとそんなに変更はないです。

まずは、Spring-SocialがOpenID Connectして持ってきたデータを受け取る部品を作ります。

import javax.validation.constraints.Size;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.social.connect.UserProfile;
import lombok.Data;

@Data
public class SignupForm {
  @NotEmpty
  private String username;
  @Size(min = 8, max = 16, message = "must be 8-16 characters")
  private String password;
  @NotEmpty
  private String firstName;
  @NotEmpty
  private String lastName;

  public static SignupForm fromProviderUser(UserProfile providerUser) {
    SignupForm form = new SignupForm();
    form.setFirstName(providerUser.getFirstName());
    form.setLastName(providerUser.getLastName());
    form.setUsername(providerUser.getEmail());
    return form;
  }
}

続いて、Spring-Social越しに認証を完了後、その認証情報をSpring-Security側に渡す部品を作ります。これは何気に重要です。

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;

public class SignInUtils {
  public static void signin(User user) {
    SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()));
  }
}

続いて、Spring-Social用のUserDetailsServiceを作ります。

import org.springframework.dao.DataAccessException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.social.security.SocialUser;
import org.springframework.social.security.SocialUserDetails;
import org.springframework.social.security.SocialUserDetailsService;

public class SimpleSocialUsersDetailService implements SocialUserDetailsService {

  private UserDetailsService userDetailsService;

  public SimpleSocialUsersDetailService(UserDetailsService userDetailsService) {
      this.userDetailsService = userDetailsService;
  }

  @Override
  public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException, DataAccessException {
    UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
    return new SocialUser(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
  }
}

肝となる部分に行きます。Springを使ったウェブサービスの肝部分であるConfigファイルをいじります。まずは追加するファイルです。このファイルにFacebookの認証情報(APPID, APPSECRET)を入力します。

import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.core.env.Environment;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.social.UserIdSource;
import org.springframework.social.config.annotation.ConnectionFactoryConfigurer;
import org.springframework.social.config.annotation.EnableSocial;
import org.springframework.social.config.annotation.SocialConfigurer;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.ConnectionRepository;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.social.facebook.api.Facebook;
import org.springframework.social.facebook.connect.FacebookConnectionFactory;

@Configuration
@EnableSocial
public class SocialConfig implements SocialConfigurer {

  @Autowired
  private DataSource dataSource;

  @Value("${oauth.appId}")
  private String FACEBOOK_APP_ID;
  @Value("${oauth.appSecret}")
  private String FACEBOOK_APP_SECRET;


  @Override
  public void addConnectionFactories(ConnectionFactoryConfigurer cfConfig, Environment env) {
    cfConfig.addConnectionFactory(new FacebookConnectionFactory(
        FACEBOOK_APP_ID,
        FACEBOOK_APP_SECRET
        ));
  }

  @Override
  public UserIdSource getUserIdSource() {
    return new UserIdSource() {
      @Override
      public String getUserId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
          throw new IllegalStateException("Unable to get a ConnectionRepository: no user signed in");
        }
        return authentication.getName();
      }
    };
  }

  @Override
  public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
    return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
  }

  @Bean
  public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator, UsersConnectionRepository connectionRepository) {
    return new ProviderSignInUtils(connectionFactoryLocator, connectionRepository);
  };

  @Bean
  @Scope(value="request", proxyMode=ScopedProxyMode.INTERFACES)
  public Facebook facebook(ConnectionRepository repository) {
    Connection<Facebook> connection = repository.findPrimaryConnection(Facebook.class);
    return connection != null ? connection.getApi() : null;
  }

}

続いて、Spring-SecurityのConfigファイルを変更します。変更箇所だけ記入します。

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Bean
  public SocialUserDetailsService socialUserDetailsService() {
    return new SimpleSocialUsersDetailService(userDetailsService());
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests()
        .antMatchers("/contents/**").authenticated()
        .anyRequest().permitAll()
      .and()
        .formLogin()
        .loginProcessingUrl("/signin/authenticate")
        .loginPage("/signin.html")
      .and()
        .logout()
        .logoutSuccessUrl("/")
        .logoutRequestMatcher(new AntPathRequestMatcher("/logout.do"))
      .and()
        .apply(new SpringSocialConfigurer());
  }

  /* 省略 */
}

最後はコントローラです。2つ追加します。SignIn用とSignUp用。

import javax.servlet.http.HttpServletRequest;
import org.apache.log4j.Logger;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import com.apitore.common.ApitoreLogFormatter;

/**
 * @author Keigo Hattori
 */
@Controller
public class SigninController {
  @RequestMapping(value="/signin", method=RequestMethod.GET)
  public void signin(HttpServletRequest request) {
  }
}
/**
 * @author Keigo Hattori
 */
@Controller
public class SignupController {
  @Autowired
  private ProviderSignInUtils providerSignInUtils;
  @Autowired
  private UserDetailsService userDetailsService;
  @RequestMapping(value="/signup", method=RequestMethod.GET)
  public String signupForm(
      WebRequest req) {
    Connection<?> connection = providerSignInUtils.getConnectionFromSession(req);
    if (connection != null) {
      SignupForm form = SignupForm.fromProviderUser(connection.fetchUserProfile());
      form.setPassword("hogehoge");
      Account account = createAccount(form);
      if (account != null) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(account.getUsername());
        User user = new SocialUser(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
        SignInUtils.signin(user);
        providerSignInUtils.doPostSignUp(userDetails.getUsername(), req);
      }
      return "redirect:/";
    } else {
      return "redirect:/signin?error";
    }
  }

  private Account createAccount(SignupForm form) {
    String username = form.getUsername();
    Account account = AccountAccess.getAccount(username); // 自分のDBにあわせて実装。すでにアカウントがあるかチェックする。
    if (account != null) {
      return null;
    }
    account = new Account(username); // 自分のDBにあわせて実装。
    /* 各種DBにインサートする */
    /* Roleとか忘れずに追加しておくこと。 */
    return account;
  }
}

対応するHTMLを実装

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.springframework.org/security/tags"
      lang="en">

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <title>Login</title>
    <link rel="stylesheet" href="/webjars/bootstrap/3.3.6/css/bootstrap.min.css"/>
</head>
<body>
<div class="container">
    <div class="row">
        <div class="col-sm-offset-1 col-sm-9">
            <div class="panel panel-default">
                <div class="panel-heading"><h3 class="panel-title">Login</h3></div>
                <div class="panel-body">
                  <div class="col-sm-6 col-xs-12" style="text-align:right;">
                    <form th:action="@{/auth/facebook}" method="GET">
                      <input type="hidden" name="scope" value="public_profile,email" />
                      <div class="form-group">
                        <button class="form-control btn btn-default" style="background-color:#4267B2;color:white;font-weight:bold;" type="submit">
                          Facebookでログインする
                        </button>
                      </div>
                    </form>
                    <div style="margin:10px 0;">
                      <a href="/create/account">
                        アカウントを新規作成する場合はこちら
                      </a>
                    </div>
                  </div>

                  <div class="col-sm-6 col-xs-12">
                    <form role="form" th:action="@{/signin/authenticate}" method="post">
                      <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
                      <div class="form-group">
                          <input type="text" id="username" class="form-control" name="username" placeholder="Username"/>
                      </div>
                      <div class="form-group">
                          <input type="password" id="password" class="form-control" name="password" placeholder="Password"/>
                          <a href="/reset/password">パスワードを忘れた場合</a>
                      </div>
                      <div class="form-group">
                          <button type="submit" class="form-control btn btn-success" style="font-weight:bold;">ログイン</button>
                      </div>
                    </form>
                  </div>
                </div>
            </div>
        </div>
    </div>
</div>
</body>
</html>

プログラム的にはこれでOK。理解してしまえば少しの修正で済むから簡単。

おわりに

終わってみると、「さすがSpring」という印象。たいていのライブラリは整っている。Spring関係は情報が少ないので探すのは大変だが、わかってしまえば便利なことこの上ない。今のところ問題なく動作している。「Spring MVCを使ったウェブサービスをSpring Securityで認証していて、新たにOpenID Connectを認証に盛り込みたい」という非常にニッチな領域の情報だが、それゆえに探すのに苦労した。この情報が誰かの参考になれば。

参考情報

本記事で参考にしたリンク

人によっては参考になるかも

11コメント

  1. SignupControllerの「providerSignInUtils.doPostSignUp(userInfo.getUsername(), req);」のuserInfoオブジェクトはどこから取得するものでしょうか?
    (userInfoの出現箇所が1箇所しか見当たりません)

    またSignupControllerの「/signup」は何をトリガーに実行されるのでしょうか?
    私の環境では「/signup」の処理は実行されませんでした。。

    1. userInfoについては私の独自の実装が残っていました。userDetails.getUsername()に修正しました。ご指摘ありがとうございます。

      SignupControllerの質問ですが、Spring-bootでウェブサービスを作った時のURLです。例えば「http://www.example.com/signup」にアクセスしたときに実行されます。

  2. FacebookでYahooのメールアドレス等で登録している場合はメールアドレスが取得できないケースがあると思うのですが、その場合の対応方針を教えてください。
    (ApitoreでYahooのメールアドレスを持つFacebookアカウントでログインしようとするとエラーが発生しました。)

    1. ウルフさん
      コメントありがとうございます。そのようなケースがあることは知りませんでした。確認してまた返信します。ApitoreのAPIをとりあえず使うのでしたら、Facebook認証ではなくメールアドレスで登録することも出来ます。

    2. ウルフさん
      私の方でYahooアカウントでフェイスブックアカウントを作ってみましたが、ウルフさんの現象は確認できませんでした。ウルフさんのフェイスブックアカウントがメールを非公開の設定にしているか、またはApitoreのアクセス許可でメールアドレスを対象外にしていないか確認頂けませんか?

    3. Keigo Hattoriさん

      >ウルフさんのフェイスブックアカウントがメールを非公開の設定にしているか、または>Apitoreのアクセス許可でメールアドレスを対象外にしていないか確認頂けませんか?

      いずれの設定もしていませんし、FacebookログインできるアカウントとできないアカウントのFacebook設定を比較しても差異は見られません。

      自分もKeigo Hattoriさんの記事を参考に自身のサービスにFacebookログイン機能を作成しているのですが、今回の問題に直面して解決策が見出だせていない状態です。

      もしよろしければ、一緒に原因調査していただけませんか?(お手すきの時で構いません。)

      私にメールしていただければ、エラーが発生するFacebookアカウントのID、パスワードを共有します。

    4. ウルフさん
      わかりました。該当アカウントのフェイスブックアカウントをお知らせ下さい。エラーの原因を見てみます。

    5. ありがとうございます。
      パスワード情報も含まれるので、本ブログのコメントに記載はできないのですが、どちらに共有すればよろしいでしょうか?
      もしよろしければ私のメールアドレスにメールしてください。
      FacebookアカウントのID、パスワードを共有します。

  3. 今Facebookで友達申請をしました。
    そちらでやりとりしましょう!

    1. 問題が解決したので備忘録としてこちらのコメントに追記します。フェイスブックアカウントを作成した際、メールアドレスの認証を完了しないとOpenIDコネクトでメールアドレス取れないようです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です