Customize Spring Social Connect Framework For MongoDB
In my previous post, I talked about the first challenge I had was to change the data model and add the connection framework. Here I want to give more details about how I did it. Spring Social project already provides a jdbc based connection repository implementation to persist user connection data into a relational database. However, I’m using MongoDB, so I need to customize the code, and I found it is relatively easy to do it. The user connection data will be saved as the object of UserSocialConnection
, it is a MongoDB document:
@SuppressWarnings('serial') @Document(collection = 'UserSocialConnection') public class UserSocialConnection extends BaseEntity { private String userId; private String providerId; private String providerUserId; private String displayName; private String profileUrl; private String imageUrl; private String accessToken; private String secret; private String refreshToken; private Long expireTime; //Getter/Setter omitted. public UserSocialConnection() { super(); } public UserSocialConnection(String userId, String providerId, String providerUserId, int rank, String displayName, String profileUrl, String imageUrl, String accessToken, String secret, String refreshToken, Long expireTime) { super(); this.userId = userId; this.providerId = providerId; this.providerUserId = providerUserId; this.displayName = displayName; this.profileUrl = profileUrl; this.imageUrl = imageUrl; this.accessToken = accessToken; this.secret = secret; this.refreshToken = refreshToken; this.expireTime = expireTime; } }
BaseEntity
just has ‘id’. With the help of the Spring Data project, I don’t need to write any code of CRUD operations for UserSocialConnection
, just extend MongoRepository
:
public interface UserSocialConnectionRepository extends MongoRepository<UserSocialConnection, String>{ List<UserSocialConnection> findByUserId(String userId); List<UserSocialConnection> findByUserIdAndProviderId(String userId, String providerId); List<UserSocialConnection> findByProviderIdAndProviderUserId(String providerId, String providerUserId); UserSocialConnection findByUserIdAndProviderIdAndProviderUserId(String userId, String providerId, String providerUserId); List<UserSocialConnection> findByProviderIdAndProviderUserIdIn(String providerId, Collection<String> providerUserIds); }
After we have our database UserSocialConnectionRepository
, we will implement Spring Social required ConnectionRepository
and UsersConnectionRepository
. I just copied the code from JdbcConnectionRepository
and JdbcUsersConnectionRepository
, and created my own MongoConnectionRepository
and MongoUsersConnectionRepository
.
public class MongoUsersConnectionRepository implements UsersConnectionRepository{ private final UserSocialConnectionRepository userSocialConnectionRepository; private final SocialAuthenticationServiceLocator socialAuthenticationServiceLocator; private final TextEncryptor textEncryptor; private ConnectionSignUp connectionSignUp; public MongoUsersConnectionRepository(UserSocialConnectionRepository userSocialConnectionRepository, SocialAuthenticationServiceLocator socialAuthenticationServiceLocator, TextEncryptor textEncryptor){ this.userSocialConnectionRepository = userSocialConnectionRepository; this.socialAuthenticationServiceLocator = socialAuthenticationServiceLocator; this.textEncryptor = textEncryptor; } /** * The command to execute to create a new local user profile in the event no user id could be mapped to a connection. * Allows for implicitly creating a user profile from connection data during a provider sign-in attempt. * Defaults to null, indicating explicit sign-up will be required to complete the provider sign-in attempt. * @see #findUserIdsWithConnection(Connection) */ public void setConnectionSignUp(ConnectionSignUp connectionSignUp) { this.connectionSignUp = connectionSignUp; } public List<String> findUserIdsWithConnection(Connection<?> connection) { ConnectionKey key = connection.getKey(); List<UserSocialConnection> userSocialConnectionList = this.userSocialConnectionRepository.findByProviderIdAndProviderUserId(key.getProviderId(), key.getProviderUserId()); List<String> localUserIds = new ArrayList<String>(); for (UserSocialConnection userSocialConnection : userSocialConnectionList){ localUserIds.add(userSocialConnection.getUserId()); } if (localUserIds.size() == 0 && connectionSignUp != null) { String newUserId = connectionSignUp.execute(connection); if (newUserId != null) { createConnectionRepository(newUserId).addConnection(connection); return Arrays.asList(newUserId); } } return localUserIds; } public Set<String> findUserIdsConnectedTo(String providerId, Set<String> providerUserIds) { final Set<String> localUserIds = new HashSet<String>(); List<UserSocialConnection> userSocialConnectionList = this.userSocialConnectionRepository.findByProviderIdAndProviderUserIdIn(providerId, providerUserIds); for (UserSocialConnection userSocialConnection : userSocialConnectionList){ localUserIds.add(userSocialConnection.getUserId()); } return localUserIds; } public ConnectionRepository createConnectionRepository(String userId) { if (userId == null) { throw new IllegalArgumentException('userId cannot be null'); } return new MongoConnectionRepository(userId, userSocialConnectionRepository, socialAuthenticationServiceLocator, textEncryptor); } }
MongoUsersConnectionRepository
is pretty much exactly like JdbcUsersConnectionRepository
. But for MongoConnectionRepository
, I needed to make some changes:
public class MongoConnectionRepository implements ConnectionRepository { private final String userId; private final UserSocialConnectionRepository userSocialConnectionRepository; private final SocialAuthenticationServiceLocator socialAuthenticationServiceLocator; private final TextEncryptor textEncryptor; public MongoConnectionRepository(String userId, UserSocialConnectionRepository userSocialConnectionRepository, SocialAuthenticationServiceLocator socialAuthenticationServiceLocator, TextEncryptor textEncryptor) { this.userId = userId; this.userSocialConnectionRepository = userSocialConnectionRepository; this.socialAuthenticationServiceLocator = socialAuthenticationServiceLocator; this.textEncryptor = textEncryptor; } public MultiValueMap<String, Connection<?>> findAllConnections() { List<UserSocialConnection> userSocialConnectionList = this.userSocialConnectionRepository .findByUserId(userId); MultiValueMap<String, Connection<?>> connections = new LinkedMultiValueMap<String, Connection<?>>(); Set<String> registeredProviderIds = socialAuthenticationServiceLocator.registeredProviderIds(); for (String registeredProviderId : registeredProviderIds) { connections.put(registeredProviderId, Collections.<Connection<?>> emptyList()); } for (UserSocialConnection userSocialConnection : userSocialConnectionList) { String providerId = userSocialConnection.getProviderId(); if (connections.get(providerId).size() == 0) { connections.put(providerId, new LinkedList<Connection<?>>()); } connections.add(providerId, buildConnection(userSocialConnection)); } return connections; } public List<Connection<?>> findConnections(String providerId) { List<Connection<?>> resultList = new LinkedList<Connection<?>>(); List<UserSocialConnection> userSocialConnectionList = this.userSocialConnectionRepository .findByUserIdAndProviderId(userId, providerId); for (UserSocialConnection userSocialConnection : userSocialConnectionList) { resultList.add(buildConnection(userSocialConnection)); } return resultList; } @SuppressWarnings('unchecked') public <A> List<Connection<A>> findConnections(Class<A> apiType) { List<?> connections = findConnections(getProviderId(apiType)); return (List<Connection<A>>) connections; } public MultiValueMap<String, Connection<?>> findConnectionsToUsers(MultiValueMap<String, String> providerUsers) { if (providerUsers == null || providerUsers.isEmpty()) { throw new IllegalArgumentException('Unable to execute find: no providerUsers provided'); } MultiValueMap<String, Connection<?>> connectionsForUsers = new LinkedMultiValueMap<String, Connection<?>>(); for (Iterator<Entry<String, List<String>>> it = providerUsers.entrySet().iterator(); it.hasNext();) { Entry<String, List<String>> entry = it.next(); String providerId = entry.getKey(); List<String> providerUserIds = entry.getValue(); List<UserSocialConnection> userSocialConnections = this.userSocialConnectionRepository.findByProviderIdAndProviderUserIdIn(providerId, providerUserIds); List<Connection<?>> connections = new ArrayList<Connection<?>>(providerUserIds.size()); for (int i = 0; i < providerUserIds.size(); i++) { connections.add(null); } connectionsForUsers.put(providerId, connections); for (UserSocialConnection userSocialConnection : userSocialConnections) { String providerUserId = userSocialConnection.getProviderUserId(); int connectionIndex = providerUserIds.indexOf(providerUserId); connections.set(connectionIndex, buildConnection(userSocialConnection)); } } return connectionsForUsers; } public Connection<?> getConnection(ConnectionKey connectionKey) { UserSocialConnection userSocialConnection = this.userSocialConnectionRepository .findByUserIdAndProviderIdAndProviderUserId(userId, connectionKey.getProviderId(), connectionKey.getProviderUserId()); if (userSocialConnection != null) { return buildConnection(userSocialConnection); } throw new NoSuchConnectionException(connectionKey); } @SuppressWarnings('unchecked') public <A> Connection<A> getConnection(Class<A> apiType, String providerUserId) { String providerId = getProviderId(apiType); return (Connection<A>) getConnection(new ConnectionKey(providerId, providerUserId)); } @SuppressWarnings('unchecked') public <A> Connection<A> getPrimaryConnection(Class<A> apiType) { String providerId = getProviderId(apiType); Connection<A> connection = (Connection<A>) findPrimaryConnection(providerId); if (connection == null) { throw new NotConnectedException(providerId); } return connection; } @SuppressWarnings('unchecked') public <A> Connection<A> findPrimaryConnection(Class<A> apiType) { String providerId = getProviderId(apiType); return (Connection<A>) findPrimaryConnection(providerId); } public void addConnection(Connection<?> connection) { //check cardinality SocialAuthenticationService<?> socialAuthenticationService = this.socialAuthenticationServiceLocator.getAuthenticationService(connection.getKey().getProviderId()); if (socialAuthenticationService.getConnectionCardinality() == ConnectionCardinality.ONE_TO_ONE || socialAuthenticationService.getConnectionCardinality() == ConnectionCardinality.ONE_TO_MANY){ List<UserSocialConnection> storedConnections = this.userSocialConnectionRepository.findByProviderIdAndProviderUserId( connection.getKey().getProviderId(), connection.getKey().getProviderUserId()); if (storedConnections.size() > 0){ //not allow one providerId connect to multiple userId throw new DuplicateConnectionException(connection.getKey()); } } UserSocialConnection userSocialConnection = this.userSocialConnectionRepository .findByUserIdAndProviderIdAndProviderUserId(userId, connection.getKey().getProviderId(), connection.getKey().getProviderUserId()); if (userSocialConnection == null) { ConnectionData data = connection.createData(); userSocialConnection = new UserSocialConnection(userId, data.getProviderId(), data.getProviderUserId(), 0, data.getDisplayName(), data.getProfileUrl(), data.getImageUrl(), encrypt(data.getAccessToken()), encrypt(data.getSecret()), encrypt(data.getRefreshToken()), data.getExpireTime()); this.userSocialConnectionRepository.save(userSocialConnection); } else { throw new DuplicateConnectionException(connection.getKey()); } } public void updateConnection(Connection<?> connection) { ConnectionData data = connection.createData(); UserSocialConnection userSocialConnection = this.userSocialConnectionRepository .findByUserIdAndProviderIdAndProviderUserId(userId, connection.getKey().getProviderId(), connection .getKey().getProviderUserId()); if (userSocialConnection != null) { userSocialConnection.setDisplayName(data.getDisplayName()); userSocialConnection.setProfileUrl(data.getProfileUrl()); userSocialConnection.setImageUrl(data.getImageUrl()); userSocialConnection.setAccessToken(encrypt(data.getAccessToken())); userSocialConnection.setSecret(encrypt(data.getSecret())); userSocialConnection.setRefreshToken(encrypt(data.getRefreshToken())); userSocialConnection.setExpireTime(data.getExpireTime()); this.userSocialConnectionRepository.save(userSocialConnection); } } public void removeConnections(String providerId) { List<UserSocialConnection> userSocialConnectionList = this.userSocialConnectionRepository .findByUserIdAndProviderId(userId, providerId); for (UserSocialConnection userSocialConnection : userSocialConnectionList) { this.userSocialConnectionRepository.delete(userSocialConnection); } } public void removeConnection(ConnectionKey connectionKey) { UserSocialConnection userSocialConnection = this.userSocialConnectionRepository .findByUserIdAndProviderIdAndProviderUserId(userId, connectionKey.getProviderId(), connectionKey.getProviderUserId()); this.userSocialConnectionRepository.delete(userSocialConnection); } // internal helpers private Connection<?> buildConnection(UserSocialConnection userSocialConnection) { ConnectionData connectionData = new ConnectionData(userSocialConnection.getProviderId(), userSocialConnection.getProviderUserId(), userSocialConnection.getDisplayName(), userSocialConnection.getProfileUrl(), userSocialConnection.getImageUrl(), decrypt(userSocialConnection.getAccessToken()), decrypt(userSocialConnection.getSecret()), decrypt(userSocialConnection.getRefreshToken()), userSocialConnection.getExpireTime()); ConnectionFactory<?> connectionFactory = this.socialAuthenticationServiceLocator.getConnectionFactory(connectionData .getProviderId()); return connectionFactory.createConnection(connectionData); } private Connection<?> findPrimaryConnection(String providerId) { List<UserSocialConnection> userSocialConnectionList = this.userSocialConnectionRepository .findByUserIdAndProviderId(userId, providerId); return buildConnection(userSocialConnectionList.get(0)); } private <A> String getProviderId(Class<A> apiType) { return socialAuthenticationServiceLocator.getConnectionFactory(apiType).getProviderId(); } private String encrypt(String text) { return text != null ? textEncryptor.encrypt(text) : text; } private String decrypt(String encryptedText) { return encryptedText != null ? textEncryptor.decrypt(encryptedText) : encryptedText; } }
First, I replaced JdbcTemplate
with UserSocialConnectionRepository
to retrieve UserSocialConnection objects from the database. Then replaced ConnectionFactoryLocator
with SocialAuthenticationServiceLocator
from spring-social-security module. A big change is in the addConnection
method (highlighted above), where it checks connection cardinality first. If connectionCardinality
of socialAuthenticationService
is ONE_TO_ONE
(which means one userId with one and only one pair of providerId/providerUserId), or ONE_TO_MANY
(which means one userId can connect to one or many providerId/providerUserId, but one pair of providerId/providerUserId can only connect to one userId).
After all those customizations, the final step is to glue them together in spring config:
@Configuration public class SocialAndSecurityConfig { @Inject private Environment environment; @Inject AccountService accountService; @Inject private AuthenticationManager authenticationManager; @Inject private UserSocialConnectionRepository userSocialConnectionRepository; @Bean public SocialAuthenticationServiceLocator socialAuthenticationServiceLocator() { SocialAuthenticationServiceRegistry registry = new SocialAuthenticationServiceRegistry(); //add google OAuth2ConnectionFactory<Google> googleConnectionFactory = new GoogleConnectionFactory(environment.getProperty('google.clientId'), environment.getProperty('google.clientSecret')); OAuth2AuthenticationService<Google> googleAuthenticationService = new OAuth2AuthenticationService<Google>(googleConnectionFactory); googleAuthenticationService.setScope('https://www.googleapis.com/auth/userinfo.profile'); registry.addAuthenticationService(googleAuthenticationService); //add twitter OAuth1ConnectionFactory<Twitter> twitterConnectionFactory = new TwitterConnectionFactory(environment.getProperty('twitter.consumerKey'), environment.getProperty('twitter.consumerSecret')); OAuth1AuthenticationService<Twitter> twitterAuthenticationService = new OAuth1AuthenticationService<Twitter>(twitterConnectionFactory); registry.addAuthenticationService(twitterAuthenticationService); //add facebook OAuth2ConnectionFactory<Facebook> facebookConnectionFactory = new FacebookConnectionFactory(environment.getProperty('facebook.clientId'), environment.getProperty('facebook.clientSecret')); OAuth2AuthenticationService<Facebook> facebookAuthenticationService = new OAuth2AuthenticationService<Facebook>(facebookConnectionFactory); facebookAuthenticationService.setScope(''); registry.addAuthenticationService(facebookAuthenticationService); return registry; } /** * Singleton data access object providing access to connections across all users. */ @Bean public UsersConnectionRepository usersConnectionRepository() { MongoUsersConnectionRepository repository = new MongoUsersConnectionRepository(userSocialConnectionRepository, socialAuthenticationServiceLocator(), Encryptors.noOpText()); repository.setConnectionSignUp(autoConnectionSignUp()); return repository; } /** * Request-scoped data access object providing access to the current user's connections. */ @Bean @Scope(value = 'request', proxyMode = ScopedProxyMode.INTERFACES) public ConnectionRepository connectionRepository() { UserAccount user = AccountUtils.getLoginUserAccount(); return usersConnectionRepository().createConnectionRepository(user.getUsername()); } /** * A proxy to a request-scoped object representing the current user's primary Google account. * * @throws NotConnectedException * if the user is not connected to Google. */ @Bean @Scope(value = 'request', proxyMode = ScopedProxyMode.INTERFACES) public Google google() { Connection<Google> google = connectionRepository().findPrimaryConnection(Google.class); return google != null ? google.getApi() : new GoogleTemplate(); } @Bean @Scope(value='request', proxyMode=ScopedProxyMode.INTERFACES) public Facebook facebook() { Connection<Facebook> facebook = connectionRepository().findPrimaryConnection(Facebook.class); return facebook != null ? facebook.getApi() : new FacebookTemplate(); } @Bean @Scope(value='request', proxyMode=ScopedProxyMode.INTERFACES) public Twitter twitter() { Connection<Twitter> twitter = connectionRepository().findPrimaryConnection(Twitter.class); return twitter != null ? twitter.getApi() : new TwitterTemplate(); } @Bean public ConnectionSignUp autoConnectionSignUp() { return new AutoConnectionSignUp(accountService); } @Bean public SocialAuthenticationFilter socialAuthenticationFilter() { SocialAuthenticationFilter filter = new SocialAuthenticationFilter(authenticationManager, accountService, usersConnectionRepository(), socialAuthenticationServiceLocator()); filter.setFilterProcessesUrl('/signin'); filter.setSignupUrl(null); filter.setConnectionAddedRedirectUrl('/myAccount'); filter.setPostLoginUrl('/myAccount'); return filter; } @Bean public SocialAuthenticationProvider socialAuthenticationProvider(){ return new SocialAuthenticationProvider(usersConnectionRepository(), accountService); } @Bean public LoginUrlAuthenticationEntryPoint socialAuthenticationEntryPoint(){ return new LoginUrlAuthenticationEntryPoint('/signin'); } }
accountService
is my own user account service to provide account related functions, and it implements SocialUserDetailsService
, UserDetailsService
, UserIdExtractor
.
There are still many areas to improve, such as refactoring MongoConnectionRepository
and MongoUsersConnectionRepository
to have an abstract social connection repository implementation using Spring Data Repository interface. And I found someone already has raised an issue for that: Leverage Spring Data for UsersConnectionRepository.
Reference: Customize Spring Social Connect Framework For MongoDB from our JCG partner Yuan Ji at the Jiwhiz blog.
Thanks.. very helpful. Although I wanted to customize for GAE. I was able to adapt your code to use Objectify for GAE.