package auth import ( "encoding/json" "fmt" "io" "strings" "time" "server/models" "server/pkg/jwtutil" authsvc "server/services/auth" beego "github.com/beego/beego/v2/server/web" ) // ============================================================= // 移动端免登录票据(把移动端登录态桥接到 Web 端) // // POST /auth/app-ticket 移动端带 Bearer 令牌 → 签发一次性票据 // POST /auth/app-ticket/exchange Web 端带票据 → 核销并换发 Web 端令牌 // // 仅用于「移动端已登录 → 打开 Web 端功能页」这一条链路, // 票据一次性 + 60 秒有效,泄露也无法二次使用。 // ============================================================= // AuthAppTicketController 移动端免登录票据 type AuthAppTicketController struct { beego.Controller } func (c *AuthAppTicketController) serveJSON(data map[string]interface{}) { c.Data["json"] = data _ = c.ServeJSON() } func (c *AuthAppTicketController) fail(msg string) { c.Ctx.Output.SetStatus(400) c.serveJSON(map[string]interface{}{"code": 400, "msg": msg}) } // ticketClaims 解析 Bearer 令牌;移动端是 user_type=app,Web 端是 backend func (c *AuthAppTicketController) ticketClaims() (*jwtutil.Claims, error) { header := c.Ctx.Request.Header.Get("Authorization") if header == "" { return nil, fmt.Errorf("未登录") } parts := strings.SplitN(header, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { return nil, fmt.Errorf("认证信息格式错误") } claims, err := jwtutil.ParseToken(parts[1]) if err != nil { return nil, fmt.Errorf("无效的token") } if claims.UserType != "app" && claims.UserType != "backend" { return nil, fmt.Errorf("无权访问") } return claims, nil } // Create POST /auth/app-ticket // 移动端用当前登录态换取一次性票据,Web 端拿票据换自己的令牌。 func (c *AuthAppTicketController) Create() { claims, err := c.ticketClaims() if err != nil { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()}) return } var req struct { ClientID string `json:"client_id"` } if body := c.Ctx.Input.RequestBody; len(body) > 0 { _ = json.Unmarshal(body, &req) } // 票据是给 Web 端兑换用的,默认按 Web 端应用签发 clientID := strings.TrimSpace(req.ClientID) if clientID == "" { clientID = "yz-backend" } tid := uint64(0) if claims.TenantId > 0 { tid = uint64(claims.TenantId) } ticket, err := authsvc.CreateAppTicket(uint64(claims.UserID), tid, clientID) if err != nil { c.fail("签发票据失败:" + err.Error()) return } c.serveJSON(map[string]interface{}{ "code": 200, "msg": "success", "data": map[string]interface{}{ "ticket": ticket, "expire_in": int(authsvc.AppTicketTTL / time.Second), }, }) } // Exchange POST /auth/app-ticket/exchange // Web 端核销票据并换发本端令牌(无鉴权:票据本身即凭证,一次性 + 短时效)。 func (c *AuthAppTicketController) Exchange() { body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } var req struct { Ticket string `json:"ticket"` Token string `json:"token"` // 兼容 mp-login 的命名 ClientID string `json:"client_id"` } if err := json.Unmarshal(body, &req); err != nil { c.fail("参数错误") return } ticket := strings.TrimSpace(req.Ticket) if ticket == "" { ticket = strings.TrimSpace(req.Token) } row, err := authsvc.ConsumeAppTicket(ticket) if err != nil { c.fail(err.Error()) return } // 身份校验:禁用/锁定的账号不签发 var identity models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", row.IdentityID). Filter("delete_time__isnull", true). One(&identity); err != nil { c.fail("账号不存在或已注销") return } if identity.Status != models.AuthIdentityStatusEnabled { c.fail("账号已被禁用或锁定,无法登录") return } username := "温新云用户" if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" { username = strings.TrimSpace(*identity.Nickname) } clientID := strings.TrimSpace(req.ClientID) if clientID == "" { clientID = row.ClientID } if clientID == "" { clientID = "yz-backend" } // 未确定企业(多企业)时签发待选令牌,由 Web 端展示企业选择 tid := row.Tid needChoose := false if tid == 0 { tenants, lerr := authsvc.ListTenantOptions(row.IdentityID) if lerr != nil || len(tenants) == 0 { c.fail("该账号未加入任何企业,无法登录") return } if len(tenants) == 1 { tid = tenants[0].Tid } else { tid = authsvc.PendingTenantID needChoose = true } } tokens, terr := c.issueTicketTokens(row.IdentityID, tid, clientID, username, needChoose) if terr != nil { c.fail(terr.Error()) return } data := map[string]interface{}{ "type": "backend", "tokens": tokens, "tid": tid, } if needChoose { tenants, _ := authsvc.ListTenantOptions(row.IdentityID) data["need_choose_tenant"] = true data["tenants"] = tenants } c.serveJSON(map[string]interface{}{"code": 200, "msg": "登录成功", "data": data}) } // issueTicketTokens 建会话 + 签发令牌 + 登录日志(失败不影响主流程) func (c *AuthAppTicketController) issueTicketTokens(identityID, tid uint64, clientID, username string, needChoose bool) (*authsvc.TokenPair, error) { accessTTL := 0 if needChoose { accessTTL = 600 // 待选状态令牌只够用户完成企业选择 } sess, serr := authsvc.CreateSession(authsvc.SessionInfo{ IdentityID: identityID, Tid: tid, ClientID: clientID, IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), LoginType: "app_ticket", Amr: "app_ticket", }) if serr != nil { return nil, fmt.Errorf("登录失败:%s", serr.Error()) } tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{ IdentityID: identityID, Tid: tid, ClientID: clientID, Sid: sess.Sid, Username: username, UserType: "backend", // Web 端业务接口普遍要求 backend Amr: "app_ticket", AccessTTL: accessTTL, }) if terr != nil { return nil, fmt.Errorf("签发令牌失败:%s", terr.Error()) } identityIDCopy := identityID tidCopy := tid amr := "app_ticket" _, _ = models.Orm.Insert(&models.AuthLoginLog{ Tid: &tidCopy, IdentityID: &identityIDCopy, UserName: username, ClientID: clientID, LoginType: "app_ticket", Amr: &amr, Status: 1, Message: "移动端票据免登录", IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), }) return tokens, nil }