权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝): - 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免 - 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*) - 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝 后端: - 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD - RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry - /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限 - 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库 前端: - useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫 - 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选) - 登录页记住密码;GUIDE/API 文档补权限章节
169 lines
5.1 KiB
Go
169 lines
5.1 KiB
Go
package handler
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"flowstack/internal/crypto"
|
||
"flowstack/internal/database"
|
||
"flowstack/internal/model"
|
||
)
|
||
|
||
const tokenTTL = 7 * 24 * time.Hour
|
||
|
||
// sessionSecret 从 data/encryption.key 派生会话签名密钥
|
||
func sessionSecret() []byte {
|
||
key := os.Getenv("FLOWSTACK_ENCRYPTION_KEY")
|
||
if key == "" {
|
||
if data, err := os.ReadFile(filepath.Join(database.DataDir, "encryption.key")); err == nil && len(data) == 32 {
|
||
key = string(data)
|
||
}
|
||
}
|
||
sum := sha256.Sum256([]byte("flowstack-session:" + key))
|
||
return sum[:]
|
||
}
|
||
|
||
// SignToken 签发 HMAC 无状态 token:base64(user|exp|sig)
|
||
func SignToken(username string) (string, time.Time) {
|
||
exp := time.Now().Add(tokenTTL)
|
||
payload := username + "|" + strconv.FormatInt(exp.Unix(), 10)
|
||
mac := hmac.New(sha256.New, sessionSecret())
|
||
mac.Write([]byte(payload))
|
||
sig := hex.EncodeToString(mac.Sum(nil))
|
||
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig)), exp
|
||
}
|
||
|
||
// VerifyToken 校验 token,返回用户名
|
||
func VerifyToken(token string) (string, error) {
|
||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||
if err != nil {
|
||
return "", fmt.Errorf("token 格式错误")
|
||
}
|
||
parts := strings.Split(string(raw), "|")
|
||
if len(parts) != 3 {
|
||
return "", fmt.Errorf("token 结构错误")
|
||
}
|
||
payload := parts[0] + "|" + parts[1]
|
||
mac := hmac.New(sha256.New, sessionSecret())
|
||
mac.Write([]byte(payload))
|
||
expect := hex.EncodeToString(mac.Sum(nil))
|
||
if !hmac.Equal([]byte(expect), []byte(parts[2])) {
|
||
return "", fmt.Errorf("token 签名校验失败")
|
||
}
|
||
expUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||
if err != nil || time.Now().Unix() > expUnix {
|
||
return "", fmt.Errorf("token 已过期")
|
||
}
|
||
return parts[0], nil
|
||
}
|
||
|
||
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook);SPA 与静态资源放行。
|
||
// SSE 日志流(*/logs/stream)的 EventSource 无法携带 Authorization 头,额外接受 ?token= 查询参数。
|
||
func AuthMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
p := c.Request.URL.Path
|
||
if p == "/health" || !strings.HasPrefix(p, "/api/") ||
|
||
p == "/api/auth/login" ||
|
||
strings.HasPrefix(p, "/api/hooks/") {
|
||
c.Next()
|
||
return
|
||
}
|
||
token := ""
|
||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||
token = strings.TrimPrefix(auth, "Bearer ")
|
||
} else if strings.HasSuffix(p, "/logs/stream") {
|
||
token = c.Query("token")
|
||
}
|
||
if token == "" {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||
return
|
||
}
|
||
username, err := VerifyToken(token)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"})
|
||
return
|
||
}
|
||
var user model.User
|
||
if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||
return
|
||
}
|
||
c.Set("username", user.Username)
|
||
c.Set("role", user.Role)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// Me GET /api/me —— 当前会话信息(前端菜单分级与按钮权限依据)
|
||
func Me(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"username": c.GetString("username"),
|
||
"role": c.GetString("role"),
|
||
"isAdmin": c.GetString("role") == "admin",
|
||
})
|
||
}
|
||
|
||
// AdminOnly 管理员专用接口守卫
|
||
func AdminOnly() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if c.GetString("role") != "admin" {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// Login POST /api/auth/login —— 本地 argon2id 与 LDAP 分流认证
|
||
func Login(c *gin.Context) {
|
||
var body struct {
|
||
Username string `json:"username" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
|
||
return
|
||
}
|
||
|
||
// 1) 本地用户:AuthSource != ldap 才走本地口令
|
||
var user model.User
|
||
hasLocal := database.DB.Where("username = ?", body.Username).First(&user).Error == nil
|
||
if hasLocal && user.AuthSource != "ldap" {
|
||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||
return
|
||
}
|
||
token, exp := SignToken(user.Username)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "token": token, "expiresAt": exp,
|
||
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||
})
|
||
return
|
||
}
|
||
|
||
// 2) LDAP:目录验证成功即登录(首次自动建号/同步)
|
||
if entry, err := ldapVerifyUser(body.Username, body.Password); err == nil {
|
||
user := syncLDAPUser(entry)
|
||
token, exp := SignToken(user.Username)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "token": token, "expiresAt": exp,
|
||
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||
})
|
||
return
|
||
}
|
||
|
||
// 统一错误,不泄露用户是否存在
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||
}
|