package auth import ( "encoding/base64" "encoding/json" "fmt" "io" "net/url" "strings" authsvc "server/services/auth" "server/models" "server/pkg/jwtutil" beego "github.com/beego/beego/v2/server/web" "github.com/beego/beego/v2/server/web/context" ) // AuthLoginController 认证中心登录相关端点 type AuthLoginController struct { beego.Controller } func (c *AuthLoginController) serveJSON(data map[string]interface{}) { c.Data["json"] = data _ = c.ServeJSON() } // LoginPage 统一登录页(beego 模板渲染) // GET /auth/login?redirect=&client_id=xxx&step=tenant func (c *AuthLoginController) LoginPage() { clientID := strings.TrimSpace(c.GetString("client_id")) redirect := c.GetString("redirect") step := c.GetString("step") errMsg := c.GetString("error") // 已登录且已选企业:直接放行到 authorize,无需再输密码 sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) if sid != "" && step != "tenant" { if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID { if back := decodeRedirect(redirect); back != "" { c.Redirect(back, 302) return } } } // 企业选择步骤:需要展示可进入的企业列表 tenants := make([]authsvc.TenantOption, 0) if sid != "" { if session, err := authsvc.GetSession(sid); err == nil { list, _ := authsvc.ListTenantOptions(session.IdentityID) tenants = list // 只有一家企业直接进入,无需展示选择页 if len(tenants) == 1 && step == "tenant" { if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil { if back := decodeRedirect(redirect); back != "" { c.Redirect(back, 302) return } } } } } c.Data["ClientID"] = clientID c.Data["Redirect"] = redirect c.Data["Step"] = step c.Data["Error"] = errMsg c.Data["Tenants"] = tenants c.TplName = "auth/login.tpl" } // LoginSubmit 提交登录 // POST /auth/login { account, password, client_id, redirect } func (c *AuthLoginController) LoginSubmit() { var req struct { Account string `json:"account"` Password string `json:"password"` ClientID string `json:"client_id"` Redirect string `json:"redirect"` DeviceID string `json:"device_id"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { var err error body, err = io.ReadAll(c.Ctx.Request.Body) if err != nil || len(body) == 0 { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return } } if err := json.Unmarshal(body, &req); err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return } if strings.TrimSpace(req.Account) == "" || req.Password == "" { c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入账号和密码"}) return } clientIP := c.Ctx.Input.IP() userAgent := c.Ctx.Request.UserAgent() result, err := authsvc.PasswordLogin(req.Account, req.Password, req.ClientID, clientIP, userAgent) if err != nil { c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()}) return } // 写入认证中心会话 Cookie,供后续 authorize 识别登录态 setSessionCookieForCtx(c.Ctx, result.Tokens.Sid) if result.NeedChooseTenant { c.serveJSON(map[string]interface{}{ "code": 200, "msg": "请选择要进入的企业", "need_choose_tenant": true, "tenants": result.Tenants, "sid": result.Tokens.Sid, }) return } back := decodeRedirect(req.Redirect) c.serveJSON(map[string]interface{}{ "code": 200, "msg": "登录成功", "redirect": back, "tokens": result.Tokens, "tenants": result.Tenants, }) } // Tenants 当前登录身份可进入的企业列表(Bearer Token) // GET /auth/tenants func (c *AuthLoginController) Tenants() { claims := claimsFromHeaderLogin(c) if claims == nil { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } list, err := authsvc.ListTenantOptions(uint64(claims.UserID)) if err != nil { c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"}) return } c.serveJSON(map[string]interface{}{"code": 200, "data": list}) } // SwitchTenant 免密切换企业 // POST /auth/switch-tenant { tid, client_id, redirect } func (c *AuthLoginController) SwitchTenant() { var req struct { Tid uint64 `json:"tid"` ClientID string `json:"client_id"` Redirect string `json:"redirect"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } _ = json.Unmarshal(body, &req) sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) if sid == "" { if claims := claimsFromHeaderLogin(c); claims != nil { sid = claims.Sid } } if sid == "" || req.Tid == 0 { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return } tokens, profile, err := authsvc.ChooseTenant(sid, req.Tid, req.ClientID) if err != nil { c.serveJSON(map[string]interface{}{"code": 403, "msg": err.Error()}) return } setSessionCookieForCtx(c.Ctx, tokens.Sid) c.serveJSON(map[string]interface{}{ "code": 200, "msg": "切换成功", "redirect": decodeRedirect(req.Redirect), "data": map[string]interface{}{"tokens": tokens, "identity": profile}, }) } // LogoutPage 单点登出入口(OIDC end_session_endpoint) // GET /auth/logout?post_logout_redirect_uri=&client_id= // // 清除认证中心 Cookie 并吊销会话与令牌;回跳地址必须在应用白名单内。 // 登记了 backchannel_logout_uri 的应用会收到服务端登出通知(P2 阶段接入)。 func (c *AuthLoginController) LogoutPage() { sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) access := bearerTokenLogin(c) if sid != "" { _ = authsvc.RevokeSession(sid, models.RevokeReasonLogout) } // 走统一登出入口:吊销令牌并通知其他应用(单点登出) if access != "" { _ = authsvc.Logout(access, "") } clearSessionCookieForCtx(c.Ctx) back := strings.TrimSpace(c.GetString("post_logout_redirect_uri")) cid := strings.TrimSpace(c.GetString("client_id")) if back != "" && cid != "" { if client, err := findClient(cid); err == nil && allowLogoutRedirect(client, back) { c.Redirect(back, 302) return } // 回跳地址未登记时不要直接抛 JSON 给用户,退回登录页 target := "/auth/login?error=" + url.QueryEscape("登出回跳地址未登记,已返回登录页") if cid != "" { target += "&client_id=" + url.QueryEscape(cid) } c.Redirect(target, 302) return } // 浏览器直接访问(无回跳参数):跳登录页,避免页面显示裸 JSON target := "/auth/login" if cid != "" { target += "?client_id=" + url.QueryEscape(cid) } c.Redirect(target, 302) } // Sessions 在线设备列表 // GET /auth/sessions func (c *AuthLoginController) Sessions() { claims := claimsFromHeaderLogin(c) if claims == nil { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } list, err := authsvc.ListActiveSessions(uint64(claims.UserID)) if err != nil { c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"}) return } c.serveJSON(map[string]interface{}{"code": 200, "data": list}) } // KickSession 踢下线指定设备 // POST /auth/sessions/kick { sid } func (c *AuthLoginController) KickSession() { var req struct { Sid string `json:"sid"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } _ = json.Unmarshal(body, &req) claims := claimsFromHeaderLogin(c) if claims == nil { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } if req.Sid == "" { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return } // 只允许踢自己名下的会话 owned := false if list, err := authsvc.ListActiveSessions(uint64(claims.UserID)); err == nil { for _, s := range list { if s.Sid == req.Sid { owned = true break } } } if !owned { c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权操作"}) return } if err := authsvc.RevokeSession(req.Sid, models.RevokeReasonAdmin); err != nil { c.serveJSON(map[string]interface{}{"code": 500, "msg": "操作失败"}) return } c.serveJSON(map[string]interface{}{"code": 200, "msg": "已下线"}) } // LogoutAction 退出登录(清 Cookie + 吊销会话与令牌) // POST /auth/logout func (c *AuthLoginController) LogoutAction() { sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) access := "" if claims := claimsFromHeaderLogin(c); claims != nil { if sid == "" { sid = claims.Sid } access = bearerTokenLogin(c) } _ = authsvc.Logout(access, "") if sid != "" { _ = authsvc.RevokeSession(sid, models.RevokeReasonLogout) } clearSessionCookieForCtx(c.Ctx) c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"}) } // VerifyConfig 租户登录验证配置(替代 /backend/login/getOpenVerify) // GET /auth/verify-config?tid= func (c *AuthLoginController) VerifyConfig() { tid, _ := c.GetInt64("tid", 0) cfg := authsvc.GetTenantSessionPolicy(uint64(tid)) var authCfg models.AuthTenantAuthConfig verifyType := "captcha" openVerify := 1 if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)). Filter("tid", tid).One(&authCfg); err == nil { verifyType = authCfg.VerifyType openVerify = int(authCfg.OpenVerify) } c.serveJSON(map[string]interface{}{ "code": 200, "data": map[string]interface{}{ "openVerify": openVerify, "verifyType": verifyType, "sessionTTL": cfg.SessionTTL, "maxSession": cfg.MaxSession, "mfaRequired": 0, }, }) } // ---------------------------------------------------------------- 工具 func decodeRedirect(encoded string) string { if encoded == "" { return "" } raw, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return "" } back := string(raw) // 只允许站内地址,防开放重定向 if !strings.HasPrefix(back, "/auth/") && !strings.HasPrefix(back, "http") { return "" } return back } func claimsFromHeaderLogin(c *AuthLoginController) *jwtutil.Claims { token := bearerTokenLogin(c) if token == "" { return nil } claims, err := jwtutil.ParseToken(token) if err != nil { return nil } return claims } func bearerTokenLogin(c *AuthLoginController) string { header := c.Ctx.Request.Header.Get("Authorization") if header == "" { return "" } parts := strings.SplitN(header, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { return "" } return strings.TrimSpace(parts[1]) } func setSessionCookieForCtx(ctx *context.Context, sid string) { ctx.Output.Header("Set-Cookie", fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax", sessionCookieName, sid, sessionCookieTTL)) } func clearSessionCookieForCtx(ctx *context.Context) { ctx.Output.Header("Set-Cookie", fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName)) }