|
@@ -335,6 +335,132 @@ func TestLoginE2E_BadPassword(t *testing.T) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// seedPhoneUser 落一个 argon2 用户 + phone 身份(type=phone, identifier=11 位手机号)+ 一条 membership。
|
|
|
|
|
+// 与 seedUser 的差异:身份类型为 phone(identifier=手机号;phone 类型 AppID 恒 "",见 DDL v1.2)。
|
|
|
|
|
+// 手机号须 11 位数字(Service 层 isValidPhone 权威校验;e2e 用固定合规号 13800001234)。
|
|
|
|
|
+func (a *e2eApp) seedPhoneUser(phone, password string, inst, campus int64, role string) string {
|
|
|
|
|
+ a.t.Helper()
|
|
|
|
|
+ uid, err := idpkg.NewCanonicalUserID()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ a.t.Fatalf("生成 canonical user id: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ hash, err := credential.HashArgon2(password)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ a.t.Fatalf("HashArgon2: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ now := time.Now()
|
|
|
|
|
+ if err := a.gdb.WithContext(ctx).Create(&model.User{
|
|
|
|
|
+ CanonicalUserID: uid, PersonType: model.PersonTypeStaff,
|
|
|
|
|
+ PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
|
|
|
|
|
+ Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ a.t.Fatalf("插入 t_user: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := a.gdb.WithContext(ctx).Create(&model.Identity{
|
|
|
|
|
+ CanonicalUserID: uid, Type: model.IdentityTypePhone, Identifier: phone,
|
|
|
|
|
+ // phone 类型 AppID 恒 ""(DDL v1.2:phone/email/username 均空串,仅 wechat 必填)
|
|
|
|
|
+ Verified: true, CreatedAt: now,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ a.t.Fatalf("插入 t_identity(phone): %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := a.gdb.WithContext(ctx).Exec(
|
|
|
|
|
+ `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, 'active', ?, ?)`,
|
|
|
|
|
+ uid, inst, campus, role, now, now,
|
|
|
|
|
+ ).Error; err != nil {
|
|
|
|
|
+ a.t.Fatalf("插入 t_membership: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return uid
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestPhoneLoginE2E 手机号+密码 登录全链路(B4 / T4 合并门禁):
|
|
|
|
|
+// phone 作为与 username 对称的第三种账号标识,走同一套两步①②协议 + 统一 RS256 token。
|
|
|
|
|
+// ① POST /auth/login {"phone","password"} → 真查 t_identity(phone)+argon2 验密 → 200 + loginTicket
|
|
|
|
|
+// ② POST /auth/issue → 200 + accessToken(本公钥可验签)
|
|
|
|
|
+// ③ GET /auth/me(验签 + scope 注入)→ 200 + 正确用户
|
|
|
|
|
+// ④ 重放票据 → 1001(防重放)
|
|
|
|
|
+func TestPhoneLoginE2E(t *testing.T) {
|
|
|
|
|
+ a := e2eSetup(t)
|
|
|
|
|
+ uid := a.seedPhoneUser("13800001234", "e2e-pass-123", 11, 3, "teacher")
|
|
|
|
|
+
|
|
|
|
|
+ // ① login:phone 走 IdentityTypePhone 真查 t_identity + argon2 验密 → 200 + loginTicket
|
|
|
|
|
+ step1 := a.post(`/auth/login`, `{"phone":"13800001234","password":"e2e-pass-123"}`)
|
|
|
|
|
+ if step1.Code != 200 {
|
|
|
|
|
+ t.Fatalf("phone login code=%d msg=%s(期望 200)", step1.Code, step1.Message)
|
|
|
|
|
+ }
|
|
|
|
|
+ var l1 struct {
|
|
|
|
|
+ LoginTicket string `json:"loginTicket"`
|
|
|
|
|
+ User struct {
|
|
|
|
|
+ ID string `json:"id"`
|
|
|
|
|
+ } `json:"user"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(step1.Data, &l1); err != nil {
|
|
|
|
|
+ t.Fatalf("解析 login data: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if l1.User.ID != uid {
|
|
|
|
|
+ t.Fatalf("phone login user.id=%q(期望 %q)", l1.User.ID, uid)
|
|
|
|
|
+ }
|
|
|
|
|
+ if l1.LoginTicket == "" {
|
|
|
|
|
+ t.Fatal("loginTicket 缺失")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ② issue:真查 t_membership + 签 token → 200 + accessToken
|
|
|
|
|
+ issueRes := a.post(`/auth/issue`, `{"loginTicket":"`+l1.LoginTicket+`","institutionId":11,"campusId":3}`)
|
|
|
|
|
+ if issueRes.Code != 200 {
|
|
|
|
|
+ t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message)
|
|
|
|
|
+ }
|
|
|
|
|
+ var pair struct {
|
|
|
|
|
+ AccessToken string `json:"accessToken"`
|
|
|
|
|
+ Roles []string `json:"roles"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
|
|
|
|
|
+ t.Fatalf("解析 issue data: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if pair.AccessToken == "" || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
|
|
|
|
|
+ t.Fatalf("issue pair=%+v(期望 accessToken + roles=[teacher])", pair)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // access 可被本公钥验签 + claims 正确
|
|
|
|
|
+ claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("验签 access 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
|
|
|
|
|
+ t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户
|
|
|
|
|
+ meRes := a.getBearer(`/auth/me`, pair.AccessToken)
|
|
|
|
|
+ if meRes.Code != 200 {
|
|
|
|
|
+ t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message)
|
|
|
|
|
+ }
|
|
|
|
|
+ var me struct{ ID string `json:"id"` }
|
|
|
|
|
+ if err := json.Unmarshal(meRes.Data, &me); err != nil {
|
|
|
|
|
+ t.Fatalf("解析 me data: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if me.ID != uid {
|
|
|
|
|
+ t.Fatalf("me.id=%q(期望 %q)", me.ID, uid)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001
|
|
|
|
|
+ if replay := a.post(`/auth/issue`, `{"loginTicket":"`+l1.LoginTicket+`","institutionId":11,"campusId":3}`); replay.Code != 1001 {
|
|
|
|
|
+ t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestPhoneLoginE2E_BadParam 非法手机号(非 11 位):Service 层 isValidPhone 拦截 → 1001/2001 统一处理。
|
|
|
|
|
+// 注意:11 位校验在 Service(ErrBadParam→2001);handler 层 max=11 兜底拦更长串。这里用 10 位号验证 Service 路径。
|
|
|
|
|
+func TestPhoneLoginE2E_BadParam(t *testing.T) {
|
|
|
|
|
+ a := e2eSetup(t)
|
|
|
|
|
+ a.seedPhoneUser("13800001234", "e2e-pass-123", 11, 3, "teacher")
|
|
|
|
|
+ // 10 位手机号:过 handler max=11,但 Service isValidPhone 判非 11 位 → ErrBadParam(2001)
|
|
|
|
|
+ res := a.post(`/auth/login`, `{"phone":"1380000123","password":"e2e-pass-123"}`)
|
|
|
|
|
+ if res.Code != 2001 {
|
|
|
|
|
+ t.Fatalf("code=%d msg=%s(期望 2001 手机号位数不合法)", res.Code, res.Message)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// seedWechatUser 落一个用户 + wechat 身份(type=wechat, identifier=openid)+ 一条 membership(raw SQL)。
|
|
// seedWechatUser 落一个用户 + wechat 身份(type=wechat, identifier=openid)+ 一条 membership(raw SQL)。
|
|
|
// 与 seedUser 的差异:无密码哈希(微信登录不验密)、身份类型为 wechat(扫码绑定的 openid)。
|
|
// 与 seedUser 的差异:无密码哈希(微信登录不验密)、身份类型为 wechat(扫码绑定的 openid)。
|
|
|
func (a *e2eApp) seedWechatUser(openid string, inst, campus int64, role string) string {
|
|
func (a *e2eApp) seedWechatUser(openid string, inst, campus int64, role string) string {
|