Spring Security09 自定义认证器实现微信小程序一键登录

前言

上一章节介绍了Spring Security认证和授权的基本流程,大家如果已经对这部分内容做到"知其所以然",那接下去肯定就得进行实际应用来加固对这部分内容的理解。
本章节以网上的若依-移动端版项目为基础框架,集成微信小程序开发,实现多种不同的登录方式,包括原有的账号密码登录和下面即将实现的微信小程序登录。

主要技术栈

移动端:uniapp框架
后端:springboot + spring security + jwt
若依移动端源码下载地址:https://gitee.com/y_project/RuoYi-App

简介

什么是微信小程序登录?
小程序大家很熟悉,市面上有许多平台有自己的小程序,包括百度小程序、支付宝小程序、微信小程序等等
本章节讲的微信小程序依附于微信平台,可以通过官方提供的登录能力方便的获取微信提供的用户身份标识,快速建立小程序内的用户体系。

登录流程时序

在正式开始之前,我先贴个流程图,该流程图是微信开放文档提供的小程序登录的流程图。可以使我们更好地理解整个登录流程,如下:

image.png

分为下面几个步骤:
1、小程序端调用微信开放提供的wx.login()方法,向微信服务器发起请求,获取临时登录凭证,顾名思义,这个凭证只是临时的,只能使用一次,下次会获取新的临时凭证。
2、小程序端向springboot后端发起请求,将临时登录凭证封装后发往开发者服务器。
3、开发者服务器接收到登录凭证后,结合微信小程序平台提供的appid和appsecret再次调用微信平台提供的接口,也就是 auth.code2Session
接口,换取用户唯一标识OpenId、 用户在微信开放平台帐号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台帐号) 和 会话密钥 session_key
4、利用获取到的session_key 会话密钥对登录凭证中加密过的用户数据进行解密,得到真实的用户数据。
5、拿真实用户数据到数据库中查询,如果用户不存在,则新增到数据库,并且结合spring security 和jwt生成token,也就是自定义登录态,回传到小程序端保存token,之后每次向后端发起请求都会带上token作为请求的通行证,用于后续业务逻辑中前后端交互时识别用户身份。

注意事项

  1. 会话密钥 session_key 是对用户数据进行 加密签名 的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥
  2. 临时登录凭证 code 只能使用一次

前端改动

目录结构
image.png

项目启动,默认展示的页面是框架提供的账号密码登录页面


image.png

查看pages.js,显示第一个page页面正是上面的登录页,在这里我把它替换成我自定义的一个页面
并且在pages.js中将该页面显示在第一个,同时我将目录结构稍微改造了一下


image.png

这时候启动项目,界面如下:
image.png

界面代码如下,样式代码部分略:






核心流程

1、用户勾选同意协议按钮并点击微信一键登录,进入如下登录方法

image.png

因为使用的是uniapp框架,uniapp为开发者提供了 uni.login(OBJECT),该方法是在微信wx.login()
的基础上在做封装。
调用uni.getProvider(OBJECT)获取服务供应商,参数service参考uniapp文档的provider在不同服务类型下可能的取值说明 取值为"oauth"。
在请求成功的回调中调用uni.login,服务提供商provider填写"weixin",请求成功后会在回调方法中返回loginRes
success 返回参数如下:
image.png

核心就是这个临时登录凭证code,上面介绍过。
2、只有一个登录凭证不够,再次调用uni.getUserInfo(OBJECT),获取用户信息。
success 返回参数如下:
image.png

3、将encryptedData、iv、code 封装成wxLoginForm对象,向后端发起请求
image.png

image.png

在store/modules/user.js下新增微信一键登录方法,其他不动

image.png

WxLogin中又调用了/api/mini/login下的wxLogin,所以在/api/mini/login下也新增一个wxLogin方法
image.png

这里有个比较重要的authType,不同的登录方式对应的值不同,用来在后端服务器中区分前端发起的登录请求的类型,从而进行不同的逻辑操作。
可能有小伙伴会有疑惑,为什么不把authType写在data里,而是要在请求头中?
其实两种都可以
1、如果authType包含在data里,因为是post请求,在后端要获取authType的值,需要使用httpServletRequest.getParameter接收post请求参数,但这里有个规定,发送端content Type必须设置为application/x-www-form-urlencoded 否则会接收不到。
image.png

这时候如果将发送端content Type设置为application/x-www-form-urlencoded ,因为控制器是以@RequestBody的方式来接受form,会出现Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported的错误。
image.png

如果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发送到后端。

后端改动

目录结构
image.png

image.png

先了解一下若依框架对spring security认证授权流程进行的一些处理。
在SecurityConfig.java中的配置:


image.png

其中在过滤器链中加上了authenticationTokenFilter,这是个token过滤器,该过滤器主要用来对每次也是有且一次请求时验证token有效性。


image.png

请求进来后,从该请求所在的线程中也就是ThreadLocal 中获取到当前用户对应的 SecurityContext,进一步获取登录用户的权限信息等。

从代码中发现,如果缓存中存在登录用户信息,那么会将这个认证过的用户登录信息存入SecurityContext


image.png

如果此时是以微信一键登录的方式进来的请求,显然用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 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();
    }
}

将过滤器改造如下


image.png

对于每次请求,通过获取请求头的authType来判断是什么样的登录方式,进而决定使用WxMiniAuthenticationToken还是UsernamePasswordAuthenticationToken来封装认证的用户登录信息。
注意:这里我始终觉得这样做也不太对,因为如果以这种方式来实现,由于这个过滤器的特殊,每次请求都会进来,都会做登录方式的判断,那如果用户已经登录好了,下一次的请求是在登录后的请求,其实跟登录方式是什么一点关系都没有,这时候再进行判断,这显然不太合理。仔细思考了一下这个过滤器的作用,发现它应该只是用来校验请求头中的jwt是否有效,以此为依据来认证用户是否登录,封装的形式其实差不多都是authenticationToken = new xxxAuthenticationToken(loginUser, null, loginUser.getAuthorities()),我发现之后即使使用到这个authenticationToken 也不会因为类型不同产生影响,因为获取到authenticationToken的目的是为了获取LoginUser,所以只要LoginUser用户登录信息不变,authenticationToken里面的内容是我们需要的,那就不会有影响。authenticationToken的不同我想应该只是用来认证的时候要使用哪种认证器,进而自定义对数据库的不同操作逻辑。所以这里其实不加判断直接使用原始的UsernamePasswordAuthenticationToken也并不会有影响。

这个过滤器里,还有个重要的点,这一步一定要设置。

SecurityContextHolder.getContext().setAuthentication(authenticationToken);

分析
结合上面贴出的SecurityConfig配置图,其中有个配置允许某些请求匿名访问

image.png

第一次请求/mini/wxLogin进来,允许匿名访问,所以即使SecurityContext里没有authenticationToken,也能通过过滤器到达Controller。
之后经过一系列认证流程得到认证过的authenticationToken,将用户登录信息保存在缓存中。
第二次发起/mini/getInfo请求,不允许匿名访问,在JwtAuthenticationTokenFilter中,通过该请求从缓存中取到用户登录信息,如果登录信息存在且此时SecurityContext里面没有authenticationToken,那么就new一个xxxToken,并调用setAuthentication(authenticationToken)设置认证信息。如此一来,经过后面的过滤器,发现存在认证过的身份,自然就能通过到达Controller。
image.png

反之,如果在这个过滤器中不调用setAuthentication(authenticationToken)设置认证信息,请求是无法通过的,就会在前端出现这样的401错误。
image.png

请求访问:/mini/getInfo,认证失败,无法访问系统资源

上一章节我们知道用户登录后,后端的AbstractAuthenticationProcessingFilter结合UsernamePasswordAuthenticationToken过滤器,将获取到的用户名和密码封装成一个实现了 Authentication 接口的实现子类 UsernamePasswordAuthenticationToken对象。
在若依项目中,这一步骤省去,直接写在login方法里


image.png

新建WxLoginController.java


image.png

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

image.png

2、拼接请求地址并发起请求
image.png

3、将请求的返回结果封装成JSONObject对象,通过key :session_key和openId取出对应值
image.png

4、将openId,session_key,encryptedData,encryptedIv 这四个值封装成WxMiniAuthenticationToken,交给AuthenticationManager认证管理器认证
image.png

这时候又到了我们熟悉的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 方法
}

image.png

接下去,在自定义微信登陆认证器返回该UserDetails,重新创建一个新的已认证过的Authentication对象
这时候我们已经得到了新的认证过的Authentication对象,接着记录登录信息


image.png

image.png

最后是生成token,利用JWT生成token的同时,将用户登录信息保存在缓存中


image.png

然后将token返回,这样一个登录流程就完成啦~

你可能感兴趣的:(Spring Security09 自定义认证器实现微信小程序一键登录)