Spring Security-17-处理注册逻辑

上文中,最后QQ授权成功,获取到用户信息之后,是跳转到了一个 /sign/up 的请求上去, 下面首先看一下为什么会跳转到注册页面上去

首先,按照 security 的登录流程, 上文中获取到了 QQ 信息,然后组成一个未认证的 SocialAuthenticationToken 对象, 然后之后就是通过 AuthenticationManager 找到对应的 Provider 来做具体的验证, 在 social 中, Provider 的具体实现是 SocialAuthenticationProvider , 部分源码如下:

image

这里就是 SocialAuthenticationProvider 中的具体的认证流程, 首先会拿到 Connection 对象,也就是我们获取回来的第三方用户信息, 然后调用了一个 toUserId() 方法,这个方法会从数据库的 userconnection 表中,查询这个第三方用户关联的我们业务系统中的id,但是我们现在这个表中并没有数据,所以会返回空, 然后抛出 BadCredentialsException 异常,标识该用户还没有在我们的业务系统中绑定

然后再上层的 SocialAuthenticationFilter 代码如下:
image

这里会捕获到认证时候抛出来的 BadCredentialsException 异常,然后判断有没有配置登录页,有的话,就会抛出异常,跳转到注册页

问题已经找到了,下面看一下如何去解决,以及做一些配置

设置注册页面的路径

注册页默认的跳转路径是 /signUp, 需要把这个做成一个可配置项,这个配置在之前的 SocialConfig

1
2
3
4
5
6
7
@Bean
public SpringSocialConfigurer securitySocialConfigurer(){
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
MySpringSocialConfigurer mySpringSocialConfigurer = new MySpringSocialConfigurer(filterProcessesUrl);
mySpringSocialConfigurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());
return mySpringSocialConfigurer;
}

在我们自定义的 MySpringSocialConfigurer 中设置, 是从配置中读取过来的

所以在 BrowserProperties 中,需要加上注册的跳转路径:

1
2
3
4
/**
* 跳转注册的路径
*/
private String signUpUrl = "/signUp";

默认的是 /signUp, 我们这里可以提供一个默认的处理方式, web 项目中,写一个 SignUpController,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@RequestMapping("/signUp")
public class SignUpController {

private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

@Autowired
private SecurityProperties securityProperties;

@RequestMapping(produces = "text/html")
public void signUpHtml(HttpServletRequest request, HttpServletResponse response) throws IOException {
redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getSignUpPage());
}

@RequestMapping
@ResponseStatus(HttpStatus.PRECONDITION_REQUIRED)
public Map<String, Object> signUpJson() {
Map<String, Object> resultMap = new HashMap<>(1);
resultMap.put("message", "用户未注册,请引导用户到注册页面....");
return resultMap;
}
}

这里和之前的 /authentication/require 一样,判断是哪里来的请求,做出不同的跳转, 然后这里跳转的登录页也做成可配置的,同时提供一个默认的页面, 这个默认的页面就提示一下用户配置自己的注册就可以了,因为每个调用方的注册可能都是不一样的,所以由调用方自己实现就好了
在demo项目中加入配置

1
2
3
4
core:
security:
browser:
signUpPage: /mySignUp.html

然后提供一个注册页面,如下:
image

这里提供了一个注册和一个绑定的按钮, 因为用户可能之前就有这个网站的账号,只是第一次使用第三方登录,所以就需要一个绑定的接口

最后在 BrowserSecurityConfig 中,记得把登录的跳转路径和登录页面的路径加到不需要权限里面去

这时候,启动项目,然后QQ登录,授权之后跳转到的应该是注册的页面,如下:
image

获取第三方用户信息

在注册/绑定的时候,可能会用到第三方的用户信息, 比如显示第三方用户的昵称或者头像等信息,这些信息可以通过 social 提供的一个工具 ProviderSignInUtils 来获取到,这个工具类再后面做绑定的时候也会用到

首先需要做一下配置, 在 SocialConfig 加入以下代码:

1
2
3
4
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator){
return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator));
}

之后就可以通过注入来使用这个工具类了

web项目里新建一个获取 social 用户信息的 controller,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
public class SocialUserController {

@Autowired
private ProviderSignInUtils providerSignInUtils;

@GetMapping(SecurityConstants.GET_SOCIAL_USER_URL)
private SocialUserInfo getSocialUser(HttpServletRequest request){
SocialUserInfo userInfo = new SocialUserInfo();

Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));

userInfo.setProviderId(connection.getKey().getProviderId());
userInfo.setProviderUserId(connection.getKey().getProviderUserId());
userInfo.setNickname(connection.getDisplayName());
userInfo.setHeadimg(connection.getImageUrl());

return userInfo;
}
}

然后把这个路径也加到不需要权限的路径里面去, 启动项目访问效果如下:
image

这个原理就是从session中去拿 Connection 对象, 看本文第二张图, social 会在重定向注册路径之前,把获取 Connection 先存在session里面

注册逻辑处理

上面的注册页面中,表单是发送到了 /user/register 这个请求里,所以我们还需要这个接口,来处理用户登录的具体逻辑,注册逻辑还是由调用方去实现,所以还是写到 demo 项目中去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

@Autowired
private ProviderSignInUtils providerSignInUtils;

@PostMapping("/register")
public void register(User user, HttpServletRequest request){
// ..省略和数据库的交互
// 最终要拿到业务系统中的 用户的唯一标识
providerSignInUtils.doPostSignUp(String.valueOf(1L), new ServletWebRequest(request));
}
}

这里不管是注册还是绑定, 最终都会拿到一个业务系统中的唯一标识,然后通过调用 providerSignInUtilsdoPostSignUp() 方法来完成注册的,这个方法会往 userconnection 表里添加数据,把业务系统的用户和第三方的用户做关联,我这里先写死用户id是1的数据了

最后要把 /user/register 添加到不需要权限的路径里面, 这个其实是调用方自定义的路径,正常来说不应该去配到 BrowserSecurityConfig 里,但是这里为了方便,先这样写,后面再去优化

这次启动项目,使用QQ登录,然后授权,跳转到注册页面之后,点注册,这时候再userconnection表中就会有一条记录

1
2
3
userId  providerId   providerUserId                      rank  displayName  profileUrl  imageUrl                                                                       accessToken                       secret  refreshToken                         expireTime  
------ ----------- -------------------------------- ------ ----------- ---------- ----------------------------------------------------------------------------- -------------------------------- ------ -------------------------------- ---------------
1 callback.do 1B815954CDDAA9E29193E3E185FB293F 1 周。 (NULL) http://thirdqq.qlogo.cn/g?b=oidb&k=cKomQts7QiascqicHy2ZY4lw&s=40&t=1554802302 1582672832E0F1AA99B95059A8A72DA8 (NULL) 4645CB01B0C04604E2B1AB6F0CEC7E5C 1574064303538

这次已经绑定了业务系统中的id, 下次登录的时候,就会通过 providerIdproviderUserId 查询到业务系统的id,然后进入 SocialUserDetailsService 通过业务系统的id去查询用户,就不会再跳转到登录页面了

踩坑记录

这里我在运行的过程中有遇到一个问题,就是 SocialConfig 中配置的 UsersConnectionRepository 没有生效,导致去数据库查询的时候用了它的默认实现 InMemoryUsersConnectionRepository ,然后就导致每次去数据库查询都查不到, 原因是类加载的时候顺序出了点问题, 这个 SocialConfig 先注册了,没等 JdbcUsersConnectionRepository 注册就结束了,导致 UsersConnectionRepository 只能使用默认的实现了

我这里解决方案是 在 SocialConfig 类上加一个 @Order(10) 注解,让他晚点注册

不需要注册的场景

上面的场景中,是用户去第三方登录,然后我们业务系统中没有对应的用户,就跳转注册页面让用户去注册,其实在很多的系统中都是第三方可以直接登录的,并不需要去创建业务系统中的用户,这样的话用户体验会更好

这里的实现原理,就是在登录的时候,发现业务系统中没有对应的用户,就我们后台去给他生成一个用户

首先来看一个方法,是 SocialAuthenticationProvider 中,调用 toUserId() 去查询用户id的方法里面的 findUserIdsWithConnection(),代码如下:
image

这里首先会去数据库里面查询,如果没有查到的话,那就是没有这个用户,他底下有个判断,是在没有查询到,而且 connectionSignUp 不为空的情况下, 去调用 connectionSignUpexecute() 方法,然后返回一个新用户的id, 创建完成后把新用户的id返回去

connectionSignUp 是一个接口,里面之后一个 execute() 方法,我们要做的就是实现这个接口

这个实现也应该由调用方去决定是否去实现,所以 也写在demo项目中, 新建 MyConnectionSignUp 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class MyConnectionSignUp implements ConnectionSignUp {

@Autowired
private UserService userService;

@Override
public String execute(Connection<?> connection) {
// 这里应该去根据自己的业务去创建一个用户,并返回用户的唯一标识
User user = new User(6L, connection.getDisplayName(), "123456", false, true);

userService.insert(user);

return user.getId().toString();
}
}

这里能拿到的就是 Connection 对象,也就是第三方用户的一些信息,然后可以结合自己的业务,创建一个新的用户,反正最终要返回业务系统中的用户唯一标识

最后需要在构建 UsersConnectionRepository 的时候把 ConnectionSignUp 的实现设置进去,在 SocialConfig 中代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;

@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {

JdbcUsersConnectionRepository jdbcUsersConnectionRepository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
// 设置表的前缀
// jdbcUsersConnectionRepository.setTablePrefix("");
jdbcUsersConnectionRepository.setConnectionSignUp(connectionSignUp);
return jdbcUsersConnectionRepository;
}

这里需要注意的是 @Autowired 要设置成非必须的,因为调用方可能并不会去实现这个接口

测试

以上配置完成之后,在 UserConnection 表中,把之前测试的数据清空掉,然后重启项目授权,这时候就不会去跳转注册页去了,而且会直接在 UserConnection 表里面生成一条新的数据

本文代码传送门