前言
上一章节介绍了Spring Security认证和授权的基本流程,大家如果已经对这部分内容做到"知其所以然",那接下去肯定就得进行实际应用来加固对这部分内容的理解。
本章节以网上的若依-移动端版项目为基础框架,集成微信小程序开发,实现多种不同的登录方式,包括原有的账号密码登录和下面即将实现的微信小程序登录。
主要技术栈
移动端:uniapp框架
后端:springboot + spring security + jwt
若依移动端源码下载地址:https://gitee.com/y_project/RuoYi-App
简介
什么是微信小程序登录?
小程序大家很熟悉,市面上有许多平台有自己的小程序,包括百度小程序、支付宝小程序、微信小程序等等
本章节讲的微信小程序依附于微信平台,可以通过官方提供的登录能力方便的获取微信提供的用户身份标识,快速建立小程序内的用户体系。
登录流程时序
在正式开始之前,我先贴个流程图,该流程图是微信开放文档提供的小程序登录的流程图。可以使我们更好地理解整个登录流程,如下:
分为下面几个步骤:
1、小程序端调用微信开放提供的wx.login()方法,向微信服务器发起请求,获取临时登录凭证,顾名思义,这个凭证只是临时的,只能使用一次,下次会获取新的临时凭证。
2、小程序端向springboot后端发起请求,将临时登录凭证封装后发往开发者服务器。
3、开发者服务器接收到登录凭证后,结合微信小程序平台提供的appid和appsecret再次调用微信平台提供的接口,也就是 auth.code2Session
接口,换取用户唯一标识OpenId、 用户在微信开放平台帐号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台帐号) 和 会话密钥 session_key。
4、利用获取到的session_key 会话密钥对登录凭证中加密过的用户数据进行解密,得到真实的用户数据。
5、拿真实用户数据到数据库中查询,如果用户不存在,则新增到数据库,并且结合spring security 和jwt生成token,也就是自定义登录态,回传到小程序端保存token,之后每次向后端发起请求都会带上token作为请求的通行证,用于后续业务逻辑中前后端交互时识别用户身份。
注意事项
- 会话密钥
session_key
是对用户数据进行 加密签名 的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥。 - 临时登录凭证 code 只能使用一次
前端改动
目录结构
项目启动,默认展示的页面是框架提供的账号密码登录页面
查看pages.js,显示第一个page页面正是上面的登录页,在这里我把它替换成我自定义的一个页面
并且在pages.js中将该页面显示在第一个,同时我将目录结构稍微改造了一下
这时候启动项目,界面如下:
界面代码如下,样式代码部分略:
柚汐云库
我已阅读并同意
《用户协议》
和
《隐私政策》
核心流程
1、用户勾选同意协议按钮并点击微信一键登录,进入如下登录方法
因为使用的是uniapp框架,uniapp为开发者提供了 uni.login(OBJECT),该方法是在微信wx.login()
的基础上在做封装。
调用uni.getProvider(OBJECT)获取服务供应商,参数service参考uniapp文档的provider在不同服务类型下可能的取值说明 取值为"oauth"。
在请求成功的回调中调用uni.login,服务提供商provider填写"weixin",请求成功后会在回调方法中返回loginRes
success 返回参数如下:
核心就是这个临时登录凭证code,上面介绍过。
2、只有一个登录凭证不够,再次调用uni.getUserInfo(OBJECT),获取用户信息。
success 返回参数如下:
3、将encryptedData、iv、code 封装成wxLoginForm对象,向后端发起请求
在store/modules/user.js下新增微信一键登录方法,其他不动
WxLogin中又调用了/api/mini/login下的wxLogin,所以在/api/mini/login下也新增一个wxLogin方法
这里有个比较重要的authType,不同的登录方式对应的值不同,用来在后端服务器中区分前端发起的登录请求的类型,从而进行不同的逻辑操作。
可能有小伙伴会有疑惑,为什么不把authType写在data里,而是要在请求头中?
其实两种都可以
1、如果authType包含在data里,因为是post请求,在后端要获取authType的值,需要使用httpServletRequest.getParameter接收post请求参数,但这里有个规定,发送端content Type必须设置为application/x-www-form-urlencoded 否则会接收不到。
这时候如果将发送端content Type设置为application/x-www-form-urlencoded ,因为控制器是以@RequestBody的方式来接受form,会出现Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported的错误。
如果Content-Type设置为“application/x-www-form-urlencoded;charset=UTF-8”无论是POST请求还是GET请求都是可以通过这种方式成功获取参数,但是如果前端POST请求中的body是Json对象的话,会报上述错误。
请求中传JSON时设置的Content-Type 如果是application/json或者text/json时,JAVA中request.getParameter("")怎么也接收不到数据。这是因为,Tomcat的HttpServletRequest类的实现类为org.apache.catalina.connector.Request(实际上是org.apache.coyote.Request)。
当前端请求的Content-Type是Json时,可以用@RequestBody这个注解来解决。@RequestParam 底层是通过request.getParameter方式获得参数的,换句话说,@RequestParam 和request.getParameter是同一回事。因为使用request.getParameter()方式获取参数,可以处理get 方式中queryString的值,也可以处理post方式中 body data的值。所以,@RequestParam可以处理get 方式中queryString的值,也可以处理post方式中 body data的值。@RequestParam用来处理Content-Type: 为 application/x-www-form-urlencoded编码的内容,提交方式GET、POST。
@RequestBody接受的是一个json对象的字符串,而不是Json对象,在请求时往往都是Json对象,用JSON.stringify(data)的方式就能将对象变成json字符串。
简单来说就是:
前端请求传Json对象则后端使用@RequestParam;
前端请求传Json对象的字符串则后端使用@RequestBody。
所以我这里使用了另一种曲线救国的方案,就是将authType放在请求头里,在后端通过request.getHeader来获取值。
通过上面的流程,用户点击一键登录按钮后,小程序端先去调用微信官方服务器,获取到一些必须的数据经过封装后将code、encryptedData、encryptedIv发送到后端。
后端改动
目录结构
先了解一下若依框架对spring security认证授权流程进行的一些处理。
在SecurityConfig.java中的配置:
其中在过滤器链中加上了authenticationTokenFilter,这是个token过滤器,该过滤器主要用来对每次也是有且一次请求时验证token有效性。
请求进来后,从该请求所在的线程中也就是ThreadLocal 中获取到当前用户对应的 SecurityContext,进一步获取登录用户的权限信息等。
从代码中发现,如果缓存中存在登录用户信息,那么会将这个认证过的用户登录信息存入SecurityContext
如果此时是以微信一键登录的方式进来的请求,显然用UsernamePasswordAuthenticationToken 来承接就不符合逻辑了。
所以应该模仿UsernamePasswordAuthenticationToken 自定义一个认证的xxxToken,并且实现AbstractAuthenticationToken抽象类。
WxMiniAuthenticationToken.java代码如下:
package com.youxi.framework.security.token;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class WxMiniAuthenticationToken extends AbstractAuthenticationToken {
private String openid;
private String sessionKey;
private String encryptedData;
private String encryptedIv;
private Object principal;
public WxMiniAuthenticationToken(String openid, String sessionKey, String encryptedData,String encryptedIv) {
super(null);
this.openid = openid;
this.sessionKey = sessionKey;
this.encryptedData = encryptedData;
this.encryptedIv = encryptedIv;
}
public WxMiniAuthenticationToken(Object principal,String openId,Collection extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.openid = openId;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return this.openid;
}
@Override
public Object getPrincipal() {
return this.principal;
}
public String getSessionKey() {
return sessionKey;
}
public void setSessionKey(String sessionKey) {
this.sessionKey = sessionKey;
}
public String getEncryptedData() {
return encryptedData;
}
public void setEncryptedData(String encryptedData) {
this.encryptedData = encryptedData;
}
public String getEncryptedIv() {
return encryptedIv;
}
public void setEncryptedIv(String encryptedIv) {
this.encryptedIv = encryptedIv;
}
public String getOpenid() {
return openid;
}
public void setOpenid(String openid) {
this.openid = openid;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
将过滤器改造如下
对于每次请求,通过获取请求头的authType来判断是什么样的登录方式,进而决定使用WxMiniAuthenticationToken还是UsernamePasswordAuthenticationToken来封装认证的用户登录信息。
注意:这里我始终觉得这样做也不太对,因为如果以这种方式来实现,由于这个过滤器的特殊,每次请求都会进来,都会做登录方式的判断,那如果用户已经登录好了,下一次的请求是在登录后的请求,其实跟登录方式是什么一点关系都没有,这时候再进行判断,这显然不太合理。仔细思考了一下这个过滤器的作用,发现它应该只是用来校验请求头中的jwt是否有效,以此为依据来认证用户是否登录,封装的形式其实差不多都是authenticationToken = new xxxAuthenticationToken(loginUser, null, loginUser.getAuthorities()),我发现之后即使使用到这个authenticationToken 也不会因为类型不同产生影响,因为获取到authenticationToken的目的是为了获取LoginUser,所以只要LoginUser用户登录信息不变,authenticationToken里面的内容是我们需要的,那就不会有影响。authenticationToken的不同我想应该只是用来认证的时候要使用哪种认证器,进而自定义对数据库的不同操作逻辑。所以这里其实不加判断直接使用原始的UsernamePasswordAuthenticationToken也并不会有影响。
这个过滤器里,还有个重要的点,这一步一定要设置。
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
分析
结合上面贴出的SecurityConfig配置图,其中有个配置允许某些请求匿名访问
第一次请求/mini/wxLogin进来,允许匿名访问,所以即使SecurityContext里没有authenticationToken,也能通过过滤器到达Controller。
之后经过一系列认证流程得到认证过的authenticationToken,将用户登录信息保存在缓存中。
第二次发起/mini/getInfo请求,不允许匿名访问,在JwtAuthenticationTokenFilter中,通过该请求从缓存中取到用户登录信息,如果登录信息存在且此时SecurityContext里面没有authenticationToken,那么就new一个xxxToken,并调用setAuthentication(authenticationToken)设置认证信息。如此一来,经过后面的过滤器,发现存在认证过的身份,自然就能通过到达Controller。
反之,如果在这个过滤器中不调用setAuthentication(authenticationToken)设置认证信息,请求是无法通过的,就会在前端出现这样的401错误。
请求访问:/mini/getInfo,认证失败,无法访问系统资源
上一章节我们知道用户登录后,后端的AbstractAuthenticationProcessingFilter结合UsernamePasswordAuthenticationToken过滤器,将获取到的用户名和密码封装成一个实现了 Authentication 接口的实现子类 UsernamePasswordAuthenticationToken对象。
在若依项目中,这一步骤省去,直接写在login方法里
新建WxLoginController.java
SysLoginService.java类中新增如下代码
// ===============================微信小程序================================
/**
* 微信登录
* 如果用unionid作为唯一标识,必须小程序要绑定微信开放平台,并且要500RMB,所以只能用openid作为唯一标识
* @param code 登录凭证 只能用一次
* @return
*/
public String wxLogin(String code,String encryptedData,String encryptedIv){
if(StringUtils.isBlank(code)){
throw new BadCredentialsException("wxCode is null");
}
if(StringUtils.isBlank(encryptedData)){
throw new BadCredentialsException("encryptedData is null");
}
if(StringUtils.isBlank(encryptedIv)){
throw new BadCredentialsException("encryptedIv is null");
}
//向微信服务器发送请求获取用户信息
String url = wxAppConfig.getServerUrl() + "?appid=" + wxAppConfig.getAppId()
+ "&secret=" + wxAppConfig.getAppSecret() + "&js_code=" + code + "&grant_type=authorization_code";
String res = restTemplate.getForObject(url, String.class);
JSONObject jsonObject = JSONObject.parseObject(res);
//获取session_key和openId
String session_key = jsonObject.getString("session_key");
//解析用户信息
//解密
String userInfo = "";
try{
//如果没有绑定微信开放平台,解析结果是没有unionId的。
userInfo = WeChatUtil.getUserInfo(encryptedData,session_key,encryptedIv);
}catch (Exception e){
e.printStackTrace();
throw new BadCredentialsException("微信登录失败");
}
if(!StringUtils.isNotEmpty(userInfo)){
throw new BadCredentialsException("微信登录失败");
}
//如果解析成功,获取token
jsonObject = JSONObject.parseObject(userInfo);
//获取openid
String openId = jsonObject.getString("openId");
// 用户验证
Authentication authentication = null;
try
{
WxMiniAuthenticationToken authenticationToken = new WxMiniAuthenticationToken(openId,session_key,encryptedData,encryptedIv);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(wxAppConfig.getAppId(), Constants.LOGIN_FAIL, MessageUtils.message("user.wx.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(wxAppConfig.getAppId(), Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(wxAppConfig.getAppId(), Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
处理步骤如下
1、根据小程序登录的接口说明,在微信开放平台获取小程序对应的appId、appSecurt ,以及调用的code2Session接口地址写入配置文件application.yml
2、拼接请求地址并发起请求
3、将请求的返回结果封装成JSONObject对象,通过key :session_key和openId取出对应值
4、将openId,session_key,encryptedData,encryptedIv 这四个值封装成WxMiniAuthenticationToken,交给AuthenticationManager认证管理器认证
这时候又到了我们熟悉的spring security认证流程了~
经过之前的学习,我们知道在ProviderManager中,会遍历AuthenticationProvider列表,根据xxxToken的类型来选择合适的认证器。
这里就不能再使用AbstractUserDetailsAuthenticationProvider了,我们来自定义一个认证器WxMiniAuthenticationProvider
@Component
public class WxMiniAuthenticationProvider implements AuthenticationProvider {
protected final Log logger = LogFactory.getLog(getClass());
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
@Autowired
private CustomUserDetailsServiceImpl customUserDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(WxMiniAuthenticationToken.class, authentication,
() -> this.messages.getMessage("WxMiniAuthenticationProvider.onlySupports",
"Only WxMiniAuthenticationToken is supported"));
WxMiniAuthenticationToken tokenA = (WxMiniAuthenticationToken) authentication;
String sessionKey = tokenA.getSessionKey();
String encryptedData = tokenA.getEncryptedData();
String encryptedIv = tokenA.getEncryptedIv();
//然后,查询对应用户,有就更新,没有就新增
UserDetails user = customUserDetailsService.loadUserByOpenId(sessionKey, encryptedIv, encryptedData);
return createSuccessAuthentication(user,authentication,user);
}
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
// Ensure we return the original credentials the user supplied,
// so subsequent attempts are successful even with encoded passwords.
// Also ensure we return the original getDetails(), so that future
// authentication events after cache expiry contain the details
WxMiniAuthenticationToken result = new WxMiniAuthenticationToken(principal,(String) authentication.getCredentials(),user.getAuthorities());
result.setDetails(authentication.getDetails());
return result;
}
/**
* 加上这段,当请求进来时
* ProviderManager会对目前存在的所有provider进行遍历判断
* 如果当前的AbstractAuthenticationToken实现类xxxToken支持该provider
* 就执行这个provider的provider.authenticate(authentication)方法
* @param authentication
* @return
*/
@Override
public boolean supports(Class> authentication) {
return WxMiniAuthenticationToken.class.isAssignableFrom(authentication);
}
}
这个认证器实现逻辑很简单,类似DaoAuthenticationProvider中的this.getUserDetailsService().loadUserByUsername(username);所以我自定义了一个类CustomUserDetailsServiceImpl.java
@Service
public class CustomUserDetailsServiceImpl {
private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Autowired
private SysPermissionService permissionService;
@Autowired
private SysUserMapper userMapper;
/**
* @return
* @throws AuthenticationException
*/
public UserDetails loadUserByOpenId(String sessionKey,String encryptedIv,String rawData) throws AuthenticationException
{
//解密
String userInfo = "";
try{
//如果没有绑定微信开放平台,解析结果是没有unionId的。
userInfo = WeChatUtil.getUserInfo(rawData,sessionKey,encryptedIv);
}catch (Exception e){
e.printStackTrace();
throw new BadCredentialsException("微信登录失败");
}
if(!StringUtils.isNotEmpty(userInfo)){
throw new BadCredentialsException("微信登录失败");
}
//如果解析成功,获取token
JSONObject jsonObject = JSONObject.parseObject(userInfo);
//获取openid
String openId = jsonObject.getString("openId");
// String unionId = jsonObject.getString("unionId"); 要钱
//获取nikeName
String nikeName = jsonObject.getString("nickName");
//获取头像
String avatarUrl = jsonObject.getString("avatarUrl");
String language = jsonObject.getString("language");
String city = jsonObject.getString("city");
String province = jsonObject.getString("province");
String gender = jsonObject.getString("gender");
//还可以获取其他信息
//根据openId判断数据库中是否有该用户
//根据openId查询用户信息
SysUser wxUser = userMapper.queryWxUserByOpenId(openId);
//如果查不到,则新增,查到了就更新
if(wxUser == null){
wxUser = new SysUser();
//新增
wxUser.setOpenId(openId);
wxUser.setUserName("");
wxUser.setNickName(nikeName);
wxUser.setUserType(Constants.CLIENTUSER);
wxUser.setSex(gender);
wxUser.setAvatar(avatarUrl);
wxUser.setPassword("");
wxUser.setCreateTime(DateUtils.getNowDate());
//新增用户
userMapper.insertUser(wxUser);
}else {
//更新
wxUser = wxUser;
wxUser.setNickName(nikeName);
wxUser.setAvatar(avatarUrl);
wxUser.setSex(gender);
wxUser.setUpdateTime(DateUtils.getNowDate());
//更新用户
userMapper.updateUser(wxUser);
}
return createLoginUser(wxUser);
}
public UserDetails createLoginUser(SysUser user)
{
return new LoginUser(user.getUserId(), user.getOpenId(), user, permissionService.getMenuPermission(user));
}
}
这个类的作用就是通过sessionKey和encryptedIv,对加密过的用户数据字符串进行解密,得到一系列用户昵称,头像,性别等用户信息,其中还有一个最重要的openId,可以理解成用户的唯一标识。
得到了这些数据后,接下去就是基本的怎删改查,通过openId来作为唯一标识
如果已存在openId的对象,将对象更新
如果不存在则新增user对象并插入数据库
最终结果将user封装成UserDetails,具体实现是利用一个继承了UserDetails的登录用户身份权限类LoginUser
package com.youxi.common.core.domain.model;
import java.util.Collection;
import java.util.Set;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.alibaba.fastjson2.annotation.JSONField;
import com.youxi.common.core.domain.entity.SysUser;
/**
* 登录用户身份权限
*
* @author youxi
*/
public class LoginUser implements UserDetails
{
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 部门ID
*/
private Long deptId;
/**
* 用户唯一标识
*/
private String token;
/**
* 登录时间
*/
private Long loginTime;
/**
* 过期时间
*/
private Long expireTime;
/**
* 登录IP地址
*/
private String ipaddr;
/**
* 登录地点
*/
private String loginLocation;
/**
* 浏览器类型
*/
private String browser;
/**
* 操作系统
*/
private String os;
/**
* 权限列表
*/
private Set permissions;
/**
* 用户信息
*/
private SysUser user;
/**
* 微信登录unionId
*/
private String unionId;
/**
* 微信登录openId
*/
private String openId;
public LoginUser()
{
}
public LoginUser(SysUser user, Set permissions)
{
this.user = user;
this.permissions = permissions;
}
public LoginUser(Long userId, Long deptId, SysUser user, Set permissions)
{
this.userId = userId;
this.deptId = deptId;
this.user = user;
this.permissions = permissions;
}
public LoginUser(Long userId, String openId, SysUser user, Set permissions)
{
this.userId = userId;
this.openId = openId;
this.user = user;
this.permissions = permissions;
}
。。。。省略get set 方法
}
接下去,在自定义微信登陆认证器返回该UserDetails,重新创建一个新的已认证过的Authentication对象
这时候我们已经得到了新的认证过的Authentication对象,接着记录登录信息
最后是生成token,利用JWT生成token的同时,将用户登录信息保存在缓存中
然后将token返回,这样一个登录流程就完成啦~