Apitore blog

Apitoreを運営していた元起業家のブログ

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を認証に盛り込みたい」という非常にニッチな領域の情報だが、それゆえに探すのに苦労した。この情報が誰かの参考になれば。

参考情報

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

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