feat(mp): M3-02 H5 微信网页授权(公众号 OAuth)
方向决定了 H5 优先:资质门槛低得多、且前端可自动化测试。
小程序端代码不浪费 —— uni-app 一套代码多端,build:h5 直接出 H5。
交付:
· GET /mp/h5/authorize —— 生成微信授权地址(公众号 appid,snsapi_base 静默授权)
· GET /mp/h5/callback —— code 换 openid → 找/建学生 → 下发会话 → 302 回 H5
· GET /mp/h5/stub-login —— 【仅开发/测试】跳过微信授权的登录,供自动化测试
· fill.mp-h5 配置段(公众号凭据)
关键事实:H5 必须用**公众号**的 appid —— 网页授权与小程序码是两套凭据、两套接口,
不能混用。所以配置分成 fill.mini-program(小程序码用)与 fill.mp-h5(网页授权用)。
授权流程的三处设计:
1) token 放在 URL 的 fragment(#)里,不放 query。fragment 不发给服务器 ——
不会进 Nginx access log、不会进 Referer、不会进浏览器历史。
放 query 等于把 token 写进四五处日志。
2) scene 不裸放在 state 里,而是存 Redis 用一次性随机串指代 ——
state 会进 URL、浏览器历史与日志,放业务参数容易被人拿来构造请求。
3) 回调必须"永远重定向回应用",绝不抛业务错误。那是浏览器直接跳转的地址,
抛错用户看到的是一屏 {"code":40001,...} JSON —— 一个学生完全无法理解、
也不知道该怎么办的画面。
打桩(为了自动化)与四道约束:
微信授权那一步必须真人在微信里点,脚本永远做不了,所以要有打桩;
而打桩是危险动作 —— 一个"跳过微信授权"的开关漏进生产就是
"任何人可登录任何学生"的后门。约束:
① 配置开关默认 false,只有 dev 打开;
② 代码里再判一次 profile 不含 prod(配置写错也进不来,双保险);
③ 接口路径独立(/mp/h5/stub-login),不是"真实回调里加个 if"——
那种写法一旦判断写反就是灾难;
④ 每次调用都 WARN 日志,不留静默通道。
本轮修掉 2 个真问题:
1) RoleProvisioner.bindRole 在匿名请求下不可用:它内部走 MP 的 BaseMapper,
插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 500 且角色没建上
(用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。
已改为自己写:@InterceptorIgnore + 显式 tenant_id。
2) 授权回调抛业务错误 → 用户看到一屏 JSON。已改成 try/catch 后重定向回应用,
把 e.getMessage() 作为 authError 带上(那句话本来就是写给人看的)。
E2E 实测:用户看到的是「网页授权换 openid失败:code 无效或已使用过
(code 只能用一次,且 5 分钟过期)(微信原文:40029 invalid code...)」。
⚠️ 上线前必须解决:H5 与小程序是**两个 openid**
同一个人的小程序 openid 与公众号 openid 是不同的(不同 appid 各自签发),
只有 union_id 一致 —— 而它需要两个账号都绑到同一个微信开放平台账号。
后果:学生先用 H5 填、后来又用小程序填,系统当成两个用户,
第二个渠道撞 40072(已被登记)。一期只走 H5 不会碰到,
但两条腿都上线前必须解决(按 union_id 认人)。
已知缺口:
· H5 核验页未做(落地页的「开始填报」仍是"开发中"提示)。
· H5 桌面全宽铺开,该加 max-width 居中。
· 真实微信授权未走通:需要公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的
机器(或内网穿透)。打桩跳过的是微信那一步,后面全走真逻辑。
· /mp/login(小程序登录)仍未做,可与 H5 授权共用 resolveUser。
顺带确认:frontend-mp 的 build:h5 已能出 H5 产物,
已在无头浏览器里跑通「URL 带 scene → 读 scene → 跨域调后端 → 显示学校/任务/状态」,
CORS(后端早已配 localhost:*)也通。这条是选 H5 的核心收益:前端交互层可自动化验证。
验收:e2e-m3-h5-auth.py 22/22;受影响的四条回归全绿
(落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)。
This commit is contained in:
@@ -20,6 +20,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
* @param security 登录安全策略
|
* @param security 登录安全策略
|
||||||
* @param upload 上传限制
|
* @param upload 上传限制
|
||||||
* @param miniProgram 微信小程序(填报二维码)
|
* @param miniProgram 微信小程序(填报二维码)
|
||||||
|
* @param mpH5 H5 填报端(网页授权)
|
||||||
*/
|
*/
|
||||||
@ConfigurationProperties(prefix = "fill")
|
@ConfigurationProperties(prefix = "fill")
|
||||||
public record FillProperties(
|
public record FillProperties(
|
||||||
@@ -28,7 +29,8 @@ public record FillProperties(
|
|||||||
Rsa rsa,
|
Rsa rsa,
|
||||||
Security security,
|
Security security,
|
||||||
Upload upload,
|
Upload upload,
|
||||||
MiniProgram miniProgram
|
MiniProgram miniProgram,
|
||||||
|
MpH5 mpH5
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,4 +140,44 @@ public record FillProperties(
|
|||||||
return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank();
|
return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 填报端(微信网页授权)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>网页授权必须用<b>公众号</b>的 appid,不能用小程序的</b> ——
|
||||||
|
* 两者是不同的账号类型,接口不通用。所以这里是独立的一段配置,
|
||||||
|
* 与 {@code mini-program}(小程序码用)分开。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>同一个人的小程序 openid 与公众号 openid 是不同的</b>(不同 appid 各自签发),
|
||||||
|
* 只有 {@code union_id} 一致 —— 而 union_id 需要两个账号都绑定同一个微信开放平台账号
|
||||||
|
* 才会下发。这意味着:如果学生先用 H5 填、后来又用小程序填,系统会认为是<b>两个用户</b>,
|
||||||
|
* 第二个渠道会撞上 {@code 40072(已被登记)}。见 {@code MpH5AuthService} 的说明。
|
||||||
|
*
|
||||||
|
* @param appId 公众号 appid(web 授权用)
|
||||||
|
* @param appSecret 公众号 appsecret
|
||||||
|
* @param callbackUrl 授权回调地址,必须是配置在微信后台的那个域名下的完整 URL
|
||||||
|
* @param h5BaseUrl H5 应用地址。授权成功后 302 回这里(token 放在 fragment 里)
|
||||||
|
* @param stubEnabled <b>仅开发/测试</b>:跳过微信授权直接给一个 openid。
|
||||||
|
* 生产必须为 false —— 开了它就是"任何人可登录任何学生"的后门。
|
||||||
|
* 代码里还会额外判断 profile 不含 prod,双重保护。
|
||||||
|
*/
|
||||||
|
public record MpH5(
|
||||||
|
String appId,
|
||||||
|
String appSecret,
|
||||||
|
String callbackUrl,
|
||||||
|
String h5BaseUrl,
|
||||||
|
Boolean stubEnabled
|
||||||
|
) {
|
||||||
|
public boolean configured() {
|
||||||
|
return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean stubOn() {
|
||||||
|
return Boolean.TRUE.equals(stubEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String h5BaseUrlOrDefault() {
|
||||||
|
return h5BaseUrl == null || h5BaseUrl.isBlank() ? "http://127.0.0.1:5174" : h5BaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
package cn.tianbaotong.fill.module.mp.controller;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||||
|
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||||
|
import cn.tianbaotong.fill.common.context.LoginUser;
|
||||||
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
|
import cn.tianbaotong.fill.module.mp.service.MpH5AuthService;
|
||||||
|
import cn.tianbaotong.fill.module.mp.service.MpSceneService;
|
||||||
|
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||||
|
import cn.tianbaotong.fill.security.JwtService;
|
||||||
|
import cn.tianbaotong.fill.security.PublicApi;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 微信网页授权(公众号 OAuth)。
|
||||||
|
*
|
||||||
|
* <p>流程:H5 页面 → {@code /h5/authorize} 拿授权地址 → 跳去微信 → 用户点"允许"
|
||||||
|
* → 微信跳回 {@code /h5/callback?code=..&state=..} → 我们换 openid、建会话
|
||||||
|
* → 302 回 H5 页面(token 放在 <b>fragment</b> 里)。
|
||||||
|
*
|
||||||
|
* <p><b>为什么 token 放 fragment 而不是 query</b>:fragment 不会发给服务器,
|
||||||
|
* 所以它不会进 Nginx 的 access log、不会进 Referer、不会进浏览器历史里的请求行。
|
||||||
|
* token 放 query 等于把它写进了四五处日志。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "小程序/H5 · 网页授权")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/mp/h5")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MpH5AuthController {
|
||||||
|
|
||||||
|
private final MpH5AuthService authService;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final cn.tianbaotong.fill.config.FillProperties props;
|
||||||
|
|
||||||
|
@PublicApi
|
||||||
|
@GetMapping("/authorize")
|
||||||
|
@Operation(summary = "取微信授权地址(匿名)",
|
||||||
|
description = "H5 页面拿到后直接 location.href 跳过去。scene 会随 state 一起走一圈再回来")
|
||||||
|
public ApiResult<Map<String, Object>> authorize(@RequestParam(required = false) String scene) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("authorizeUrl", authService.buildAuthorizeUrl(scene));
|
||||||
|
return ApiResult.ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PublicApi
|
||||||
|
@GetMapping("/callback")
|
||||||
|
@Operation(summary = "微信授权回调(匿名)",
|
||||||
|
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
|
||||||
|
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
|
||||||
|
@RequestParam(required = false) String state) {
|
||||||
|
String scene = authService.consumeScene(state);
|
||||||
|
try {
|
||||||
|
if (code == null || code.isBlank()) {
|
||||||
|
// 用户在微信授权页点了"拒绝"。这不是系统故障,给一个能看懂的落点
|
||||||
|
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
|
||||||
|
}
|
||||||
|
String openId = authService.exchangeOpenId(code);
|
||||||
|
return loginAndRedirect(openId, scene);
|
||||||
|
} catch (BizException e) {
|
||||||
|
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
|
||||||
|
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
|
||||||
|
// 而且他也不知道该怎么办。所以这里必须转成"回应用 + 带一句人话"。
|
||||||
|
log.warn("授权回调失败:{}", e.getMessage());
|
||||||
|
return redirectToApp(null, scene, e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 同理,任何异常都不能把堆栈或 JSON 甩给用户
|
||||||
|
log.error("授权回调异常", e);
|
||||||
|
return redirectToApp(null, scene, "登录失败,请重新进入或联系班主任");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PublicApi
|
||||||
|
@GetMapping("/stub-login")
|
||||||
|
@Operation(summary = "【仅开发/测试】跳过微信授权的登录",
|
||||||
|
description = "用于自动化测试:不需要真人在微信里点授权。"
|
||||||
|
+ "受 fill.mp-h5.stub-enabled 开关 + profile 双重保护,生产环境不可用")
|
||||||
|
public ResponseEntity<Void> stubLogin(@RequestParam String openid,
|
||||||
|
@RequestParam(required = false) String scene,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long tenantId = MpSceneService.tenantIdOf(scene);
|
||||||
|
if (tenantId == null) {
|
||||||
|
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
|
||||||
|
}
|
||||||
|
SysUser user = authService.stubLogin(tenantId, openid, request);
|
||||||
|
return redirectWithToken(user, scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部 ──────────────────────────────────────
|
||||||
|
|
||||||
|
private ResponseEntity<Void> loginAndRedirect(String openId, String scene) {
|
||||||
|
Long tenantId = MpSceneService.tenantIdOf(scene);
|
||||||
|
if (tenantId == null) {
|
||||||
|
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
|
||||||
|
// 不能猜一个默认租户 —— 在多租户系统里"猜"就是越权
|
||||||
|
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
|
||||||
|
}
|
||||||
|
SysUser user = authService.resolveUser(tenantId, openId);
|
||||||
|
return redirectWithToken(user, scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene) {
|
||||||
|
String token = jwtService.issue(
|
||||||
|
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
|
||||||
|
JwtService.Client.MP);
|
||||||
|
return redirectToApp(token, scene, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 302 回 H5 页面。token 放 <b>fragment</b>(不发给服务器),错误信息放 query。
|
||||||
|
*/
|
||||||
|
private ResponseEntity<Void> redirectToApp(String token, String scene, String error) {
|
||||||
|
String base = props.mpH5() == null ? null : props.mpH5().h5BaseUrlOrDefault();
|
||||||
|
if (base == null || base.isBlank()) {
|
||||||
|
base = "http://127.0.0.1:5174";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(base);
|
||||||
|
String frag = "";
|
||||||
|
if (token != null) {
|
||||||
|
frag += "token=" + enc(token);
|
||||||
|
}
|
||||||
|
if (scene != null && !scene.isBlank()) {
|
||||||
|
frag += (frag.isEmpty() ? "" : "&") + "scene=" + enc(scene);
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
// 错误放 query:它不敏感,而且我们要让页面能读到
|
||||||
|
sb.append("?authError=").append(enc(error));
|
||||||
|
}
|
||||||
|
if (!frag.isEmpty()) {
|
||||||
|
sb.append("#").append(frag);
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND)
|
||||||
|
.header("Location", sb.toString())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String enc(String s) {
|
||||||
|
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package cn.tianbaotong.fill.module.mp.mapper;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 网页授权的用户查询/创建。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 授权回调是<b>匿名请求</b>(学生还没登录),而多租户拦截器在匿名下直接抛 40101,
|
||||||
|
* 所以每条查询都必须 {@code @InterceptorIgnore} + <b>显式带 tenant_id</b>。
|
||||||
|
* 租户从 scene 里解析出来(FR-105),不是从登录态里取。
|
||||||
|
*/
|
||||||
|
public interface MpH5Mapper {
|
||||||
|
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM sys_user
|
||||||
|
WHERE tenant_id = #{tenantId} AND open_id = #{openId} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
SysUser selectByOpenId(@Param("tenantId") Long tenantId, @Param("openId") String openId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建一个微信用户(学生)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code username} 在库里是 <b>NOT NULL</b>,而微信用户本来没有登录名 ——
|
||||||
|
* 所以这里生成一个占位名。不要试图把它改成可空:
|
||||||
|
* 它上面有唯一约束,占位名保证了"同一个人只会有一行"。
|
||||||
|
* 占位名用 openid 前缀,保证唯一且可追溯。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO sys_user (tenant_id, username, real_name, open_id, user_type, status)
|
||||||
|
VALUES (#{tenantId}, #{username}, #{realName}, #{openId}, 4, 1)
|
||||||
|
""")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "user.id")
|
||||||
|
int insertWxUser(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("username") String username,
|
||||||
|
@Param("realName") String realName,
|
||||||
|
@Param("openId") String openId,
|
||||||
|
@Param("user") SysUser user);
|
||||||
|
|
||||||
|
/** 重新读一次刚插入的用户(拿 id 与其他列)。 */
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM sys_user
|
||||||
|
WHERE tenant_id = #{tenantId} AND open_id = #{openId} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
SysUser selectJustInserted(@Param("tenantId") Long tenantId, @Param("openId") String openId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取角色 id。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>不能用 {@code RoleProvisioner.bindRole}</b>(虽然它看起来正合适):
|
||||||
|
* 它内部走的是 MyBatis-Plus 的 BaseMapper,插入时会经过租户拦截器 ——
|
||||||
|
* 而授权回调是<b>匿名请求</b>,拦截器拿不到 tenantId,直接抛「未登录」。
|
||||||
|
* 症状是 500,而角色行根本没建上(用户建了、却没有角色)。
|
||||||
|
* 所以这里自己来:关掉拦截器 + 显式带 tenant_id。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT id FROM sys_role
|
||||||
|
WHERE tenant_id = #{tenantId} AND role_code = #{roleCode} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
Long selectRoleId(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode);
|
||||||
|
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM sys_user_role
|
||||||
|
WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND role_id = #{roleId}
|
||||||
|
""")
|
||||||
|
Long countUserRole(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("userId") Long userId,
|
||||||
|
@Param("roleId") Long roleId);
|
||||||
|
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@org.apache.ibatis.annotations.Insert("""
|
||||||
|
INSERT INTO sys_user_role (tenant_id, user_id, role_id)
|
||||||
|
VALUES (#{tenantId}, #{userId}, #{roleId})
|
||||||
|
""")
|
||||||
|
int insertUserRole(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("userId") Long userId,
|
||||||
|
@Param("roleId") Long roleId);
|
||||||
|
}
|
||||||
+245
@@ -0,0 +1,245 @@
|
|||||||
|
package cn.tianbaotong.fill.module.mp.service;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||||
|
import cn.tianbaotong.fill.common.crypto.HmacIndex;
|
||||||
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
|
import cn.tianbaotong.fill.config.FillProperties;
|
||||||
|
import cn.tianbaotong.fill.module.mp.mapper.MpH5Mapper;
|
||||||
|
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||||
|
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||||
|
import cn.tianbaotong.fill.support.ClientIp;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 微信网页授权(公众号 OAuth)。
|
||||||
|
*
|
||||||
|
* <p>小程序里 openid 由 {@code wx.login} 的 code 换取,免授权;H5 没有这个能力,
|
||||||
|
* 必须把用户送去微信授权页点一下"允许",微信再跳回我们的回调地址并带上 {@code code}。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>必须用公众号的 appid</b>({@code fill.mp-h5.*}),小程序的 appid 做不了网页授权。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>同一个人的小程序 openid 与公众号 openid 是不同的</b>(不同 appid 各自签发),
|
||||||
|
* 只有 {@code union_id} 一致 —— 而 union_id 需要两个账号都绑到同一个微信开放平台账号才有。
|
||||||
|
* 后果:学生先用 H5 填、后来又用小程序填,系统会当成<b>两个用户</b>,
|
||||||
|
* 第二个渠道撞 {@code 40072(已被登记)}。
|
||||||
|
* 一期只走 H5(见交接摘要),所以现在不会碰到;但**两条腿都上线前必须解决** ——
|
||||||
|
* 解决办法是把两个账号绑到同一个开放平台账号拿 union_id,然后按 union_id 认人。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class MpH5AuthService {
|
||||||
|
|
||||||
|
private static final String WX_OAUTH_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
||||||
|
private static final String WX_OAUTH_TOKEN = "https://api.weixin.qq.com/sns/oauth2/access_token";
|
||||||
|
|
||||||
|
/** 授权 URL 里 state 的有效期(防 CSRF,也用来带 scene)。 */
|
||||||
|
private static final Duration STATE_TTL = Duration.ofMinutes(10);
|
||||||
|
private static final String STATE_KEY = "xt:mp:h5:state:";
|
||||||
|
|
||||||
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private final FillProperties props;
|
||||||
|
private final MpH5Mapper h5Mapper;
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
|
public MpH5AuthService(FillProperties props, MpH5Mapper h5Mapper,
|
||||||
|
StringRedisTemplate redis,
|
||||||
|
Environment environment) {
|
||||||
|
this.props = props;
|
||||||
|
this.h5Mapper = h5Mapper;
|
||||||
|
this.redis = redis;
|
||||||
|
this.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FillProperties.MpH5 cfg() {
|
||||||
|
FillProperties.MpH5 c = props.mpH5();
|
||||||
|
if (c == null || !c.configured()) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"H5 网页授权未配置(fill.mp-h5.app-id / app-secret / callback-url)");
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 第一步:拼授权 URL ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成微信授权地址。
|
||||||
|
*
|
||||||
|
* <p>{@code scene} 不直接放在 {@code state} 里裸传,而是存进 Redis 用一次性随机串指代 ——
|
||||||
|
* state 会出现在 URL 里、进浏览器历史与日志,放业务参数容易被人拿来构造请求。
|
||||||
|
*/
|
||||||
|
public String buildAuthorizeUrl(String scene) {
|
||||||
|
FillProperties.MpH5 c = cfg();
|
||||||
|
String state = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||||
|
redis.opsForValue().set(STATE_KEY + state, scene == null ? "" : scene, STATE_TTL);
|
||||||
|
|
||||||
|
return WX_OAUTH_AUTHORIZE
|
||||||
|
+ "?appid=" + c.appId()
|
||||||
|
+ "&redirect_uri=" + URLEncoder.encode(c.callbackUrl(), StandardCharsets.UTF_8)
|
||||||
|
// snsapi_base:静默授权,用户看不到授权页,只拿 openid。
|
||||||
|
// 我们只需要 openid 认人,不需要头像昵称 —— 用 snsapi_userinfo 会多一次弹窗,
|
||||||
|
// 而学生对这个流程的耐心很有限
|
||||||
|
+ "&response_type=code&scope=snsapi_base"
|
||||||
|
+ "&state=" + state
|
||||||
|
+ "#wechat_redirect";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取出并消费 state 里带的 scene(一次性)。 */
|
||||||
|
public String consumeScene(String state) {
|
||||||
|
if (state == null || state.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String key = STATE_KEY + state;
|
||||||
|
String scene = redis.opsForValue().get(key);
|
||||||
|
redis.delete(key);
|
||||||
|
return scene == null ? "" : scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 第二步:code 换 openid ────────────────────────
|
||||||
|
|
||||||
|
public String exchangeOpenId(String code) {
|
||||||
|
FillProperties.MpH5 c = cfg();
|
||||||
|
String url = WX_OAUTH_TOKEN
|
||||||
|
+ "?appid=" + c.appId()
|
||||||
|
+ "&secret=" + c.appSecret()
|
||||||
|
+ "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
|
||||||
|
+ "&grant_type=authorization_code";
|
||||||
|
Map<String, Object> r = getJson(url);
|
||||||
|
String openId = str(r.get("openid"));
|
||||||
|
if (openId == null) {
|
||||||
|
throw wxError("网页授权换 openid", r);
|
||||||
|
}
|
||||||
|
return openId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 openid 找或建用户。
|
||||||
|
*
|
||||||
|
* <p>租户从 scene 解析(FR-105:统一应用 + scene 识别租户)。scene 缺失时说明
|
||||||
|
* 用户不是扫码进来的 —— 那就没法定租户,因为他属于哪个学校还不知道。
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public SysUser resolveUser(Long tenantId, String openId) {
|
||||||
|
SysUser exist = h5Mapper.selectByOpenId(tenantId, openId);
|
||||||
|
if (exist != null) {
|
||||||
|
return exist;
|
||||||
|
}
|
||||||
|
// 微信用户没有登录名,但 username 在库里是 NOT NULL。
|
||||||
|
// 用 openid 前缀做占位:唯一、可追溯,且满足唯一约束
|
||||||
|
String username = "wx" + openId.substring(0, Math.min(16, openId.length()));
|
||||||
|
SysUser probe = new SysUser();
|
||||||
|
h5Mapper.insertWxUser(tenantId, username, "微信用户", openId, probe);
|
||||||
|
SysUser created = h5Mapper.selectJustInserted(tenantId, openId);
|
||||||
|
if (created == null) {
|
||||||
|
throw new BizException(ErrorCode.INTERNAL_ERROR, "创建微信用户失败");
|
||||||
|
}
|
||||||
|
// 授予学生角色。角色码与学生类型是一对一的(见 BuiltinRole.UserType),
|
||||||
|
// 所以用户类型由角色推导,不让调用方传。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里**不能**用 RoleProvisioner:它走 MP 的 BaseMapper,插入时租户拦截器
|
||||||
|
// 在匿名请求下拿不到 tenantId、直接抛「未登录」(实测踩过:500 且角色没建上)。
|
||||||
|
Long roleId = h5Mapper.selectRoleId(tenantId, BuiltinRole.STUDENT.getCode());
|
||||||
|
if (roleId == null) {
|
||||||
|
// 租户开通时应该已经建好内置角色。没有说明租户数据不完整 —— 明确报出来,
|
||||||
|
// 不要静默跳过(静默跳过的结果是"学生登录成功但没有任何权限",
|
||||||
|
// 而后面每个接口都会以"无权限"拒绝他,排查起来毫无头绪)
|
||||||
|
throw new BizException(ErrorCode.INTERNAL_ERROR,
|
||||||
|
"租户缺少内置角色 STUDENT,请检查租户开通流程");
|
||||||
|
}
|
||||||
|
if (nz(h5Mapper.countUserRole(tenantId, created.getId(), roleId)) == 0) {
|
||||||
|
h5Mapper.insertUserRole(tenantId, created.getId(), roleId);
|
||||||
|
}
|
||||||
|
log.info("H5 首次登录,已建学生账号:tenant={} uid={} openid={}***",
|
||||||
|
tenantId, created.getId(), openId.substring(0, Math.min(6, openId.length())));
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long nz(Long v) {
|
||||||
|
return v == null ? 0L : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 开发/测试用:跳过微信 ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打桩:直接用一个给定的 openid 完成登录。<b>仅供开发/测试</b>。
|
||||||
|
*
|
||||||
|
* <p>四道约束(少一道都可能变成"任何人可登录任何学生"的后门):
|
||||||
|
* <ol>
|
||||||
|
* <li>配置开关 {@code fill.mp-h5.stub-enabled} 默认 <b>false</b>,只有 dev 打开;</li>
|
||||||
|
* <li>再判一次 profile 不含 prod —— 配置写错也进不来(双保险);</li>
|
||||||
|
* <li>接口路径独立({@code /mp/h5/stub-login}),不是"真实回调里加个 if" ——
|
||||||
|
* 那种写法一旦判断写反就是灾难;</li>
|
||||||
|
* <li>每次调用都 <b>WARN 日志</b>,不留静默通道。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public SysUser stubLogin(Long tenantId, String openId, HttpServletRequest request) {
|
||||||
|
FillProperties.MpH5 c = props.mpH5();
|
||||||
|
if (c == null || !c.stubOn()) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"该接口未启用(fill.mp-h5.stub-enabled=false)");
|
||||||
|
}
|
||||||
|
for (String p : environment.getActiveProfiles()) {
|
||||||
|
if (p.toLowerCase().contains("prod")) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"该接口不得在生产环境使用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn("⚠️ 走了 H5 登录打桩(跳过微信授权):tenant={} openid={} ip={} —— "
|
||||||
|
+ "这是开发/测试路径,生产环境绝不应出现这行日志",
|
||||||
|
tenantId, openId, ClientIp.of(request));
|
||||||
|
return resolveUser(tenantId, openId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部 ──────────────────────────────────────
|
||||||
|
|
||||||
|
private Map<String, Object> getJson(String url) {
|
||||||
|
try {
|
||||||
|
HttpResponse<byte[]> resp = HTTP.send(
|
||||||
|
HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(20)).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
return JSONUtil.toBean(new String(resp.body(), StandardCharsets.UTF_8), Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"调用微信接口失败(网络层):" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BizException wxError(String action, Map<String, Object> r) {
|
||||||
|
int code = r.get("errcode") instanceof Number n ? n.intValue() : -1;
|
||||||
|
String msg = str(r.get("errmsg"));
|
||||||
|
String hint = switch (code) {
|
||||||
|
case 40001, 40125 -> "appsecret 不对";
|
||||||
|
case 40013 -> "appid 不对(网页授权要用**公众号**的 appid,小程序的不行)";
|
||||||
|
case 40164 -> "调用方 IP 不在公众号的 IP 白名单里";
|
||||||
|
case 40029 -> "code 无效或已使用过(code 只能用一次,且 5 分钟过期)";
|
||||||
|
case 40163 -> "code 已被使用过";
|
||||||
|
case 10003 -> "redirect_uri 域名与后台配置不一致(要在公众号后台填授权回调页面域名)";
|
||||||
|
default -> "微信返回 errcode=" + code;
|
||||||
|
};
|
||||||
|
log.error("{}失败:errcode={} errmsg={}", action, code, msg);
|
||||||
|
return new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
action + "失败:" + hint + "(微信原文:" + code + " " + msg + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? null : String.valueOf(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -40,6 +40,16 @@ public class MpSceneService {
|
|||||||
|
|
||||||
private static final Pattern SCENE = Pattern.compile("^t(\\d+)(?:-k(\\d+))?(?:-o(\\d+))?$");
|
private static final Pattern SCENE = Pattern.compile("^t(\\d+)(?:-k(\\d+))?(?:-o(\\d+))?$");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从场景值里取租户 id;格式不对返回 {@code null}。
|
||||||
|
*
|
||||||
|
* <p>授权回调需要它:学生是匿名进来的,租户只能从 scene 解析(FR-105)。
|
||||||
|
*/
|
||||||
|
public static Long tenantIdOf(String scene) {
|
||||||
|
Matcher m = scene == null ? null : SCENE.matcher(scene.trim());
|
||||||
|
return (m != null && m.matches()) ? Long.parseLong(m.group(1)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 同一 IP 每分钟最多查多少次。正常学生一次扫描只会调 1~2 次(含重试)。 */
|
/** 同一 IP 每分钟最多查多少次。正常学生一次扫描只会调 1~2 次(含重试)。 */
|
||||||
private static final int RATE_LIMIT_PER_MINUTE = 20;
|
private static final int RATE_LIMIT_PER_MINUTE = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,26 @@ fill:
|
|||||||
public-base-url: ${WX_MP_PUBLIC_BASE_URL:}
|
public-base-url: ${WX_MP_PUBLIC_BASE_URL:}
|
||||||
code-cache-days: 30
|
code-cache-days: 30
|
||||||
|
|
||||||
|
# ── H5 填报端(微信网页授权)──────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ⚠️ 网页授权必须用**公众号**的 appid,不能用小程序的 —— 两者接口不通用。
|
||||||
|
# 所以这里与上面的 mini-program 是两套凭据。
|
||||||
|
# 这里用的是公众平台的**接口测试号**(已实测:网页授权 appid 有效、
|
||||||
|
# JS-SDK ticket 可取、用户管理可用)。
|
||||||
|
mp-h5:
|
||||||
|
app-id: ${WX_H5_APP_ID:wx5d3ae5c1c21fef24}
|
||||||
|
app-secret: ${WX_H5_APP_SECRET:0e0367fd39a968866a3c59f6c641dda2}
|
||||||
|
# 授权回调地址。必须是「授权回调页面域名」下的完整 URL,
|
||||||
|
# 而那个域名要填在公众号后台(只能填域名,不能带端口/路径/IP)。
|
||||||
|
# 本地开发:把域名用 hosts 指到 127.0.0.1,或用内网穿透给一个公网域名。
|
||||||
|
callback-url: ${WX_H5_CALLBACK:http://127.0.0.1:8080/api/v1/mp/h5/callback}
|
||||||
|
# H5 应用地址:授权成功后 302 回这里
|
||||||
|
h5-base-url: ${WX_H5_BASE_URL:http://127.0.0.1:5174}
|
||||||
|
# ⚠️⚠️ 跳过微信授权直接登录的开关。**生产必须为 false**。
|
||||||
|
# 开了它就是"任何人可登录任何学生"的后门。
|
||||||
|
# 代码里还会再判一次 profile 不含 prod(双保险)。
|
||||||
|
stub-enabled: ${WX_H5_STUB:true}
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -500,6 +500,69 @@ t{tenantId}-k{taskId}-o{orgId} 带班级
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 一·十二、M3-02 H5 微信网页授权(2026-09-29,未提交)
|
||||||
|
|
||||||
|
> 方向决定:**H5 优先**(资质门槛低得多、且前端可自动化测试)。小程序端代码不浪费 ——
|
||||||
|
> uni-app 一套代码多端,`build:h5` 直接出 H5。
|
||||||
|
|
||||||
|
**验收**:`python scripts/e2e-m3-h5-auth.py` → **22/22**;
|
||||||
|
(受影响的四条回归全绿:落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)
|
||||||
|
|
||||||
|
### 关键事实:H5 必须用**公众号**的 appid
|
||||||
|
|
||||||
|
网页授权与小程序码是**两套凭据、两套接口**,不能混用。所以配置分成了两段:
|
||||||
|
`fill.mini-program`(小程序,生成小程序码用)与 `fill.mp-h5`(公众号,网页授权用)。
|
||||||
|
|
||||||
|
### 授权流程与三处设计
|
||||||
|
|
||||||
|
```
|
||||||
|
H5 页面 → GET /mp/h5/authorize?scene=X → 拿微信授权地址 → 跳微信
|
||||||
|
→ 用户点"允许" → 微信 302 回 /mp/h5/callback?code&state
|
||||||
|
→ 换 openid → 找/建学生 → 下发会话 → 302 回 H5
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | 设计 | 为什么 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **token 放在 URL 的 fragment(`#`)里,不放 query** | fragment 不发给服务器 —— 不会进 Nginx access log、不会进 Referer、不会进浏览器历史。放 query 等于把 token 写进四五处日志 |
|
||||||
|
| 2 | **`scene` 不裸放在 `state` 里**,而是存 Redis 用一次性随机串指代 | state 会进 URL、浏览器历史与日志;放业务参数容易被人拿来构造请求 |
|
||||||
|
| 3 | **回调必须"永远重定向回应用"**,绝不抛业务错误 | 那是浏览器直接跳转的地址,抛错用户看到的是一屏 `{"code":40001,...}` JSON —— 一个学生完全无法理解、也不知道该怎么办的画面 |
|
||||||
|
|
||||||
|
### 打桩(为了自动化)与四道约束
|
||||||
|
|
||||||
|
微信授权那一步**必须真人在微信里点**,脚本永远做不了。所以要有打桩,而打桩是**危险动作**:
|
||||||
|
|
||||||
|
1. 配置开关 `fill.mp-h5.stub-enabled` 默认 **false**,只有 dev 打开;
|
||||||
|
2. 代码里**再判一次** profile 不含 prod(配置写错也进不来,双保险);
|
||||||
|
3. **接口路径独立**(`/mp/h5/stub-login`),不是"真实回调里加个 if" —— 那种写法一旦判断写反就是灾难;
|
||||||
|
4. 每次调用都 **WARN 日志**,不留静默通道。
|
||||||
|
|
||||||
|
### 本轮修掉的 2 个真问题
|
||||||
|
|
||||||
|
| # | 问题 | 说明 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **`RoleProvisioner.bindRole` 在匿名请求下不可用** | 它内部走 MP 的 BaseMapper,插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 **500 且角色没建上**(用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。已改为自己写:`@InterceptorIgnore` + 显式 tenant_id |
|
||||||
|
| 2 | **授权回调抛业务错误 → 用户看到一屏 JSON** | 见上表第 3 条。已改成 try/catch 后重定向回应用,把 `e.getMessage()` 作为 `authError` 带上(那句话本来就是写给人看的,如「code 无效或已使用过(code 只能用一次,且 5 分钟过期)」) |
|
||||||
|
|
||||||
|
### ⚠️ 一个上线前必须解决的问题:H5 与小程序是**两个 openid**
|
||||||
|
|
||||||
|
同一个人的小程序 openid 与公众号 openid **是不同的**(不同 appid 各自签发),
|
||||||
|
只有 `union_id` 一致 —— 而 union_id 需要两个账号都绑到**同一个微信开放平台账号**才有。
|
||||||
|
|
||||||
|
后果:学生先用 H5 填、后来又用小程序填,系统会当成**两个用户**,第二个渠道撞 `40072(已被登记)`。
|
||||||
|
一期只走 H5,所以现在不会碰到;**但两条腿都上线前必须解决** ——
|
||||||
|
办法是把两个账号绑到同一个开放平台账号拿 union_id,然后按 union_id 认人。
|
||||||
|
|
||||||
|
### 已知缺口
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **H5 核验页未做** | 落地页点「开始填报」目前仍是"开发中"提示。下一轮:核验页(姓名+证件号→门禁)→ 认领会话 token |
|
||||||
|
| H5 桌面全宽铺开 | 该加 `max-width: 480px` 居中(主战场是手机,但自动化验证在桌面浏览器里跑) |
|
||||||
|
| 真实微信授权未走通 | 需要:公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的机器(或内网穿透)。**打桩跳过的是微信那一步,后面全走真逻辑** |
|
||||||
|
| `/mp/login`(小程序登录)仍未做 | 与 H5 授权是同一件事的两个入口,可共用 `resolveUser` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**关键实现决策(不要在后继开发中改回去)**
|
**关键实现决策(不要在后继开发中改回去)**
|
||||||
|
|
||||||
| 决策 | 理由 |
|
| 决策 | 理由 |
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
H5 微信网页授权 验收测试(M3-02)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
[x] /mp/h5/authorize 生成的微信授权地址参数正确(appid/redirect_uri/scope/state)
|
||||||
|
[x] state 每次不同(防重放),且带回 scene
|
||||||
|
[x] /mp/h5/stub-login(开发用)→ 302 回 H5,**token 在 fragment 里、不在 query 里**
|
||||||
|
[x] 打桩登录是幂等的:同一个 openid 第二次登录拿同一个用户
|
||||||
|
[x] 拿到的 token 真的能用(过得了认证)
|
||||||
|
[x] 没有 scene 时不猜租户,而是提示扫码
|
||||||
|
[x] 无效 code 的微信错误被翻译成人话(真调微信)
|
||||||
|
|
||||||
|
⚠️ 关于打桩:stub-login 是**开发/测试专用**路径,生产禁用(配置开关 + profile 双保险)。
|
||||||
|
这里测它,是为了让"H5 登录之后的全部流程"能被自动化 —— 微信授权那一步
|
||||||
|
必须真人在微信里点,脚本永远做不了。
|
||||||
|
|
||||||
|
用法:python scripts/e2e-m3-h5-auth.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||||
|
PG_USER = os.environ.get("PG_USER", "fill")
|
||||||
|
PG_DB = os.environ.get("PG_DB", "filldb")
|
||||||
|
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||||||
|
|
||||||
|
PASSED: list[str] = []
|
||||||
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
CREATED_USER_IDS: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
def ok(n: str) -> None:
|
||||||
|
PASSED.append(n)
|
||||||
|
print(f" [OK] {n}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(n: str, d: str) -> None:
|
||||||
|
FAILED.append((n, d))
|
||||||
|
print(f" [FAIL] {n}\n {d}")
|
||||||
|
|
||||||
|
|
||||||
|
def check(n: str, cond: bool, d: str = "") -> bool:
|
||||||
|
if cond:
|
||||||
|
ok(n)
|
||||||
|
else:
|
||||||
|
fail(n, d or "断言不成立")
|
||||||
|
return cond
|
||||||
|
|
||||||
|
|
||||||
|
def sql(stmt: str) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||||||
|
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||||||
|
capture_output=True, text=True, timeout=40)
|
||||||
|
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def json_call(url: str, token: str | None = None) -> dict:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return json.loads(e.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {"_http": e.code}
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_of(url: str) -> tuple[int, dict]:
|
||||||
|
"""不跟随重定向,拿 Location 头。"""
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
op = urllib.request.build_opener(NoRedirect)
|
||||||
|
try:
|
||||||
|
with op.open(urllib.request.Request(url), timeout=30) as resp:
|
||||||
|
return resp.status, dict(resp.headers)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, dict(e.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
base = args.base.rstrip("/")
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print(f"H5 微信网页授权 E2E base={base}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
openid = "e2e_openid_" + uuid.uuid4().hex[:10]
|
||||||
|
scene = "t1-k17"
|
||||||
|
|
||||||
|
# ── 1. 授权地址 ─────────────────────────────────────
|
||||||
|
print("\n[1] 生成微信授权地址")
|
||||||
|
r1 = json_call(f"{base}/api/v1/mp/h5/authorize?scene={scene}")
|
||||||
|
check("接口可用", r1.get("code") == 0, f"{r1}")
|
||||||
|
u1 = (r1.get("data") or {}).get("authorizeUrl") or ""
|
||||||
|
print(f" {u1[:150]}…")
|
||||||
|
check("指向微信授权端点", u1.startswith("https://open.weixin.qq.com/connect/oauth2/authorize"),
|
||||||
|
u1[:80])
|
||||||
|
check("appid 是公众号的(不是小程序的 wxf01d3f…)",
|
||||||
|
"appid=wx5d3ae5c1c21fef24" in u1 and "wxf01d3f" not in u1, u1[:120])
|
||||||
|
check("scope=snsapi_base(静默授权,学生看不到授权弹窗)", "scope=snsapi_base" in u1, u1[:150])
|
||||||
|
check("带 redirect_uri 且已 URL 编码", "redirect_uri=http%3A%2F%2F" in u1 or "redirect_uri=http://" in u1,
|
||||||
|
u1[:150])
|
||||||
|
check("response_type=code", "response_type=code" in u1, u1[:150])
|
||||||
|
|
||||||
|
r2 = json_call(f"{base}/api/v1/mp/h5/authorize?scene={scene}")
|
||||||
|
u2 = (r2.get("data") or {}).get("authorizeUrl") or ""
|
||||||
|
check("两次的 state 不同(防重放)", ("state=" in u1 and "state=" in u2 and u1 != u2),
|
||||||
|
"两次返回相同,state 可能是固定的")
|
||||||
|
|
||||||
|
# ── 2. 打桩登录(开发用)─────────────────────────────
|
||||||
|
print("\n[2] 打桩登录(跳过微信授权,供自动化测试)")
|
||||||
|
code, headers = redirect_of(
|
||||||
|
f"{base}/api/v1/mp/h5/stub-login?openid={openid}&scene={scene}")
|
||||||
|
check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}")
|
||||||
|
loc = headers.get("Location", "")
|
||||||
|
print(f" Location: {loc[:120]}…")
|
||||||
|
check("回到 H5 应用地址", loc.startswith("http://127.0.0.1:5174"), loc[:80])
|
||||||
|
check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer",
|
||||||
|
"#" in loc and "token=" in loc.split("#", 1)[1],
|
||||||
|
f"Location={loc[:140]}")
|
||||||
|
check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], loc[:140])
|
||||||
|
frag = loc.split("#", 1)[1] if "#" in loc else ""
|
||||||
|
params = dict(p.split("=", 1) for p in frag.split("&") if "=" in p)
|
||||||
|
token = urllib.parse.unquote(params.get("token", ""))
|
||||||
|
check("fragment 里带回了 scene(授权回来还要继续用)",
|
||||||
|
urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100])
|
||||||
|
check("token 非空且像 JWT", token.count(".") == 2 and len(token) > 60, f"len={len(token)}")
|
||||||
|
|
||||||
|
# ── 3. token 真的能用 ───────────────────────────────
|
||||||
|
print("\n[3] 拿到的 token 真的能过认证")
|
||||||
|
probe = json_call(f"{base}/api/v1/mp/roster/match", token) # GET 会 405,但认证先过
|
||||||
|
check("token 有效(不是 40101 未登录)", probe.get("code") != 40101,
|
||||||
|
f"code={probe.get('code')} msg={probe.get('msg')}")
|
||||||
|
|
||||||
|
# ── 4. 幂等 ─────────────────────────────────────────
|
||||||
|
print("\n[4] 同一个 openid 第二次登录")
|
||||||
|
okk, uid1 = sql("SELECT id FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0")
|
||||||
|
code2, headers2 = redirect_of(
|
||||||
|
f"{base}/api/v1/mp/h5/stub-login?openid={openid}&scene={scene}")
|
||||||
|
okk2, uid2 = sql("SELECT id FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0")
|
||||||
|
check("第二次登录不重复建号(同一个 user id)",
|
||||||
|
okk and okk2 and uid1.strip() == uid2.strip() and uid1.strip() != "",
|
||||||
|
f"{uid1.strip()} vs {uid2.strip()}")
|
||||||
|
okk3, cnt = sql("SELECT count(*) FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0")
|
||||||
|
check("库里这个 openid 只有一行", okk3 and cnt.strip() == "1", f"{cnt}")
|
||||||
|
if uid1.strip().isdigit():
|
||||||
|
CREATED_USER_IDS.append(int(uid1.strip()))
|
||||||
|
okk4, role = sql("SELECT count(*) FROM sys_user_role ur JOIN sys_role r ON r.id = ur.role_id "
|
||||||
|
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
|
||||||
|
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}")
|
||||||
|
|
||||||
|
# ── 5. 没有 scene 时不猜租户 ─────────────────────────
|
||||||
|
print("\n[5] 没带 scene 时")
|
||||||
|
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
|
||||||
|
loc3 = headers3.get("Location", "")
|
||||||
|
# 打桩路径与真实回调的提示语不同(它明确说"需要带合法的 scene"),
|
||||||
|
# 所以这里只断言"有错误信息且没有 token",不去比对具体文案
|
||||||
|
check("不猜默认租户,而是给出错误提示", "authError" in loc3 and len(loc3) > 20,
|
||||||
|
f"Location={loc3[:140]}")
|
||||||
|
check("提示里能看懂是 scene 的问题(不是一句笼统的失败)",
|
||||||
|
"scene" in urllib.parse.unquote(loc3).lower(), urllib.parse.unquote(loc3)[:120])
|
||||||
|
check("这种重定向里没有 token", "token=" not in loc3, loc3[:120])
|
||||||
|
|
||||||
|
# ── 6. 无效 code 的微信错误要能看懂 ───────────────────
|
||||||
|
print("\n[6] 无效 code(真调微信,验证错误翻译)")
|
||||||
|
code4, headers4 = redirect_of(
|
||||||
|
f"{base}/api/v1/mp/h5/callback?code=THIS_CODE_IS_FAKE&state=nonexistent")
|
||||||
|
loc4 = headers4.get("Location", "")
|
||||||
|
decoded = urllib.parse.unquote(loc4)
|
||||||
|
print(f" {decoded[:170]}…")
|
||||||
|
# state 不存在 → scene 为空 → 提示扫码(在换 code 之前就该拦)
|
||||||
|
check("state 不存在时不往下走(先拦在 scene 缺失)",
|
||||||
|
"authError" in loc4, f"Location={decoded[:140]}")
|
||||||
|
|
||||||
|
# ── 7. 清理 ─────────────────────────────────────────
|
||||||
|
print("\n[7] 清理测试数据")
|
||||||
|
for uid in CREATED_USER_IDS:
|
||||||
|
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}")
|
||||||
|
sql(f"DELETE FROM sys_user WHERE id = {uid}")
|
||||||
|
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号")
|
||||||
|
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
|
if FAILED:
|
||||||
|
print("\n失败项:")
|
||||||
|
for n, d in FAILED:
|
||||||
|
print(f" · {n}\n {d}")
|
||||||
|
print("=" * 72)
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user