spring-security
spring-boot
spring-social

spring-socialとspringbootでSNSログインを実装する

環境

  • 今回関係ありそうな依存関係とバージョン
  • クライアントモジュールはfacebookとtwitter
pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-social-facebook</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.social</groupId>
    <artifactId>spring-social-core</artifactId>
    <version>2.0.0.M4</version>
</dependency>
<dependency>
    <groupId>org.springframework.social</groupId>
    <artifactId>spring-social-config</artifactId>
    <version>2.0.0.M4</version>
</dependency>
  • spring-boot-starter-social-facebook: 1.5.4.RELEASE
    • spring-boot: 1.5.4.RELEASE
    • spring-social: 2.0.0.M4
    • spring-social-facebook: 2.0.3.RELEASE

概要

  • 機能はソーシャルログイン、暗黙的サインアップ、SNSアカウントとの紐付けと解除
  • ソーシャルログインはProviderSignInControllerSocialAuthenticationFilterの2つのプロバイダ認証方式がありますが、SocialAuthenticationFilterが推奨らしいので、それについて公式リファレンスをかいつまんで見ていきます。

1. ソーシャルログイン

SocialAuthenticationFilterの適用

  • WebSecurityConfigurerAdapterで設定していきます。
Securityconfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
    @Autowired
    private DataSource dataSource;

    //-----(1)
    @Autowired
    public void registerAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource)
                .usersByUsernameQuery("select username, password, true from Account where username = ?")
                .authoritiesByUsernameQuery("select username, 'ROLE_USER' from Account where username = ?")
                .passwordEncoder(passwordEncoder());
    }

// その他の設定

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .formLogin()
                .loginPage("/signin")
                .loginProcessingUrl("/signin/authenticate")
                .failureUrl("/signin?param.error=bad_credentials")
            .and()
                .logout()
                    .logoutUrl("/signout")
                    .deleteCookies("JSESSIONID")
            .and()
                .authorizeRequests()
                    .antMatchers("/", "/webjars/**", "/admin/**", "/favicon.ico", "/resources/**", "/auth/**", "/signin/**", "/signup/**", "/disconnect/facebook").permitAll()
                    .antMatchers("/**").authenticated()
            .and()
                .rememberMe()
            .and()
                //-----(2)
                .apply(new SpringSocialConfigurer()
                    .postLoginUrl("/profile/view")
                    .connectionAddedRedirectUrl("/profile/view"));
//                  .signupUrl("/register")); // デフォルトは "/signup"
    }

    //-----(3)
    @Bean
    public SocialUserDetailsService socialUsersDetailService() {
        return new SimpleSocialUsersDetailService(userDetailsService());
    }

// その他の設定
  1. いつも通りAuthenticationManagerのBean定義をするためのメソッドを作成します。
  2. SocialAuthenticationFilterを適用するためにSpringSocialConfigurerを設定します。同時にログイン後の遷移先URL、と後述のSNSアカウントとの紐付け後のURLを設定します。また独自サインアップ用のURLを変更できたりもします(デフォルトは"/signup")。
  3. SocialUserDetailsServiceのBean定義します。上述のSpringSocialConfigurerConfigureメソッド内でDIされます。

SocialUserDetailsServiceの実装

  • SocialUserDetailsServiceUserDetailsServiceの実装とさほど変わりません。サンプルでは以下のように実装してます。
SimpleSocialUsersDetailService.java
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());
    }

}

ソーシャルボタンの追加

  • 以下のようにリンク先は/auth/{providerid}にします。
<!-- FACEBOOK SIGNIN -->
<p><a th:href="@{/auth/facebook}"><img th:src="@{/resources/social/facebook/sign-in-with-facebook.png}" border="0"/></a></p>
  • SocialAuthenticationFilter内で以下のようにしてプロバイダIDを取得するので、リンク先は/auth/{providerid}にする必要があります。
SocialAuthenticationFilter.java
private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";

private String getRequestedProviderId(HttpServletRequest request) {
    String uri = request.getRequestURI();
    int pathParamIndex = uri.indexOf(';');

    if (pathParamIndex > 0) {
        // strip everything after the first semi-colon
        uri = uri.substring(0, pathParamIndex);
    }

    // uri must start with context path
    uri = uri.substring(request.getContextPath().length());

    // remaining uri must start with filterProcessesUrl
    if (!uri.startsWith(filterProcessesUrl)) {
        return null;
    }
    uri = uri.substring(filterProcessesUrl.length());

    // expect /filterprocessesurl/provider, not /filterprocessesurlproviderr
    if (uri.startsWith("/")) {
        return uri.substring(1);
    } else {
        return null;
    }
}

2. 暗黙的サインアップ

  • ソーシャルログイン時に該当するレコードがない場合に、暗黙的にサインアップします。

UserConnectionテーブルの作成

  • やり方はなんでもいいんですが、RDBでの永続化のために以下のテーブルを作成しておきます。
  • "/org/springframework/social/connect/jdbc/JdbcUsersConnectionRepository.sql"にDDLがあるので、使用しているDBに合えばそれを使用してもいいですし、"schema.sql"とかに書いちゃってもいいと思います。
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(1024) not null,
  secret varchar(255),
  refreshToken varchar(255),
  expireTime bigint,
  primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);

SocialConfigurerAdapterの実装

  • 使用するUsersConnectionRepositoryを設定します。こいつがUserConnectionテーブル用のリポジトリになってくれます。
  • Encryptorsは適宜選択してください。
SocialConfig
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    private final DataSource dataSource;
    private final SignupService signupService;


    public SocialConfig(DataSource dataSource, SignupService signupService) {
        this.dataSource = dataSource;
        this.signupService = signupService;
    }

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

ConnectionSignUpの実装

  • ConnectionSignUpインターフェイスが用意されているので、実装します。
  • SignupService内でプロバイダユーザーと紐づけるローカルユーザー情報をinsertしてます。適宜、実装してください。
UserConnectionSignUp.java
public class UserConnectionSignUp implements ConnectionSignUp {

    private final SignupService signupService;

    public DemoUserConnectionSignUp(SignupService signupService) {
        this.signupService = signupService;
    }

    @Override
    public String execute(Connection<?> connection) {
        // プロバイダからユーザー情報取得
        UserProfile profile = connection.fetchUserProfile();

        // プロバイダから取得した情報を元にローカルユーザー情報作成
        User user = signupService.createUser(profile);

        // 自動生成されたローカルユーザーID返却
        // このユーザーIDがUserConnectionテーブルPKとして使用される
        return user.getUserId();
    }
  • executeは先に設定したJdbcUsersConnectionRepository内でユーザー情報がDB上に存在しない時に実行されます。具体的には以下の部分です。
  • 見ての通りexecute内でnullを返却するとUserConnectionにinsertされないので、execute内で何か問題が発生してinsertしたくない時はnullを返すように実装します。
JdbcUsersConnectionRepository.java
public List<String> findUserIdsWithConnection(Connection<?> connection) {
    ConnectionKey key = connection.getKey();
    List<String> localUserIds = jdbcTemplate.queryForList("select userId from " + tablePrefix + "UserConnection where providerId = ? and providerUserId = ?", String.class, key.getProviderId(), key.getProviderUserId());      
    if (localUserIds.size() == 0 && connectionSignUp != null) {
        String newUserId = connectionSignUp.execute(connection);
        if (newUserId != null)
        {
            createConnectionRepository(newUserId).addConnection(connection);
            return Arrays.asList(newUserId);
        }
    }
    return localUserIds;
}

3. ソーシャルアカウントの紐付けと解除

  • ローカルアカウントにSNSアカウントを紐付けたり解除したりできるので、1ユーザーに対して複数SNSアカウントを紐付けられます。

connectボタンの設置

  • 紐付けはPOST、解除はDELETEメソッドでsubmitします。
<h4>Connect other account.</h4>
<form role="form" th:action="@{/connect/facebook}" method="POST">
    <button type="submit" class="btn btn-block btn-social btn-facebook">
        <i class="fa fa-facebook"></i> Connect to facebook
    </button>
</form>
<form role="form" th:action="@{/connect/twitter}" method="POST">
    <button type="submit" class="btn btn-block btn-social btn-twitter">
        <i class="fa fa-twitter"></i> Connect to twitter
    </button>
</form>
<h4>Disconnect account.</h4>
<form role="form" th:action="@{/connect/facebook}" method="POST">
    <input name="_method" type="hidden" value="DELETE" />
    <button type="submit" class="btn btn-block btn-social btn-facebook">
        <i class="fa fa-facebook"></i> Disconnect facebook
    </button>
</form>
<form role="form" th:action="@{/connect/twitter}" method="POST">
    <input name="_method" type="hidden" value="DELETE" />
    <button type="submit" class="btn btn-block btn-social btn-twitter">
        <i class="fa fa-twitter"></i> Disconnect twitter
    </button>
</form>

ConnectControllerの実装

ちょっと裏側説明

  • デフォルトのままでいいのであれば、ConnectControllerはすでに実装済みなので何もする必要はありません。
  • template/connect/配下に"{providerId}Connect.html"、”{providerId}status.html”を準備して終わりです。
  • デフォルトはいかのようになってます。
ConnectController.java
//----(1)
@RequestMapping(value="/{providerId}", method=RequestMethod.GET, params="code")
public RedirectView oauth2Callback(@PathVariable String providerId, NativeWebRequest request) {
    try {
        OAuth2ConnectionFactory<?> connectionFactory = (OAuth2ConnectionFactory<?>) connectionFactoryLocator.getConnectionFactory(providerId);
        Connection<?> connection = connectSupport.completeConnection(connectionFactory, request);
        addConnection(connection, connectionFactory, request);
    } catch (Exception e) {
        sessionStrategy.setAttribute(request, PROVIDER_ERROR_ATTRIBUTE, e);
        logger.warn("Exception while handling OAuth2 callback (" + e.getMessage() + "). Redirecting to " + providerId +" connection status page.");
    }
    //----(2)
    return connectionStatusRedirect(providerId, request);
}

protected RedirectView connectionStatusRedirect(String providerId, NativeWebRequest request) {
    HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
    String path = "/connect/" + providerId + getPathExtension(servletRequest);
    if (prependServletPath(servletRequest)) {
        path = servletRequest.getServletPath() + path;
    }
    return new RedirectView(path, true);
}

//----(3)
@RequestMapping(value="/{providerId}", method=RequestMethod.GET)
public String connectionStatus(@PathVariable String providerId, NativeWebRequest request, Model model) {
    setNoCache(request);
    processFlash(request, model);
    List<Connection<?>> connections = connectionRepository.findConnections(providerId);
    setNoCache(request);
    if (connections.isEmpty()) {
        return connectView(providerId); 
    } else {
        model.addAttribute("connections", connections);
        return connectedView(providerId);           
    }
}

//----(4)
private String viewPath = "connect/";

protected String connectView() {
    return getViewPath() + "status";
}
protected String connectView(String providerId) {
    return getViewPath() + providerId + "Connect";      
}
  1. POSTリクエストされるとOAuth2ならcodeがクエリに付与されたGETリクエストにコールバックされます。
  2. その後クエリなしの"connect/{providerId}"にリダイレクトされます。
  3. 特定のview名が返されるので、デフォルトのままやるならそのbiewを用意する必要があります。
  4. 接頭語は変更可能ですが、"status"、"{providerId}"、"Connect"がview名として付与されてしまいます。

ConnectControllerの上書き

  • というわけで、デフォルトのview名などが気に入らないときや、なにか処理を追加したいときはoverrideしてやる必要があります。
CustomConnectController
@Controller
@RequestMapping("/connect")
public class CustomConnectControllerextends ConnectController {

    public CustomConnectController(ConnectionFactoryLocator connectionFactoryLocator,
            ConnectionRepository connectionRepository) {
        super(connectionFactoryLocator, connectionRepository);
    }

    @Override
    @RequestMapping(value="/{providerId}", method=RequestMethod.GET)
    public String connectionStatus(@PathVariable String providerId, NativeWebRequest request, Model model) {
        model.addAttribute("providerId", providerId);
        return super.connectionStatus(providerId, request, model);
    }

    @Override
    protected String connectedView(String providerId) {
        return "profile";
    }

    @Override
    protected String connectView(String providerId) {
        return "profile";
    }
}

ソース

ハマったポイント

facebookのUSER_PROFILE

  • facebookアプリをいつ作成したのかにもよるようなのですが、最近作成したのならもれなく(#12) bio field is deprecated for versions v2.8 and higherといった例外が発生すると思います。
  • 一応、下記のように適当なクラスでPROFILE_FIELDSを上書くことで回避可能です。
FacebookService
    @PostConstruct
    private void init() {
        try {
            String[] fieldsToMap = { "id", "about", "age_range", "birthday",
                    "context", "cover", "currency", "devices", "education",
                    "email", "favorite_athletes", "favorite_teams",
                    "first_name", "gender", "hometown", "inspirational_people",
                    "installed", "install_type", "is_verified", "languages",
                    "last_name", "link", "locale", "location", "meeting_for",
                    "middle_name", "name", "name_format", "political",
                    "quotes", "payment_pricepoints", "relationship_status",
                    "religion", "security_settings", "significant_other",
                    "sports", "test_group", "timezone", "third_party_id",
                    "updated_time", "verified", "viewer_can_send_gift",
                    "website", "work" };

            Field field = Class.forName(
                    "org.springframework.social.facebook.api.UserOperations")
                    .getDeclaredField("PROFILE_FIELDS");
            field.setAccessible(true);

            Field modifiers = field.getClass().getDeclaredField("modifiers");
            modifiers.setAccessible(true);
            modifiers.setInt(field, field.getModifiers() & ~Modifier.FINAL);
            field.set(null, fieldsToMap);

        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
  • コチラにもあるように、現時点ではまだGAではありませんがv3.0.0.M1以降であればFIXされているようなのでライブラリのバージョンをあげてしまっても動きます。

JdbcConnectionRepositoryのcglibエラー

twitterのコールバックURL指定漏れ

参考