Files
yunzerwebsiteallinone/go/controllers/auth/app_ticket.go
T
2026-11-11 16:58:30 +08:00

244 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}