Files
yunzerwebsiteallinone/go/controllers/auth/login.go
T

526 lines
17 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/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=<base64(回跳URL)>&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")
// 已登录且已选企业:直接放行,无需再输密码。
// 即使 redirect 丢失也要放行到业务站首页,否则会一直停留在登录页,
// 表现为「登录后还是跳回登录界面」。
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid != "" && step != "tenant" {
if session, err := authsvc.GetSession(sid); err == nil {
if session.Tid == authsvc.PendingTenantID {
// 已登录但还没选企业:直接带到企业选择步骤,
// 不要再渲染一次登录表单让用户重新登录。
target := "/auth/login?step=tenant&client_id=" + url.QueryEscape(clientID) +
"&redirect=" + url.QueryEscape(redirect)
c.Redirect(target, 302)
return
}
if back := safeBackURL(clientID, redirect); back != "" {
c.Redirect(back, 302)
return
}
} else {
// 会话已被吊销/过期:清掉失效 Cookie,避免后续请求继续带上它
clearSessionCookieForCtx(c.Ctx)
sid = ""
}
}
// 企业选择步骤:需要展示可进入的企业列表
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 := safeBackURL(clientID, redirect); back != "" {
c.Redirect(back, 302)
return
}
}
}
} else if step == "tenant" {
// 选企业时会话已失效:不要渲染一个空的企业列表,退回登录页重新登录
clearSessionCookieForCtx(c.Ctx)
c.Redirect("/auth/login?client_id="+url.QueryEscape(clientID), 302)
return
}
}
c.Data["ClientID"] = clientID
c.Data["Redirect"] = redirect
c.Data["Step"] = step
c.Data["Error"] = errMsg
c.Data["Tenants"] = tenants
// 自助账户流程提示:registered=注册成功 / registered_warn=注册成功但加入企业失败 / reset=密码已重置
c.Data["Notice"] = c.GetString("notice")
c.Data["WarnMsg"] = c.GetString("warn")
// 登录页品牌:platform「平台设置 → 登录页设置」里的配置,未配置时用默认文案。
// 读取失败不阻断登录页,GetPlatformSiteTagMap 会返回空值。
brand := models.GetPlatformSiteTagMap()
loginTitle := strings.TrimSpace(brand["login_title"])
if loginTitle == "" {
loginTitle = "一次登录,全平台通行"
}
loginSubtitle := strings.TrimSpace(brand["login_subtitle"])
if loginSubtitle == "" {
loginSubtitle = "基于 OIDC / OAuth 2.0 的统一身份底座,为所有接入应用提供安全、一致的登录体验。"
}
c.Data["LoginTitle"] = loginTitle
c.Data["LoginSubtitle"] = loginSubtitle
c.Data["LoginLogo"] = strings.TrimSpace(brand["login_logo"])
c.Data["LoginBg"] = strings.TrimSpace(brand["login_bg"])
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"`
// 极验 4.0 验证结果:开启人机验证后由前端 SDK 产出,验证通过才允许继续登录
CaptchaID string `json:"captcha_id"`
LotNumber string `json:"lot_number"`
PassToken string `json:"pass_token"`
GenTime string `json:"gen_time"`
CaptchaOutput string `json:"captcha_output"`
}
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
}
// 开启人机验证时:先极验通过,再走账号密码校验
// 小程序端(yz-miniapp)无法集成极验 SDK 且运行于微信沙箱内,跳过人机验证
if strings.TrimSpace(req.ClientID) != "yz-miniapp" {
if err := authsvc.VerifyLoginCaptcha(0, authsvc.CaptchaResult{
CaptchaID: req.CaptchaID,
LotNumber: req.LotNumber,
PassToken: req.PassToken,
GenTime: req.GenTime,
CaptchaOutput: req.CaptchaOutput,
}); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
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 {
// 待选令牌必须一并下发:APP / 小程序等无 Cookie 环境的客户端
// 只能靠 Bearer 令牌调 /auth/switch-tenant,浏览器端则继续用 yz_sid Cookie。
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "请选择要进入的企业",
"need_choose_tenant": true,
"tenants": result.Tenants,
"tokens": result.Tokens,
"sid": result.Tokens.Sid,
})
return
}
back := safeBackURL(req.ClientID, 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, sid }
//
// sid 来源优先级:yz_sid Cookie → Authorization Bearer 令牌中的 sid → 请求体 sid。
// 请求体 sid 供无 Cookie 能力(且尚未拿到 Bearer 令牌)的客户端兜底。
func (c *AuthLoginController) SwitchTenant() {
var req struct {
Tid uint64 `json:"tid"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
Sid string `json:"sid"`
}
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 == "" {
sid = strings.TrimSpace(req.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": safeBackURL(req.ClientID, 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": "已登出"})
}
// ClearSiteData 清除当前站点在浏览器中的全部缓存数据
// POST /auth/clear-site-data
//
// 登录页「清除缓存」按钮调用。前端 JS 已清理同源的 Web Storage / Cache
// Storage / IndexedDB / Service Worker,但 yz_sid 是 HttpOnly Cookie,前端
// 读不到也删不掉,因此这里:
// - 用 Set-Cookie 直接删除会话 Cookie(兼容所有浏览器);
// - 返回 Clear-Site-Data 响应头,让浏览器在安全上下文(HTTPS / localhost)
// 下把本 origin 的 cookies / storage / HTTP cache 一并清空。
//
// Clear-Site-Data 严格限定在响应所属 origin,不会影响其它站点。
func (c *AuthLoginController) ClearSiteData() {
// Cookie 被清后服务端会话将无人使用,同步吊销,避免留下仍有效的孤儿会话
// 占用「1 号 1 机」名额。
if sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)); sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
clearSessionCookieForCtx(c.Ctx)
c.Ctx.Output.Header("Clear-Site-Data", `"cache", "cookies", "storage"`)
c.Ctx.Output.Header("Cache-Control", "no-store")
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已清除"})
}
// VerifyConfig 登录验证配置(替代 /backend/login/getOpenVerify)
// GET /auth/verify-config?tid=
//
// 登录页在选企业之前调用,tid 可为空:此时以平台全局配置为准。
// 返回的人机验证信息供登录页决定是否在点击登录后弹出极验。
func (c *AuthLoginController) VerifyConfig() {
tid, _ := c.GetInt64("tid", 0)
sessionCfg := authsvc.GetTenantSessionPolicy(uint64(tid))
verify := authsvc.GetLoginVerifyConfig(uint64(tid))
openVerify := 0
if verify.OpenVerify {
openVerify = 1
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"openVerify": openVerify,
"verifyType": verify.VerifyType,
"needGeetest": verify.NeedGeetest(),
"geetestVersion": verify.GeetestVersion,
"captchaId": verify.CaptchaID,
"sessionTTL": sessionCfg.SessionTTL,
"maxSession": sessionCfg.MaxSession,
"mfaRequired": 0,
},
})
}
// ---------------------------------------------------------------- 工具
// safeBackURL 计算登录成功后的回跳地址。
//
// 优先使用业务端传来的 authorize 地址(base64url 编码);若链路中 redirect
// 丢失(直接打开登录页、单点登出后再次登录、选企业时参数未透传等),则退回
// 该应用已登记回跳地址所在的站点首页。业务站没有令牌时会自动重新发起 OIDC,
// 认证中心此时已有会话可静默签码完成登录,不会卡在登录页形成循环。
func safeBackURL(clientID, redirect string) string {
if back := decodeRedirect(redirect); back != "" {
return back
}
client, err := findClient(strings.TrimSpace(clientID))
if err != nil || client.RedirectURIs == nil {
return ""
}
var list []string
if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil {
return ""
}
for _, item := range list {
u, err := url.Parse(strings.TrimSpace(item))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
continue
}
return u.Scheme + "://" + u.Host + "/"
}
return ""
}
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) {
// Secure 仅在 https 下添加:本地 http 联调时浏览器会丢弃 Secure Cookie,
// 导致 authorize 识别不到会话又被送回登录页
flag := cookieSecureFlag(ctx)
ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly%s; SameSite=Lax",
sessionCookieName, sid, sessionCookieTTL, flag))
}
func clearSessionCookieForCtx(ctx *context.Context) {
flag := cookieSecureFlag(ctx)
ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly%s; SameSite=Lax", sessionCookieName, flag))
}
// cookieSecureFlag https 请求(或网关带 X-Forwarded-Proto: https)时返回 "; Secure"
func cookieSecureFlag(ctx *context.Context) string {
if ctx.Request.TLS != nil || strings.EqualFold(ctx.Request.Header.Get("X-Forwarded-Proto"), "https") {
return "; Secure"
}
return ""
}