環境
- 今回関係ありそうな依存関係とバージョン
- クライアントモジュールは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アカウントとの紐付けと解除
- ソーシャルログインは
ProviderSignInController
とSocialAuthenticationFilter
の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());
}
// その他の設定
- いつも通り
AuthenticationManager
のBean定義をするためのメソッドを作成します。 -
SocialAuthenticationFilter
を適用するためにSpringSocialConfigurer
を設定します。同時にログイン後の遷移先URL、と後述のSNSアカウントとの紐付け後のURLを設定します。また独自サインアップ用のURLを変更できたりもします(デフォルトは"/signup")。 -
SocialUserDetailsService
のBean定義します。上述のSpringSocialConfigurer
内Configure
メソッド内でDIされます。
SocialUserDetailsServiceの実装
-
SocialUserDetailsService
はUserDetailsService
の実装とさほど変わりません。サンプルでは以下のように実装してます。
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";
}
- POSTリクエストされるとOAuth2ならcodeがクエリに付与されたGETリクエストにコールバックされます。
- その後クエリなしの"connect/{providerId}"にリダイレクトされます。
- 特定のview名が返されるので、デフォルトのままやるならそのbiewを用意する必要があります。
- 接頭語は変更可能ですが、"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";
}
}
ソース
- validateとかもろもろサボってるところありますが、見たい人はどうぞ。
https://github.com/kunipon/spring-social-demo
ハマったポイント
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エラー
- なにやら潜在的なバグのよう
- JdbcConnectionRepository is package private which can lead to cglib error
- 1.1.4.RELEASE(現時点のGA)ではクラスに修飾子がなかったのが、2.0.0.M4では
public
になってました。
twitterのコールバックURL指定漏れ
- こちらが参考になりました。
- Spring-socialのtwitterでResourceAccessException
参考
- spring-social: 2.0.0.M4.RELEASE
- github: spring-social-samples - spring-social-showcase-sec
- WebSecurityConfigurerAdapterで複数のauthenticationProviderを設定したい時の参考
Multiple Authentication Providers in Spring Security