上一篇文章主要完成了
Spring Social
集成/signup
上,其实这是Spring Social
注册逻辑,所以我们就一起用这节内容来共同探讨解决这个问题。
为什么会跳转到/signup
上,或者在上面情况下会跳转到/signup
上呢?我们一起阅读源代码来查找原因。我们在此把社交登录的流程图贴到这里。
我们在封装好SocialAuthenticationToken
以后,就会调用AuthenticationManager
来调用SocialAuthenticationProvider
来进行认证工作,我们一起来看具体的认证代码:
从上图的代码中可知,在认证过程中,打断点的那一步骤是拿到providerId
和providerUserId
(其实就是openId
)去数据库表UserConnection
中去查询业务系统中的userId
,因为我们业务系统中还没有这个授权登录的用户,所以这里返回的就是null
,然后就直接抛出了BadCredentialsException
异常,那么该异常最终在类SocialAuthenticationFilter
中的doAuthentication
方法中被捕获,代码如下:
该异常在这里被处理,这里有一个判断,判断signupUrl
是否为null
,其实它有一个默认值,那就是“/signup”
,那么接下来的代码将从QQ
服务器中获取的用户信息存储到了Session
中,然后抛出了一个跳转的异常,然后该异常被捕获后,就会跳转到“/signup”
上,然后我们并没有配置“/signup”
免认证访问,所以就出现了如下图所示情况:
问题算是确定了,那么我们来分析一下场景:其实这个场景我们经常遇见,例如我们第一次使用QQ
授权登录某网站,扫码后,一般都是跳转到了一个要求绑定本网站账户的页面上,并且也支持在该页面上注册账户,然后进行绑定,那么现在对于这种需要注册的场景,我们提供两种常见的解决方案:
对于这两种解决方案,都是很常见的,那么我们来一一实现它。
我们提供在lemon-security-browser
项目中添加一个注册页面,由于注册页面是用户高度自定义的页面,所以这里默认的注册页面仅仅提示用户配置相关属性即可,代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>注册页面</title>
</head>
<body>
<h2>标准注册页</h2>
<h3>这里是标准的注册页面,需要用户自己配置属性com.lemon.security.browser.signUpUrl属性类配置自己的注册页面</h3>
</body>
</html>
这里就提示了用户去配置com.lemon.security.browser.signUpUrl
属性,然后调用自己的页面。那么我们在BrowserProperties
配置类加一个属性signUpUrl
,这个属性的默认值是指向我们在lemon-security-browser
下的signUp.html
。还有一点,为了项目的可用性,我们在lemon-security-demo
项目中也加入自定义的登录页面,和系统默认的一致,然后配置application.yml
如下所示:
com:
lemon:
security:
browser:
loginPage: /lemon-login.html
signUpUrl: /lemon-signUp.html
code:
image:
length: 6
url: /user,/user/*
这样就是是要用户自定义的登录页面(虽然本案例中和默认的页面是一样的)和注册页面。接下来,我们来完成用户自主注册的逻辑。
因为注册逻辑是用户自定义的,所以只能在demo
项目中写注册逻辑,并将注册好的用户存储到数据库中,我们现在来实现这个功能。
这里在demo
项目中加入一个简单的用户自定义的注册绑定页面,代码如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>欢迎注册</title>
</head>
<body>
<h2>用户注册页</h2>
<form action="/user/register" method="post">
<table>
<tr>
<td>用户名:</td>
<td><label>
<input name="username" type="text">
</label></td>
</tr>
<tr>
<td>密 码:</td>
<td><label>
<input name="password" type="password">
</label></td>
</tr>
<tr>
<td colspan="2">
<button type="submit" name="type" value="register">注册</button>
<button type="submit" name="type" value="binding">绑定</button>
</td>
</tr>
</table>
</form>
</body>
</html>
页面写完了,我们还需要在demo
项目中提供一个注册的Controller
,具体代码稍后提供,我们还需要配置一下,我们要告诉Spring Social
,我们的注册页面不需要授权就可以访问,那么需要在BrowserSecurityConfig
类中将securityProperties.getBrowser().getSignUpUrl()
设置到HttpSecurity
对象中去,具体方式如下所示:
.antMatchers(securityProperties.getBrowser().getSignUpUrl()).permitAll()
还需要配置一下SocialConfig
类,将代码:
@Bean
public SpringSocialConfigurer lemonSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
return new LemonSpringSocialConfigurer(filterProcessesUrl);;
}
改成
@Bean
public SpringSocialConfigurer lemonSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
LemonSpringSocialConfigurer configurer = new LemonSpringSocialConfigurer(filterProcessesUrl);
configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());
return configurer;
}
这里就是将Spring Social
默认的/signup
改成了我们自己配置的/lemon-signUp.html
,这样,当数据库没有当前授权登录的用户的时候,就会跳转到本页,提示用户注册或者绑定本站账号。我们启动项目,访问http://www.itlemon.cn/lemon-login.html
页面,点击QQ
登录,授权后就直接跳到了我们设定的注册绑定界面,如下所示:
这样,我们就将用户引导了注册绑定页面,那么用户在没有本站账户的情况下,可以选择注册,在有账户的情况下,可以选择绑定,这里对于密码的处理没有进行二次确认,这仅仅是为了方便,实际开发中对于密码的处理要复杂一些,比如加密,二次校验等。
我们在大多数网站上,当用户到达注册或者绑定的时候,页面旁边都会显示QQ
的相关信息,比如用户的QQ
昵称,第三方的服务提供商ID
,头像等信息,我们这里也这样做,请看接下来的内容。
其实这个需求,Spring Social
已经为我们考虑好了,它提供了一个工具类ProviderSignInUtils
,这个工具类提供了两个解决方案,一个是在业务系统中拿到Spring Social
的用户数据,另一个是将业务系统中注册的用户ID
再传递给Spring Social
。这两个方案就可以帮助我们在注册绑定页面显示用户第三方信息,且注册后将业务系统中的用户和第三方用户信息绑定起来。
我们在SocialConfig
类中加一个Bean配置,代码如下:
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) {
return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator));
}
其中ConnectionFactoryLocator
在Spring Boot
中已经被实例化了,我们直接通过参数形式注入进来即可,实例化ProviderSignInUtils
还需要UsersConnectionRepository
对象,那么直接调用本类中的getUsersConnectionRepository
方法即可。那么这里就配置好了ProviderSignInUtils
的实例对象,那么在需要的地方就可以直接使用注解@Autowired
注入即可。
我们需要使用到用户在第三方的信息用于展示,那么这个需求我们可以帮他做好,我们在lemon-security-browser
项目中的BrowserSecurityController
类中引入ProviderSignInUtils
的Spring Bean
,且加一个获取用户信息的接口,代码如下:
@GetMapping("/social/user")
public SocialUserInfo getSocialUserInfo(HttpServletRequest request) {
Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
return SocialUserInfo.builder().providerId(connection.getKey().getProviderId())
.providerUserId(connection.getKey().getProviderUserId())
.nickname(connection.getDisplayName())
.headImg(connection.getImageUrl())
.build();
}
代码块中,我们的providerSignInUtils
工具类是从Session
中拿到的用户信息,那么这个信息是什么时候存储到session
中的呢?在本文章的开头部分,我们讲到了信息的存储,你可以到前面看看。如果用户自定义的注册绑定页面需要显示这些信息,那么直接访问这个接口就可以实现了,在本案例中,我只提供接口,就不在去实现具体的页面逻辑了,感兴趣的朋友可以自行实现。
我们接着来提供一下注册绑定的Controller
,代码如下:
package com.lemon.security.web.controller;
import com.lemon.security.web.dto.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.servlet.http.HttpServletRequest;
/**
* 用户注册的Controller
*
* @author jiangpingping
* @date 2019-02-18 19:43
*/
@RestController
@RequestMapping("/demo")
public class RegisterController {
private final ProviderSignInUtils providerSignInUtils;
@Autowired
public RegisterController(ProviderSignInUtils providerSignInUtils) {
this.providerSignInUtils = providerSignInUtils;
}
@PostMapping("/register")
public String register(User user, HttpServletRequest request) {
// 不管是注册还是绑定,都会拿到用户在业务系统中的唯一标识,注册是新生成标识,绑定是从数据库中获取唯一标识
// 那么我们就以用户传递过来名称作为唯一标识,将这个标识和session中的用户信息一同传输给Spring Social
// Spring Social拿到数据以后,就会将这个唯一标识和用户在QQ上的信息一同存储到UserConnection表中
String userId = user.getUsername();
providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));
return "注册并绑定成功";
}
}
在上面类中,我并没有提供用户注册的具体逻辑,无非就是一些增删改查,这里提供的就是一种思路,不管是注册还是绑定,都会拿到用户在业务系统中的唯一标识,注册是新生成标识,绑定是从数据库中获取唯一标识,那么我们就以用户传递过来名称作为唯一标识,将这个标识和session
中的用户信息一同传输给Spring Social
,Spring Social
拿到数据以后,就会将这个唯一标识和用户在QQ
上的信息一同存储到UserConnection
表中,那么下次授权登录的时候,再次走到认证代码中的时候,如下图所示:
它就会调用findUserIdsWithConnection
方法从数据库表UserConnection
中查找用户信息,具体的查找代码如下源码所示:
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;
}
在源码中我们可以看出,查找依据就是providerId
和providerUserId
(实际就是openId
,QQ
用户对于每个授权应用都会生成的一个唯一的ID
),那么注册后,或者绑定后,就会查询到数据,这时候就不会返回null
了,也就不会再抛出重定向的异常了,那么就可以正确地进入到系统中了。在此之前,我们还需要配置一下,那就是配置注册URL
可以未授权就可以登录,我们在BrowserSecurityConfig
中配置一下,具体请参考前面的配置或查看码云上chapter015的源码,需要注意的一点是,这个注册URL
是demo
项目中的,在我们这个安全模块中是不知道有这么个URL
的,我们只是暂时配置到BrowserSecurityConfig
中,后面的重构中会将其配置到demo
项目中。
我们再次启动demo
项目,访问http://www.itlemon.cn/lemon-login.html
页面,点击QQ
登录,授权后就直接跳到了我们设定的注册绑定界面,如下所示:
然后我们输入任意的用户名和密码:
点击注册,然后显示如下所示:
此时,我们观察数据库的UserConnection
表,发现多了一条数据:
这样,我们就将业务系统中的用户和QQ
用户绑定起来了,下次再次登录的时候,就不会跳转到注册页面了,直接进入到主页。
上面的内容讲述了用户自己注册账号或者绑定账号,本小节将介绍默认帮助用户注册的行为,这也是一般网站常用的方法之一。其实,Spring Social
也提供了相关功能,这个需要我们一起去源码中进行探索。我们都知道,当用户使用QQ
登录的时候,会从QQ
资源服务器上获取用户的信息来封装成SocialAuthenticationToken
然后交给对应的SocialAuthenticationProvider
来进行认证操作,如果用户第一次登录,那么Spring Social
在UserConnection
表中就查不到用户的数据,那么用户就会跳转到主页页面要求用户注册或者绑定,那么我们一起来看看具体的认证代码:
这段代码是SocialAuthenticationProvider
的认证方法代码,我们进入到findUserIdsWithConnection
中查看一下:
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;
}
这段代码较长,所以没有截图,这段代码我们在上面已经介绍过了,这里再补充一点,请看条件localUserIds.size() == 0 && connectionSignUp != null
,条件也就是说,当Spring Social
在UserConnection
表中没有查到用户的信息,且connectionSignUp
对象(它是接口ConnectionSignUp
的实现类对象)存在的时候,会进入到if
方法体中,就会调用ConnectionSignUp
接口的实习类的execute
方法来注册一个用户,然后返回用户的userId
,这时候Spring Social
就会将这个userId
和connection
数据一同存入到表UserConnection
中,这也就完成了默认的注册行为。而我们进入到接口ConnectionSignUp
的时候,发现它没有任何实现,所以我们需要自己写一个类去实现默认的注册行为。由于默认的注册行为要和系统的业务关联起来,所以这里默认的注册类要写在demo
项目中,代码如下:
package com.lemon.security.web.authentication;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionSignUp;
import org.springframework.stereotype.Component;
/**
* 默认为用户注册账户的实现类
*
* @author jiangpingping
* @date 2019-02-20 20:20
*/
@Component
public class DemoConnectionSignUp implements ConnectionSignUp {
@Override
public String execute(Connection<?> connection) {
// 这里应该写与业务相关的默认注册行为,这里为了简便,生成的系统用户的userId就是要QQ的相关信息
// 这里使用的是QQ用户对本网站的唯一的openId作为userId来注册的
return connection.getKey().getProviderUserId();
}
}
这里为了简便,没有涉及太多的业务逻辑,这里使用用户的openId
作为userId
来注册了一个用户,在实际的业务系统中,应该还有一张以上的表来记录用户的信息,而UserConnection
表只是用来记录业务系统中的用户和QQ
用户之间的关系的表。我们再将这个Spring Bean
注入到SocialConfig
类中,代码如下所示:
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;
这里设置required
值为false
,这是因为ConnectionSignUp
并不是一定会有开发者提供,这得针对项目要求来决定,所以这里的required
的值就被设置为false
。还要将代码:
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 创建一个JDBC连接仓库,需要dataSource、connectionFactory加载器,对存到数据库中的加密策略,这里选择不做加密,信息原样存入数据库
// 这里创建的JdbcUsersConnectionRepository可以设置UserConnection表的前缀
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}
修改为:
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 创建一个JDBC连接仓库,需要dataSource、connectionFactory加载器,对存到数据库中的加密策略,这里选择不做加密,信息原样存入数据库
// 这里创建的JdbcUsersConnectionRepository可以设置UserConnection表的前缀
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
repository.setConnectionSignUp(connectionSignUp);
return repository;
}
我将UserConnection
表中的数据删除掉,重新登录,发现数据库表又新增了一条数据,这就完成了默认的注册行为。
那么文章写道这里,我们就一起完成了Spring Social
集成QQ
登录的开发内容,这里提供的案例很简单,朋友们可以根据自己实际的业务需求,来开发适合自己系统的代码。接下来,我会继续更新Spring Social
集成微信登录的开发案例,请继续关注后面的内容。