微信小程序 + shiro 实现登录(安全管理) —— 保姆级教学


最近,我开发了一个微信小程序,后台使用 Spring 搭建,安全管理交给了 shiro。初次将二者结合开发,经验不足,不知如何把二者结合起来。在网上搜寻了一阵,查到的资料有限——给的不全,要么是代码臃肿不规范。因此,本博主决定此时抽出半日的空闲为后来者写上一篇完整的 微信小程序 + shiro 如何开发 的文章。

一、开发流程

首先,我们先看一下,微信官方推荐的登录流程,后序的开发就是按这个来的。
微信小程序登录流程

1.1 难点及其解决办法

仔细看这张图会发现,我们的难处有两点:

  1. 微信小程序不是 web 浏览器。它不会为我们自主的储存 shiro 的 JSessionId。需要我们自己储存
  2. 就算每次请求我们都能在 header 中带上 JSessionId ,我们服务器上的 shiro 也不能识别。需要我们对 request 做些修改

1.1.1 解决办法

1.1.1.1 针对第一点:

先简述下原理:

http 协议是无状态协议。我们并不能通过每次的请求知晓操作者是谁。为了解决这个问题,提出了 cookie 的概念,解决这个问题。
客户与服务器之间通过 sessionId 进行交流。

当然这也是 shiro 的原理。shiro 就是通过 request header 中的 JSessionId 识别用户的。

在调用我们的服务器根据用户的 openid 确认到给用户后,shiro 此时也完成了用户的登录,并把用户信息保存起来了,最后,controller 返回处理结果。(具体代码见下方)
我们在微信小程序端要获取到 JSeesionId ,并保存:
(下方代码在 app.js 的 onLounch() 方法里)

let JSessionId = response.header["Set-Cookie"].toString().split(';')[0].substring(11);
 wx.setStorageSync("JSessionId", JSessionId);
1.1.1.2 针对第二点:

在需要用户登录的地方,我们只要注意两点:

  1. wx.request() 向有登录要求的 URL 发送请求时,在 header 中带上该字段,代码如下:
wx.request({
	url: URL,
	 header: {
	   "Content-Type": "application/x-www-form-urlencoded",
	   "JSessionId": wx.getStorageSync('JSessionId')
	 },
	 data: {},
	 success(response) {}
)};
  1. 自定义 sessionManager,并将其注入到 shiro 的 securityManager
  • 自定义 sessionManager
public class WeChatSessionManager extends DefaultWebSessionManager {

    public final static String HEADER_TOKEN_NAME = "JSessionId";
    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";

    /**
     * 逻辑:
     *     如果请求头中有 JSessionId,就分析它;
     *     没有就调用父类的方法
     */
    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response){
        String JSessionId = WebUtils.toHttp(request).getHeader(HEADER_TOKEN_NAME);

        if(JSessionId == null) {
            return super.getSessionId(request, response);
        } else {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, JSessionId);
            log.info("JSessionId: {}", JSessionId);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return JSessionId;
        }
    }
}
  • sessionManager 注入到 shiro 的 securityManager
    当然,你也可以根据自己的需求,对 sessionManager 做些自己的定义
@Configuration
public class ShiroConfig {
    /**
     * 其它代码
     * /

    @Bean
    public WeChatSessionManager sessionManager() {
        WeChatSessionManager weChatSessionManager = new WeChatSessionManager();
        return weChatSessionManager;
    }

    @Bean
    public SecurityManager securityManager(@Qualifier("realm") Realm realm,
                           @Qualifier("sessionManager") SessionManager sessionManager) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(realm);
        securityManager.setSessionManager(sessionManager);
        return securityManager;
    }
    
    /**
     * 其它代码
     * /
}

二、代码

2.1 小程序

2.1.1 app.js

//app.js
App({
  globalData: {
    timeout: 30000,
    localhost: "http://localhost:8080/miniprogram",
    login: false,
  },


  /**
   * 小程序初始化
   */
  onLaunch: function () {
    let p = this;
    wx.login({
      success(res) {
        if (res.code) {
          p.login(res.code);
        }
      },
      fail: () => wx.showModal({
        content: "获取 code 失败",
        showCancel: false
      })
    });
  },

  login(code) {
    let p = this;

    wx.request({
      url: p.globalData.localhost + "/noLogin/login",
      method: "POST",
      header: {"Content-Type": "application/x-www-form-urlencoded"},
      data: {code: code},
      success(response) {
        switch(response.data["data"]) {                  
          case "unregistered":
            wx.showModal({
              content: "您未注册,是否前往注册"
            });
            break;
          case "registered": 
            p.globalData.login = true;
            let JSessionId = response.header["Set-Cookie"].toString().split(';')[0].substring(11);
            wx.setStorageSync("JSessionId", JSessionId);
            break;
        }
      },
    });        
  }
})

2.2 spring 后端代码

2.2.1 pom.xml

主要是 shiro 的 dependency,其它请根据自己的业务需求自行添加

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.7.0</version>
</dependency>

2.2.2 reaml 的代码

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import top.leeti.entity.User;
import top.leeti.service.UserService;
import top.leeti.util.PasswordUtil;

import javax.annotation.Resource;

@Slf4j
public class MyRealm extends AuthorizingRealm {

    @Resource
    private UserService userService;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
            throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;

        User user = userService.getUserByOpenId(usernamePasswordToken.getUsername());

        if (user == null) {
            throw new AccountException();
        }

        ByteSource saltOfCredential = ByteSource.Util.bytes(user.getStuId());
        return new SimpleAuthenticationInfo(user, String.valueOf(usernamePasswordToken.getPassword()),
                saltOfCredential, getName());
    }
}

2.2.3 sessionManager 代码

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;

/**
 * 自定义session管理器
 * 继承DefaultWebSessionManager,重写getSessionId方法
 */
@Slf4j
public class WeChatSessionManager extends DefaultWebSessionManager {

    public final static String HEADER_TOKEN_NAME = "JSessionId";
    private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";

    /**
     * 逻辑:
     *     如果请求头中有 JSessionId,就分析它;
     *     没有就调用父类的方法
     */
    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response){
        String JSessionId = WebUtils.toHttp(request).getHeader(HEADER_TOKEN_NAME);

        if(JSessionId == null) {
            return super.getSessionId(request, response);
        } else {
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,REFERENCED_SESSION_ID_SOURCE);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, JSessionId);
            log.info("JSessionId: {}", JSessionId);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return JSessionId;
        }
    }

}

2.2.4 ShiroConfig.java

import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.leeti.config.manager.WeChatSessionManager;
import top.leeti.realm.MyRealm;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

    @Bean
    public CredentialsMatcher credentialsMatcher() {
        HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
        matcher.setHashAlgorithmName("MD5");
        matcher.setHashIterations(1024);
        return matcher;
    }

    @Bean
    public Realm realm(@Qualifier("credentialsMatcher") CredentialsMatcher credentialsMatcher) {
        MyRealm realm = new MyRealm();
        realm.setCredentialsMatcher(credentialsMatcher);
        return new MyRealm();
    }

    @Bean
    public WeChatSessionManager sessionManager() {
        WeChatSessionManager weChatSessionManager = new WeChatSessionManager();
        return weChatSessionManager;
    }

    @Bean
    public SecurityManager securityManager(@Qualifier("realm") Realm realm,
                           @Qualifier("sessionManager") SessionManager sessionManager) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(realm);
        securityManager.setSessionManager(sessionManager);
        return securityManager;
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        // 设置默认登录的 url(若登录失败,则转到此)
        shiroFilterFactoryBean.setLoginUrl("/app/noLogin/error");
        // 设置登录认证成功后默认转到的 url
//        shiroFilterFactoryBean.setSuccessUrl("/admin/index");
        // 设置权限认证失败时转到的 url
        shiroFilterFactoryBean.setUnauthorizedUrl("/app/noLogin/noAccess");

        /*
         * anon:匿名用户可访问
         * authc:认证用户可访问
         * user:使用rememberMe可访问
         * perms:对应权限可访问
         * roles[角色名]:对应角色权限可访问
         */
        //设置访问各 url 的权限
        Map<String, String> filterChain = new HashMap<>(5);
        
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChain);

        return shiroFilterFactoryBean;
    }
}

2.2.5 LoginController.java

package top.leeti.controller.nologin;

import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.*;
import top.leeti.entity.result.Result;
import top.leeti.myenum.ResultCodeEnum;
import top.leeti.util.WechatUtil;

import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/miniprogram/noLogin/")
public class LoginController {
    
    @PostMapping("login")
    public String login(@RequestParam String code) {
        Map<String, String> map = WechatUtil.acquireSessionKeyAndOpenId(code);
        String openId = map.get("openId");

        Result<String> result = null;

        if (openId == null) {
            result = new Result<>(null, "获取openId失败", null, false);
        } else {
            UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(openId, "");
            Subject currentUser = SecurityUtils.getSubject();
            try{
                currentUser.login(usernamePasswordToken);
                result = new Result<>(null, null, "registered", true);
            } catch(AccountException accountException) {
                result = new Result<>(null, null, "unregistered", false);
                return JSON.toJSONString(result);
            }
        }

        return JSON.toJSONString(result);
    }
}

2.2.6 WeChatUtil.java

package top.leeti.util;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

import java.util.Hashtable;
import java.util.Map;

@Slf4j
public class WechatUtil {

    private static final String APP_ID = "wxf4eeba633c89a51f";
    private static final String APP_SECRET = "8a7de97a3189c29e3faf87275e3449ee";
    private static final String GRANT_TYPE = "authorization_code";

    /**
     * 像微信服务器发送请求,获取 sessionKey、openId
     * @param code
     *        本次登录请求的小程序传来的标识(保证传来的 code 绝不为空或是空字符串)
     * @return
     *        Map key:sessionKey、openId。如果不能正确地获取到 sessionKey、openId,
     *        返回的 map 中,值为 null。
     */
    public static Map<String, String> acquireSessionKeyAndOpenId(String code){
        RestTemplate restTemplate = new RestTemplate();
        Map<String, String> map = new Hashtable<>();
        try{
            String url = String.format("https://api.weixin.qq.com/sns/jscode2session" +
                    "?appid=%s&secret=%s&js_code=%s&grant_type=%s", APP_ID, APP_SECRET, code, GRANT_TYPE);
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
            ResponseEntity<String> response = restTemplate.getForEntity( url, String.class);
            String[] results = response.getBody().split("\"");
            map.put("sessionKey", results[4]);
            map.put("openId", results[7]);
        } catch (Exception e){
            map.put("sessionKey", null);
            map.put("openId", null);
        }
        return map;
    }
}

三、最后的说明

3.1 用户信息的管理

我这里是把 openid 保存到了数据库。
因为每个小程序的中每个用户的 openid 具有唯一性,我就把它作为判断用户是否注册的凭证

3.2 用户登录成功后,通过 shiro 交互的页面例子我就不举例了。主要是注意 1.1.1.2 第一点内容就行。只要注意到这一点,其它便于浏览器上的 web 开发无异了

  • 2
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值