|
|
@@ -304,6 +304,7 @@ func (fx *fixture) newApp(claims *token.Claims) *fiber.App {
|
|
|
app.Post("/auth/issue", fx.handler.Issue)
|
|
|
app.Post("/auth/refresh", fx.handler.Refresh)
|
|
|
app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode)
|
|
|
+ app.Get("/auth/wechat/authorize", fx.handler.WechatAuthorize)
|
|
|
app.Get("/auth/wechat/status", fx.handler.WechatStatus)
|
|
|
app.Get("/auth/wechat/callback", fx.handler.WechatCallback)
|
|
|
|
|
|
@@ -1003,10 +1004,16 @@ func TestWechat_NotConfigured(t *testing.T) {
|
|
|
}
|
|
|
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
app.Get("/auth/wechat/qrcode", h.WechatQrcode)
|
|
|
+ app.Get("/auth/wechat/authorize", h.WechatAuthorize)
|
|
|
app.Get("/auth/wechat/status", h.WechatStatus)
|
|
|
app.Get("/auth/wechat/callback", h.WechatCallback)
|
|
|
|
|
|
- for _, path := range []string{"/auth/wechat/qrcode", "/auth/wechat/status?state=x", "/auth/wechat/callback?code=x&state=x"} {
|
|
|
+ for _, path := range []string{
|
|
|
+ "/auth/wechat/qrcode",
|
|
|
+ "/auth/wechat/authorize",
|
|
|
+ "/auth/wechat/status?state=x",
|
|
|
+ "/auth/wechat/callback?code=x&state=x",
|
|
|
+ } {
|
|
|
env := do(fx.t, app, http.MethodGet, path, "", nil)
|
|
|
if env.Code != apperrors.CodeThirdNotConfigured {
|
|
|
t.Fatalf("%s code=%d(期望 %d 未配置)", path, env.Code, apperrors.CodeThirdNotConfigured)
|
|
|
@@ -1014,6 +1021,88 @@ func TestWechat_NotConfigured(t *testing.T) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// ---- 移动端 App 内微信授权(webview,snsapi_userinfo)----
|
|
|
+
|
|
|
+// TestWechatAuthorize_Success 生成授权 URL:code 200,data 含 state(32hex) + authorizeUrl(oauth2/authorize + snsapi_userinfo)
|
|
|
+// + campusId + expiresIn。authorizeUrl 与 qrUrl 的差异:endpoint=oauth2/authorize(非 qrconnect)、
|
|
|
+// scope=snsapi_userinfo(非 snsapi_login)、**无** #wechat_redirect 尾段。
|
|
|
+func TestWechatAuthorize_Success(t *testing.T) {
|
|
|
+ fx := newFixture(t)
|
|
|
+ app := fx.newApp(nil)
|
|
|
+ env := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil)
|
|
|
+ if env.Code != 200 || !env.Success {
|
|
|
+ t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
|
|
|
+ }
|
|
|
+ var ar struct {
|
|
|
+ State string `json:"state"`
|
|
|
+ AuthorizeURL string `json:"authorizeUrl"`
|
|
|
+ CampusID int64 `json:"campusId"`
|
|
|
+ ExpiresIn int64 `json:"expiresIn"`
|
|
|
+ }
|
|
|
+ if err := json.Unmarshal(env.Data, &ar); err != nil {
|
|
|
+ t.Fatalf("解析 data 失败: %v", err)
|
|
|
+ }
|
|
|
+ if len(ar.State) != 32 {
|
|
|
+ t.Fatalf("state 长度=%d(期望 32 hex)", len(ar.State))
|
|
|
+ }
|
|
|
+ // endpoint 应为 oauth2/authorize(非 qrconnect)
|
|
|
+ if !strings.Contains(ar.AuthorizeURL, "open.weixin.qq.com/connect/oauth2/authorize") {
|
|
|
+ t.Fatalf("authorizeUrl=%q(期望 connect/oauth2/authorize)", ar.AuthorizeURL)
|
|
|
+ }
|
|
|
+ // scope 应为 snsapi_userinfo(非 snsapi_login)
|
|
|
+ if !strings.Contains(ar.AuthorizeURL, "scope=snsapi_userinfo") {
|
|
|
+ t.Fatalf("authorizeUrl=%q(期望 scope=snsapi_userinfo)", ar.AuthorizeURL)
|
|
|
+ }
|
|
|
+ // 无 #wechat_redirect 尾段(webview 场景不保留)
|
|
|
+ if strings.Contains(ar.AuthorizeURL, "#wechat_redirect") {
|
|
|
+ t.Fatalf("authorizeUrl=%q(webview 场景不应含 #wechat_redirect)", ar.AuthorizeURL)
|
|
|
+ }
|
|
|
+ if !strings.Contains(ar.AuthorizeURL, "state="+ar.State) {
|
|
|
+ t.Fatalf("authorizeUrl=%q(期望携带 state=%q)", ar.AuthorizeURL, ar.State)
|
|
|
+ }
|
|
|
+ if ar.CampusID != 3 {
|
|
|
+ t.Fatalf("campusId=%d(期望 3,配置首个 campus)", ar.CampusID)
|
|
|
+ }
|
|
|
+ if ar.ExpiresIn <= 0 {
|
|
|
+ t.Fatalf("expiresIn=%d(期望 >0 秒)", ar.ExpiresIn)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestWechatAuthorize_ReuseCallback 授权 URL 的 state 可被同一 /auth/wechat/callback 消费
|
|
|
+// (证明 PC 扫码与移动 webview 共用同一 callback 换 openid,无需独立端点)。
|
|
|
+func TestWechatAuthorize_ReuseCallback(t *testing.T) {
|
|
|
+ fx := newFixture(t)
|
|
|
+ uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
|
|
|
+ fx.addWechatUser(uid, "openid-mobile")
|
|
|
+ fx.memberships.add(uid, orgmodel.Membership{
|
|
|
+ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
|
|
|
+ Role: "teacher", Status: "active",
|
|
|
+ })
|
|
|
+ fx.wechat.byCode["code-mobile"] = "openid-mobile"
|
|
|
+
|
|
|
+ app := fx.newApp(nil)
|
|
|
+ // ① 生成移动 webview 授权 URL
|
|
|
+ authEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil)
|
|
|
+ var ar struct {
|
|
|
+ State string `json:"state"`
|
|
|
+ }
|
|
|
+ _ = json.Unmarshal(authEnv.Data, &ar)
|
|
|
+
|
|
|
+ // ② 模拟微信重定向回 callback(同 PC 扫码的同一端点)
|
|
|
+ cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-mobile&state="+ar.State, "", nil)
|
|
|
+ if cbEnv.Code != 200 {
|
|
|
+ t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message)
|
|
|
+ }
|
|
|
+ var cb struct {
|
|
|
+ Status string `json:"status"`
|
|
|
+ LoginTicket string `json:"loginTicket"`
|
|
|
+ }
|
|
|
+ _ = json.Unmarshal(cbEnv.Data, &cb)
|
|
|
+ if cb.Status != "success" || cb.LoginTicket == "" {
|
|
|
+ t.Fatalf("status=%q ticket=%q(期望 success + 票据)", cb.Status, cb.LoginTicket)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。
|
|
|
func md5Hex(s string) string {
|
|
|
d := md5.Sum([]byte(s))
|