390 lines
11 KiB
Go
390 lines
11 KiB
Go
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")
|
||
|
||
// 已登录且已选企业:直接放行到 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))
|
||
}
|