做后端开发的朋友应该都有过这种经历:手里维护着一套基于 SpringBoot 和 Shiro 的老系统,账号、角色、权限过滤器全跑得好好的,突然产品丢过来一句话,「小程序端要加一个微信登录」。听起来像是加个接口的事,实际上却牵扯到小程序端的 wx.login、微信服务端的 code2Session 换取 openid、Shiro 的 Realm 扩展、自定义 Token、无状态会话适配……一整套链路。如果只是简单把 openid 当成用户名塞进原来的登录逻辑里,很快就会发现各种 session 失效、401、权限拿不到的问题。
这篇文章我会以「SpringBoot 整合 Shiro 实现微信小程序登录」为线索,从整体方案设计讲起,把最核心的三处改造——自定义 OpenIdToken、微信 Realm、Shiro 无状态过滤链——逐个拆开,再补上小程序端的调用姿势和几个高频坑。适合正在改造老项目的人,也适合准备在小程序里重新搭认证体系的同学。整个方案可以直接当脚手架用,代码我尽量贴出关键片段,但也要提醒一句:不同项目的基础设施不一样,工具类、缓存组件、用户表结构都会有差异,需要根据自身情况微调。
1. 整体方案设计与登录链路拆解
1.1 为什么要在现有 Shiro 体系里接微信登录
Shiro 这类框架最大的价值,不是「能用」,而是它把认证、授权、会话管理抽象成了固定流程。老项目里通常已经有了一套基于用户名密码的认证:登录接口收账号密码,封装成 UsernamePasswordToken,交给 SecurityManager,再由配置好的 Realm 去查数据库比对密码。这个流程很稳,用户、权限的代码也都沉淀下来了。微信小程序登录的真正难点,在于怎么把「微信账号」映射到这套已有流程里。
很多人会纠结要不要干脆换成 Spring Security,或者直接用自己手写拦截器。我个人的建议是:只要老系统里 Shiro 的权限体系还在稳定运转,就不要为了一个登录入口把地基掀了。Shiro 的扩展设计很适合这种增量改造:你可以定义新的 Token 类型,可以新增 Realm,可以在过滤链上挂新的过滤器,原有代码基本不用动。微信登录本质上是「换取一个身份标识,然后拿着这个标识在系统里找到用户并完成认证」,天然可以映射到 Shiro 的 authenticate 流程里。
还有一点要考虑:小程序端不是浏览器,它的会话模型和老系统很不一样。老项目里用户登录成功后,Shiro 会在服务端生成 Session,通过 Cookie 里的 JSESSIONID 去维持会话。小程序环境虽然也能带 Cookie,但能力不完整,跨端适配也很别扭。更常见的做法是改成无状态模式:登录成功后由后端签发一个自定义 token 返回给小程序,小程序每次请求都把它放在请求头里带回来,Shiro 过滤器从请求头里取 token 完成每次认证。这个改动的影响范围要比想象中大,后面第 4 章会专门讲。
1.2 一次微信小程序登录要走的四段链路
把整个登录过程铺开看,其实特别清晰,一共四步:
| 环节 | 参与者 | 核心内容 | 产物 |
|---|---|---|---|
| 第一步 | 小程序客户端 | 调用 wx.login 获取临时凭证 | code |
| 第二步 | 后端服务 | 拿 code 请求微信服务端的 code2Session 接口 | openid、session_key |
| 第三步 | 后端服务 | 按 openid 查用户,不存在则自动注册 | 本地用户记录 |
| 第四步 | 后端服务 | 签发业务 token,返回给小程序 | token |
第一步里 wx.login 拿到的是临时凭证,官方叫 code,有效期很短,通常五分钟,而且只能用一次。这个 code 承载的唯一作用,就是让后端拿去换用户身份。这里有一个新手最容易犯的错误:以为 code 可以存起来复用,或者提前在小程序端缓存好,等要做某个操作时再拿给后端。code 是一次性的,后端每次收到 code,只要调用过 code2Session,这个 code 就失效了,再调一次就会报错。所以正确做法是:每次登录流程都重新触发 wx.login,拿到一个新 code 再传给后端。
第二步是整个方案的核心,后端拿着 code 去请求微信服务端的 jscode2session 接口,参数主要包括小程序 appid、secret、js_code 和固定值 grant_type。微信服务端校验通过后,会返回 openid 和 session_key。这里要记住一个特性:同一个微信用户在不同的小程序下面,得到的 openid 是不同的;但在同一个小程序里,openid 是唯一且固定的。也就是说,openid 可以作为你系统里的用户唯一标识来用。如果同一个微信用户还要登录公众号、网页、APP 等不同端,才需要考虑 unionid 这套更复杂的映射,单做小程序登录时可以先不碰。
第三步就是按 openid 去用户表里查记录。查得到,直接走认证;查不到,就把这个 openid 当作新用户自动注册。这也是微信生态登录的一个天然优势:用户不需要填用户名密码,首次进入就等于注册完成。但要注意,自动注册不等于不需要补齐用户资料,头像昵称这些信息通常要另外通过小程序端的用户信息组件获取,再调接口补全。
第四步是签发 token 给小程序。token 怎么生成、怎么存储,项目里可以自由设计:简单点,一个 UUID 塞进 Redis 并绑定 openid;复杂点,用 JWT 把 openid 直接写进载荷里。签发给小程序之后,后面所有请求都要在 header 里带上这个 token,后端过滤器据此识别用户身份。把 token 生成逻辑放在微信登录接口里,而不是塞进 Shiro 的认证流程里,能让职责更清晰:Shiro 负责认证,token 只是认证通过的凭证。
2. 登录凭证的改造:从用户名密码到 OpenIdToken
2.1 理解 Shiro 的 Token 机制
Shiro 的认证过程,核心围绕一小套接口展开:AuthenticationToken 定义了一组身份凭证,SecurityManager 接收它,Realm 通过 supports 判断自己能不能处理这类 Token,能处理则进入 doGetAuthenticationInfo 去查用户、校验凭证。
常规登录用的 UsernamePasswordToken,主身份 Principal 是用户名,凭证 Credentials 是密码。密码是保密的,需要比对、需要加密,这是它的核心特征。但微信 openid 不一样。openid 本身就是唯一标识,它更像「用户名」,不过这个「用户名」不能由用户自定义,是从微信服务端换回来的。如果把 openid 直接塞进 UsernamePasswordToken 里,又随便给个 password,就破坏了原来密码校验的语义,Realm 里的逻辑会写得别扭,以后维护的人也会很困惑。
所以正确的做法是自定义一个 OpenIdToken,专门给微信登录链路用。这不是炫技,而是让整个认证过程语义清晰:看到 OpenIdToken,就知道这是微信登录;看到 UsernamePasswordToken,就知道是账密登录。如果后面还要接入手机号登录、扫码登录,同样的套路再扩展新 Token 和 Realm 就行。
2.2 自定义 OpenIdToken 的实现要点
OpenIdToken 要实现 AuthenticationToken 接口,主要就是实现 getPrincipal 和 getCredentials 两个方法。逻辑上,principal 可以返回 openid,credentials 可以返回业务 token,也可以直接返回 openid 占位。
下面是一段基础实现:
package com.example.framework.shiro.token; import org.apache.shiro.authc.AuthenticationToken; public class OpenIdToken implements AuthenticationToken { private String openid; private String sessionKey; private String authToken; public OpenIdToken(String openid, String sessionKey, String authToken) { this.openid = openid; this.sessionKey = sessionKey; this.authToken = authToken; } @Override public Object getPrincipal() { return this.openid; } @Override public Object getCredentials() { return this.authToken == null ? this.openid : this.authToken; } public String getOpenid() { return openid; } public String getSessionKey() { return sessionKey; } public String getAuthToken() { return authToken; } }这里的 authToken 指的是登录成功后签发的业务 token。为什么把 authToken 也放进 OpenIdToken?因为无状态方案里,每次请求过滤器都会根据请求头里的 authToken 查对应 openid,再构造 OpenIdToken 去执行 login。Realm 拿到 credentials 时可以校验这个 authToken 是否有效、是否过期,等于在认证阶段又做了一层加固。
sessionKey 这个字段,大多数登录场景用不到,但如果你后续要做手机号解密、小程序数据解密,sessionKey 就必须存下来,所以这里先预留。注意一点:session_key 属于敏感信息,不要打到日志里,也不要轻易返回给前端,存储时如果数据库字段没有加密条件,至少不要露出完整明文。
2.3 微信专属 Realm:自动注册与用户查询
Realm 是 Shiro 里最容易被写乱的环节。很多项目一个 Realm 又查数据库又比对密码又加载权限,最后改都不敢改。微信登录场景建议拆一个独立 Realm,比如 WxCodeRealm,只管 openid 对应的用户查询和状态校验,权限加载可以复用原来那个 Realm 的逻辑,也可以在 doGetAuthorizationInfo 里手动串。
下面是一个比较干净的实现:
package com.example.framework.shiro.realm; import com.example.framework.shiro.token.OpenIdToken; import com.example.user.entity.User; import com.example.user.service.UserService; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; public class WxCodeRealm extends AuthorizingRealm { private UserService userService; public WxCodeRealm(UserService userService) { this.userService = userService; } @Override public boolean supports(AuthenticationToken token) { return token instanceof OpenIdToken; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 这里按需加载角色、权限 // 如果老项目的权限查询逻辑已经在其他 Realm 里,可以在这里调用同一套 service return new SimpleAuthorizationInfo(); } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { OpenIdToken openIdToken = (OpenIdToken) token; String openid = String.valueOf(openIdToken.getPrincipal()); User user = userService.selectUserByOpenid(openid); if (user == null) { user = userService.registerByOpenid(openid); if (user == null) { throw new AuthenticationException("用户注册失败,请稍后重试"); } } return new SimpleAuthenticationInfo(user, openIdToken.getCredentials(), getName()); } }supports 方法一定不要忘记重写。Shiro 拿到 Token 后,会遍历 SecurityManager 里注册的 Realm,用 supports 判断这个 Realm 是否处理当前 Token。如果忘了重写,默认 Supports 逻辑对 OpenIdToken 通常返回 false,登录时会直接抛 UnsupportedTokenException。
doGetAuthenticationInfo 里做了「查用户 + 自动注册」两件事。registerByOpenid 内部要保证幂等:两个请求同时首次登录,不能让同一 openid 生成两条记录,数据库唯一索引是最兜底的防线。新用户注册成功后,再往 SimpleAuthenticationInfo 里塞同一个 User 对象,后续业务代码里用 SecurityUtils.getSubject().getPrincipal() 就能直接拿到用户。
还有一个小细节:SimpleAuthenticationInfo 的第一个参数我传的是 user 对象,而不是 openid 字符串。这样后面在 Controller 里取当前用户非常方便,不用查库。如果你传的是字符串,取的时候就要再查一次数据库,多一次 IO 不说,还容易因为 DB 数据变化导致身份的临时状态不一致。
3. 微信服务端请求封装与用户表设计
3.1 code2Session 参数与响应字段
code2Session 是打通小程序和后端的关键一环。后端拿到前端传来的 code 后,要主动向微信服务端发起一次 HTTP 请求。请求参数需要四样:
| 参数 | 含义 | 备注 |
|---|---|---|
| appid | 小程序的 AppID | 每个小程序唯一,不要和公众号 AppID 混淆 |
| secret | 小程序的 AppSecret | 高度敏感,只能存在服务端 |
| js_code | 前端 wx.login 返回的 code | 临时、一次性、有效期五分钟 |
| grant_type | 固定值 | 按规范填 authorization_code |
微信服务端响应一般是 JSON 格式,包含 openid、session_key,以及出错时的 errcode、errmsg。如果项目同时挂在小程序开放平台下,响应里还可能出现 unionid,用于多端用户打通。但 unionid 必须是小程序绑定某个开放平台账号后才会返回,不是所有用户都能拿到,所以业务逻辑不能强依赖它。
正常情况下,openid 和 session_key 会同时返回。openid 用于确认用户身份,这是我们登录要的核心数据。session_key 则是小程序会话密钥,主要用于解密手机号等敏感数据,平时可以不管,但拿到后建议由后端按用户维度管理,而不是直接暴露给前端。
3.2 请求封装实现:一段可靠的 RestTemplate 写法
老项目里一般已经有 HTTP 工具,可能是 HttpClient,可能是 OKHttp,也可能是 Spring 的 RestTemplate。我这里用 RestTemplate 写一个版本,逻辑重点放在错误处理和返回结构定义上。
package com.example.wechat.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @Service public class WechatAuthService { private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; public WechatAuthService(RestTemplate restTemplate, ObjectMapper objectMapper) { this.restTemplate = restTemplate; this.objectMapper = objectMapper; } public WxSession code2Session(String appid, String secret, String code) { String url = CODE2SESSION_URL + "?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; String response = restTemplate.getForObject(url, String.class); if (response == null || response.isEmpty()) { throw new RuntimeException("微信服务端返回为空"); } try { JsonNode json = objectMapper.readTree(response); if (json.has("errcode") && json.get("errcode").asInt() != 0) { throw new RuntimeException("code2Session 失败, errcode=" + json.get("errcode").asInt() + ", errmsg=" + json.get("errmsg").asText()); } WxSession session = new WxSession(); session.setOpenid(json.get("openid").asText()); session.setSessionKey(json.get("session_key").asText()); if (json.has("unionid")) { session.setUnionid(json.get("unionid").asText()); } return session; } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException("解析微信响应失败", e); } } }这里有几个实用细节:用 String 接收响应而不是直接映射 JsonNode,是因为微信服务端在部分错误场景下返回的 JSON 结构不太友好,先拿 String 再统一解析,调试时也方便直接把整个返回体打出来。错误码判断不能省,尤其是 40029(code 无效)、40163(code 已被使用)、45011(接口调用频率限制),这些错误码有各自对应的排查方向,后面第 6 章细说。
appid 和 secret 建议放到配置中心或配置文件里,不要写在代码常量里。尤其 secret,只允许后端使用,绝不能出现在前端任何地方。有些同学为了省事把 secret 放在请求参数里让前端传,这是非常危险的,等于把密钥直接给了用户,别人可以拿你的 secret 去冒充你的后端调微信接口。
3.3 用户表结构与首次登录注册逻辑
用户表是这套登录方案的地基。最简单的微信登录,只需要一个字段跟 openid 对应。但实际项目里建议直接预留 unionid、昵称、头像等字段,省得以后要扩展时再改表。
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `openid` varchar(64) NOT NULL COMMENT '微信小程序 openid', `unionid` varchar(64) DEFAULT NULL COMMENT '开放平台 unionid', `nickname` varchar(64) DEFAULT NULL COMMENT '昵称', `avatar` varchar(500) DEFAULT NULL COMMENT '头像地址', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';openid 字段一定要加唯一索引。原因很简单:首次登录注册时,理论上同一 openid 只会走一次注册逻辑,但并发场景下没有任何东西能保证两个请求不会同时查到「用户不存在」,然后同时执行 insert。数据库唯一索引是最后一道保险,能让第二个 insert 直接报 DuplicateKeyException,再由 registerByOpenid 方法捕获异常后重新查询一次返回已有用户。
registerByOpenid 的实现大概长这样:
@Transactional(rollbackFor = Exception.class) public User registerByOpenid(String openid) { User user = new User(); user.setOpenid(openid); // 可以在这里给新用户设置默认昵称、默认头像 user.setNickname("微信用户"); try { userMapper.insert(user); return user; } catch (DuplicateKeyException e) { // 并发插入时另一个请求已经注册过了,直接查出来返回 return userMapper.selectByOpenid(openid); } }事务注解加上是为了防止插入用户记录后,后续又更新用户其他信息时出现部分成功的情况。用户表虽然简单,但后续可能会加积分、加会员、加绑定手机号,这些都是和用户记录强关联的写操作,统一包在事务里更安心。
如果项目原本已经有了一套账号体系,比如用户本来是用手机号注册的,这时候再接入微信登录,就不能只在 user 表里塞 openid 了。更合理的做法是加一张用户绑定表,记录 user_id 和 openid、平台类型、绑定时间。这样同一个账号可以在手机号和微信之间切换,也能支持解绑、换绑。一次性把表设计好,比后面被产品要求做「微信解绑再绑定手机号」再去改表要省心得多。
4. Shiro 无状态会话配置与过滤链梳理
4.1 为什么小程序场景不适合传统 Session
老项目里 Shiro 默认走的是「登录成功 → 创建 Session → 通过 Cookie 管理会话」这条路。浏览器环境下这套机制很成熟,但小程序端有它的特殊性:小程序不是浏览器,虽然理论上支持设置 Cookie,但不同基础库版本、不同手机系统上的表现并不一致,而且小程序里处理 Cookie 的 API 很弱,开发者基本只能把它当成一个普通 key-value 来手动维护。与其依赖这种不稳定的机制,不如直接做无状态认证。
无状态认证的思路是:登录成功后后端生成一个业务 token,返回给小程序;小程序把 token 存在本地,比如 wx.setStorageSync;之后每个请求都在 header 的 Authorization 字段里带上这个 token;后端在过滤器里解析 token,识别出对应的用户,再走 Shiro 的 Subject.login 完成认证。
这对老系统的影响范围需要提前评估清楚。原来很多地方会从 SecurityUtils.getSubject().getSession() 里取数据,比如存用户 ID、存验证码、存某个临时状态。改造成无状态后,session 里这些数据都不能依赖了,要么放到 Redis 自己做,要么放到 token 载荷里。另外一个影响是:在线用户统计、踢人下线这些功能,原本依赖 Session Manager 去遍历在线会话,改无状态之后这些逻辑也要基于 Redis 的 token 记录重新实现。
4.2 自定义过滤器 WxAuthcFilter 的设计
无状态方案里,过滤器是核心入口。Shiro 默认的 authc 过滤器是基于 Session 判断是否登录的,不适合这个场景,我们需要写一个自定义过滤器,直接从请求头里取 token,完成解析和认证。继承 BasicHttpAuthenticationFilter 是比较省力的做法,它已经封装了「从 header 取鉴权值」的抽象,我们只需要覆盖 onAccessDenied。
先看代码:
package com.example.framework.shiro.filter; import com.example.framework.shiro.token.OpenIdToken; import com.example.auth.service.TokenService; import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; public class WxAuthcFilter extends BasicHttpAuthenticationFilter { private TokenService tokenService; public WxAuthcFilter(TokenService tokenService) { this.tokenService = tokenService; } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { return false; } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; String authToken = httpRequest.getHeader("Authorization"); if (authToken == null || authToken.isEmpty()) { return unauthorized(response); } String openid = tokenService.getOpenidByToken(authToken); if (openid == null) { return unauthorized(response); } OpenIdToken openIdToken = new OpenIdToken(openid, null, authToken); try { getSubject(request, response).login(openIdToken); return true; } catch (Exception e) { return unauthorized(response); } } private boolean unauthorized(ServletResponse response) { // 这里直接向客户端返回 401 JSON,而不是跳转登录页 // 具体写法依赖项目里的响应工具类,也可以直接打印 JSON 字符串 return false; } }isAccessAllowed 直接返回 false,目的是让每个请求都进 onAccessDenied 里走统一的 token 校验。校验通过后再调 subject.login。有人可能会问:filter 里已经验证了 token 能查到 openid,为什么还要 login 一次?因为 Shiro 的 SecurityManager 需要在当前线程上绑定认证状态,后续在 Service、Controller 里通过 SecurityUtils.getSubject().getPrincipal() 才能拿到用户。如果不调 login,整个 Shiro 上下文中就没有用户的身份信息,授权注解也黯然失色。
tokenService 是访问 Redis 的封装。登录成功时,后端会往 Redis 写一条 authToken -> openid 的记录,并设置过期时间。过滤器每收到一次请求,都先从 Redis 拿 openid,这就意味着一次 Redis 读操作。这个开销几乎可以忽略,但要注意 Redis 里 key 的设置一定要加业务前缀,比如 wx:auth:token:{tokenId},防止跟其他模块的 key 撞车。
4.3 ShiroConfig 装配细节和影响范围分析
有了 Realm 和过滤器,最后一步是把它们装配到 Shiro 的安全链路里。这里有一个常见误区:很多人把一个自定义过滤器写在 Spring 容器里,以为就生效了,其实它必须被塞进 ShiroFilterFactoryBean 的 filters 里,并且过滤链上指定使用这个过滤器,Shiro 才会真正走它的逻辑。
下面是一个最精简的配置:
package com.example.framework.config; import com.example.auth.service.TokenService; import com.example.framework.shiro.filter.WxAuthcFilter; import com.example.framework.shiro.realm.WxCodeRealm; import com.example.user.service.UserService; import org.apache.shiro.mgt.DefaultSecurityManager; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.servlet.Filter; import java.util.LinkedHashMap; import java.util.Map; @Configuration public class ShiroConfig { @Bean public WxCodeRealm wxCodeRealm(UserService userService) { return new WxCodeRealm(userService); } @Bean public DefaultWebSecurityManager securityManager(WxCodeRealm wxCodeRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(wxCodeRealm); return securityManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean( DefaultWebSecurityManager securityManager, TokenService tokenService) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 注册自定义过滤器 Map<String, Filter> filters = new LinkedHashMap<>(); filters.put("wxauthc", new WxAuthcFilter(tokenService)); factoryBean.setFilters(filters); // 过滤链定义:顺序很重要,/** 必须放最后 Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/api/auth/login", "anon"); filterChainMap.put("/api/unauthorized", "anon"); filterChainMap.put("/**", "wxauthc"); factoryBean.setFilterChainDefinitionMap(filterChainMap); return factoryBean; } }这段配置有几个关键点。
过滤链顺序是重中之重。Shiro 按 LinkedHashMap 的插入顺序匹配,先命中的规则生效。/api/auth/login 作为登录接口必须 anon 放行,否则小程序端连登录接口都访问不了,直接 401。/** 放在最后,代表除了前面特批的路径,其他所有请求都要经过 wxauthc 过滤器。
登录接口放行了,接下来要保证它内部能拿到 SecurityManager,主动执行 subject.login。这一步不需要经过过滤器,因为登录接口本身就是「获取身份」的入口,而不是「验证身份」的入口。小程序端第一次调 /api/auth/login 时,前端传的是 code,这个请求当然不能带 Authorization header,如果过滤器把它也拦下来,就永远无法完成登录了。
setLoginUrl 和 setUnauthorizedUrl 这两个配置我没有写在代码里,因为无状态鉴权下,Shiro 默认的跳页行为反而会干扰 JSON 接口。如果老项目这两个配置已经存在,建议在过滤器返回 401 时直接写 JSON 响应,不要依赖 Shiro 的默认跳转。前端拿到 401 后统一跳到登录页或者刷新登录态,才是小程序里常见的交互。
影响范围方面,主要提醒三处:第一,如果原来过滤链里配置了基于 Session 的 authc 过滤器,那就是要替换掉的,否则部分路径会继续走 session 逻辑;第二,默认 securityManager 的 sessionManager,在无状态场景下不需要特殊配置,但也不能依赖它来维持登录态;第三,如果项目同时存在多个 Realm,比如一个账密 Realm、一个微信 Realm,DefaultWebSecurityManager 需要改为设置 realm 集合,用 ModularRealmAuthenticator 管理,这时候每个 Realm 的 supports 逻辑就更加关键,否则 Token 会被错误的 Realm 处理。
5. 小程序端登录实现步骤
5.1 wx.login 获取 code 与界面交互
小程序端的代码不复杂,但细节也很容易出错。wx.login 这个 API 不需要用户授权,不会弹窗,也不会触发任何隐私确认。它做的是从微信那里拿一个临时凭证,所以页面上只要有「登录」按钮,或者甚至进入小程序时静默触发都可以。
wx.login({ success(res) { if (res.code) { // 把 code 发送给后端 wx.request({ url: 'https://your-domain.com/api/auth/login', method: 'POST', data: { code: res.code }, success(loginRes) { const token = loginRes.data.token; wx.setStorageSync('token', token); // 登录成功后的页面跳转或数据刷新 } }); } else { console.log('登录失败', res.errMsg); } } });要纠正一个容易踩的误区:wx.login 不需要和用户头像昵称绑定。很多团队的「微信登录」设计都是先弹授权框拿用户信息,再调 wx.login,最后把头像昵称和 code 一起发给后端。现在小程序的用户信息授权逻辑已经调整,头像昵称可以通过官方提供的头像昵称填写能力获取,也可以在登录成功后让用户自己完善资料。总之,微信登录的核心认证只需要 code,头像昵称是另一条线,不要混在一起。
code 拿到之后,要尽快发到后端。如果点击登录后用户长时间停留在当前页,code 过期了再提交,后端调 code2Session 就会报错。实际场景中,如果页面逻辑复杂、中间有二次确认等流程,建议在真正调后端接口前重新 wx.login 一次,不要拿着很久之前生成好的 code 去请求。
5.2 登录请求与 token 保存
上一个示例里已经包含了一次完整的登录请求,这里再补充一点关于 token 的处理习惯。微信小程序本地存储是异步的,所以在真正使用 token 前,一定要确定写入成功。我用 wx.setStorageSync 是因为它简单,但要注意它可能抛出异常,建议在 try/catch 里处理,或者统一封装 storage 模块。
token 保存之后,并不是万事大吉。后端给 token 时通常会附带有效期,一般一个小时或一天。小程序端自己也要考虑 token 过期的情况:请求接口如果返回 401,需要有一个统一的处理逻辑,比如自动触发一次静默登录(重新 wx.login 并更换 token),而不是直接把用户踢回登录页。对用户来说,静默续期体验比强制重新登录好得多。
5.3 请求拦截器统一携带鉴权头
小程序端没有 axios 那样的拦截器概念,但可以在自己封装的 request 函数里统一处理。下面是一个最常见的封装:
function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success(res) { if (res.statusCode === 401) { // token 失效,重新登录或跳转登录页 return reject(res); } resolve(res.data); }, fail(err) { reject(err); } }); }); }在 header 里设置的 Authorization 字段名必须和后端过滤器取的名字一致。后端代码里写的是httpRequest.getHeader("Authorization"),那前端 header 里的 key 就是Authorization,大小写保持一致。有人习惯用token、access-token、X-Token,都行,但前后端必须约定好,且建议统一一个规范,不要在有的接口用 Authorization,有的接口用 token。
这里还要提一个容易忽略的点:有些项目在前端封装 request 时,习惯对 401 做统一处理,但处理逻辑里又在 401 时重新调用 wx.login。如果后端登录接口本身返回 401(比如 code 无效或 appid 配置错误),那前端再触发 wx.login 就陷入了死循环。401 处理里要区分是「登录态过期」还是「登录请求本身失败」,这两类的处理逻辑完全不一样。
6. 常见问题与排查技巧实录
6.1 code2Session 调用失败的几个典型案例
这部分大概率是大家在真实项目里花时间最多的地方。我按错误码和经验场景整理了一张速查表:
| 错误码 / 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 40029 code 无效 | code 已过期、已被使用、传参错误 | 重新触发 wx.login,确保拿到新 code 后立即请求 |
| 40163 code 已被使用 | 同一个 code 被调用了两次 | 排查后端是否重复调用 code2Session,检查日志 |
| 45011 接口频率限制 | 每分钟调用量过大 | 增加本地缓存或熔断,检查是否有恶意调用或者循环刷登录 |
| invalid appid | 前端传入的 appid 不属于当前小程序 | 检查配置,注意区分小程序 appid 与开放平台、公众号 appid |
| errmsg 提示 secret 错误 | secret 配置错误或已重置 | 登录微信公众平台后台重新查看,注意发布后不要随意重置 |
排查这类问题的通用套路比较简单:第一步,在 code2Session 调用处把完整请求 URL、响应体打印出来,先确定是网络问题还是微信返回的业务错误;第二步,如果微信返回业务错误,按错误码表找方向;第三步,如果是偶发报错,重点检查业务里是否「拿同一个 code 调了两次」。我见过不少团队,后端为了「增强健壮性」,对 code2Session 做了失败重试,结果同一个 code 因为第一次请求超时其实是成功的,重试时反而报 40163。对于一次性凭证,重试逻辑要格外谨慎,或者至少保证重试不会造成更大的副作用。
另外,本地开发环境调用微信服务端接口通常没问题,不需要像调用某些国内接口那样专门配代理,只要确保服务器能正常访问外网即可。如果公司内网有限制,需要提前申请对微信服务端域名的放行权限。
6.2 Shiro 认证链路登录失败的常见原因
小程序端能正常登录、后端能正常查到用户,但受保护的接口一直 401,问题大概率出在 Shiro 配置而不是微信登录本身。下面几个坑我基本每次做类似项目都会遇到。
第一个是 Realm 的 supports 方法没写对。OpenIdToken 自定义好了,过滤器也调了 subject.login,但 Realm 没有正确处理这个 Token,Shiro 会直接抛 UnsupportedTokenException。检查时优先看 WxCodeRealm 的 supports 有没有被重写,以及 OpenIdToken 的 class 对象是否和上下文一致,尤其是项目存在多个 classloader 时,instanceof 可能会意外走不到。
第二个是过滤器没有注册进 ShiroFilterFactoryBean。如果过滤链是/** = wxauthc,但全局 filters 里没加 wxauthc,启动时 Shiro 会直接因为找不到过滤器名报错。如果过滤链还沿用默认的/** = authc,那前端每次携带 token 也没用,因为默认 authc 过滤器根本不读 Authorization 头。查这类问题最快的方式,就是看启动日志里 Shiro 打印了哪些过滤器。
第三个是前端请求头字段名和后端不一致。这个很低级,但很常见。前端 header 里写了authorization(小写),后端读取的是Authorization(大写),有些 HTTP 库默认会把 header 名规范化,但如果你用的是自定义 header,务必确保两端一致。
第四个是未授权响应被 Shiro 默认的跳转逻辑拦截。shiroFilterFactoryBean 可以设置 loginUrl,未认证时会重定向到那个地址,但小程序端不是一个浏览器,接收不到重定向结果,只会在调试器里看到「redirected too many times」或者一个奇怪的 HTML 错误页。自定义过滤器里如果返回 false,最好直接在这个方法内写入 JSON 格式的 401 响应,而不是依赖 Shiro 框架默认行为。
第五个是 Redis 里 token 的序列化问题。如果你把 openid 存成了某个自定义对象,读取时可能出现类型转换异常。最省事的方案是直接存字符串 openid,所有对象统一转 JSON 或直接使用 string 序列化器。别小看这一步,生产环境里因为序列化器配置不一致导致认证偶发失败的情况,排查起来非常痛苦。
6.3 上线后容易踩的稳定性与安全问题
登录方案上线只是开始,真正考验细节的是后续的稳定性。
token 过期和续期是第一个要设计的点。我的做法是 token 有效期设置得短一些,比如两个小时,但 Redis 里额外存一个刷新周期。前端每次请求都带 token,后端可以在剩余有效期低于某个阈值时,重新生成新 token 并放到响应头里告诉前端。这样用户无感知地续期,体验比到期突然踢下线好很多。如果项目规模不大,用「每次请求都续期两小时」这种更粗暴的方案也行,缺点是活跃用户永远不会过期,安全上稍弱,但实现成本很低,看团队取舍。
多设备登录也需要提前想。一个 openid 可以对应多个 token,同一用户在不同手机登录时,应该允许并存,还是顶掉之前的会话?这取决于产品需求。技术上,如果 token 存 Redis 时 key 是 tokenId -> openid,天然支持多设备;如果想做强制单端登录,就需要用 openid -> tokenId 的反向索引,新登录时删除旧 token。
还有一个安全性细节必须提:session_key 不要随接口响应返回给前端,也不要打日志。小程序很多敏感数据的加密都用 session_key 做 key,一旦泄露,再结合截获的数据包,用户隐私就有风险。后端在完成 code2Session 后,session_key 按用户维度入库或者单独缓存即可,前端完全不需要知道它。
再一个权限实时性问题。Shiro 默认会把 AuthorizationInfo 缓存起来,用户角色、权限变更后,如果缓存不清,授权信息不会立即生效。微信登录自动注册的新用户,一开始大概率是普通角色,但后续可能被管理员提升权限;如果授权缓存没有清理策略,你会发现用户在权限升级后仍然访问不了新功能。解决方式不难,要么在修改权限时主动调用 realm 的 clearCache 方法,要么给授权缓存设置较短的过期时间。
最后再分享一点实践经验
这套方案我在好几个项目里落地过,最大的体会是:登录流程本身不难,难的是把它和老系统的认证体系融合得自然。每改一个环节,都要问自己一句:原来依赖 Session 的代码还能不能用?原来过滤链的顺序会不会影响新链路?原来那种「从头再写一个登录模块」的心态,恰恰是最容易埋坑的。
如果你要从零开始做新项目,我个人建议直接把 token 换成 JWT 风格,省掉 Redis 查询这一步,过滤器里解析签名即可拿到 openid。但如果在老项目上改造,先沿用 Redis 管理 token,改动面更小,也更稳妥。等这一版运行稳定了,再考虑是不是要把 token 升级成更轻量的方案,到时候过滤器内部替换一行逻辑就能完成切换。
做这类整合,慢一点不是坏事。先把 wx.login 到 code2Session 的链路打通,再动 Shiro 的过滤器,最后再补授权信息,按这个顺序来,排查成本会低很多。