chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Admin 管理员权限中间件,检查 context 中 user 的 role 是否为 admin
|
||||
func Admin() gin.HandlerFunc {
|
||||
return AdminMiddleware()
|
||||
}
|
||||
|
||||
// AdminMiddleware 管理员权限中间件,检查 context 中 user 的 role 是否为 admin
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
userMap, ok := user.(map[string]interface{})
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := userMap["role"].(string)
|
||||
if !ok || role != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTClaims JWT 负载,与 NestJS 签发格式一致
|
||||
type JWTClaims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TokenType string `json:"tokenType,omitempty"` // access | refresh,空值视为 access(兼容旧令牌)
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// 白名单路径,无需鉴权
|
||||
var whitelist = map[string]bool{
|
||||
"/api/v1/health": true,
|
||||
"/health": true,
|
||||
"/api/health": true,
|
||||
"/api/v1/auth/login": true,
|
||||
"/auth/login": true,
|
||||
"/api/v1/auth/register": true,
|
||||
"/auth/register": true,
|
||||
"/api/v1/auth/refresh": true,
|
||||
"/auth/refresh": true,
|
||||
"/api/v1/video/clips/internal": true,
|
||||
"/api/v1/video/recordings/internal/end": true,
|
||||
}
|
||||
|
||||
// Auth JWT 鉴权中间件
|
||||
func Auth(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 白名单路径跳过鉴权
|
||||
if whitelist[path] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行
|
||||
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 直播流转码代理接口
|
||||
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Authorization: Bearer <token>
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未提供认证信息"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
|
||||
// 用 JWTSecret 验证 token,仅允许 HS256 算法
|
||||
claims := &JWTClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验令牌是否已被登出吊销
|
||||
if IsRevoked(claims) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"})
|
||||
return
|
||||
}
|
||||
// 拒绝使用 refresh 令牌访问业务接口
|
||||
if claims.TokenType == "refresh" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入 context
|
||||
c.Set("user", map[string]interface{}{
|
||||
"sub": claims.Subject,
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
})
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件,限制允许的来源、方法和头
|
||||
func CORS() gin.HandlerFunc {
|
||||
// 允许的来源:同源、本地开发、Tailscale 网段、局域网
|
||||
allowedOrigins := []string{
|
||||
"http://localhost:5174",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://127.0.0.1:3000",
|
||||
}
|
||||
|
||||
isAllowed := func(origin string) bool {
|
||||
for _, o := range allowedOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 允许 Tailscale 100.x.x.x 和局域网 192.168.x.x 访问
|
||||
if strings.HasPrefix(origin, "http://100.") || strings.HasPrefix(origin, "http://192.168.") ||
|
||||
strings.HasPrefix(origin, "http://113.") || strings.HasPrefix(origin, "http://115.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && isAllowed(origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Logger 请求日志中间件,记录方法、路径、状态码、耗时
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
slog.Info("请求",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency.String(),
|
||||
"ip", c.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rolePermCache 角色-权限码缓存,避免每次请求查库
|
||||
var (
|
||||
rolePermCache = make(map[string]map[string]bool)
|
||||
rolePermCacheMu sync.RWMutex
|
||||
rolePermCacheTime time.Time
|
||||
)
|
||||
|
||||
const rolePermCacheTTL = 5 * time.Minute
|
||||
|
||||
// loadRolePermissions 从数据库加载所有角色-权限映射到缓存
|
||||
func loadRolePermissions(db *gorm.DB) map[string]map[string]bool {
|
||||
result := make(map[string]map[string]bool)
|
||||
|
||||
var rows []struct {
|
||||
Role string `gorm:"column:role"`
|
||||
Code string `gorm:"column:code"`
|
||||
}
|
||||
db.Table("role_permissions").
|
||||
Select("role_permissions.role AS role, permissions.code AS code").
|
||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||
Scan(&rows)
|
||||
|
||||
for _, r := range rows {
|
||||
if result[r.Role] == nil {
|
||||
result[r.Role] = make(map[string]bool)
|
||||
}
|
||||
result[r.Role][r.Code] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getRolePermissions 获取缓存的角色权限映射(TTL 5 分钟)
|
||||
func getRolePermissions(db *gorm.DB) map[string]map[string]bool {
|
||||
rolePermCacheMu.RLock()
|
||||
if time.Since(rolePermCacheTime) < rolePermCacheTTL && len(rolePermCache) > 0 {
|
||||
cached := rolePermCache
|
||||
rolePermCacheMu.RUnlock()
|
||||
return cached
|
||||
}
|
||||
rolePermCacheMu.RUnlock()
|
||||
|
||||
rolePermCacheMu.Lock()
|
||||
defer rolePermCacheMu.Unlock()
|
||||
// 双重检查
|
||||
if time.Since(rolePermCacheTime) < rolePermCacheTTL && len(rolePermCache) > 0 {
|
||||
return rolePermCache
|
||||
}
|
||||
rolePermCache = loadRolePermissions(db)
|
||||
rolePermCacheTime = time.Now()
|
||||
return rolePermCache
|
||||
}
|
||||
|
||||
// InvalidateRolePermCache 使角色权限缓存失效(角色权限变更时调用)
|
||||
func InvalidateRolePermCache() {
|
||||
rolePermCacheMu.Lock()
|
||||
defer rolePermCacheMu.Unlock()
|
||||
rolePermCache = make(map[string]map[string]bool)
|
||||
rolePermCacheTime = time.Time{}
|
||||
}
|
||||
|
||||
// hasPermission 检查角色是否拥有指定权限码
|
||||
func hasPermission(db *gorm.DB, role, code string) bool {
|
||||
perms := getRolePermissions(db)
|
||||
rolePerms, ok := perms[role]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return rolePerms[code]
|
||||
}
|
||||
|
||||
// RequirePermission 返回一个校验指定权限码的中间件
|
||||
func RequirePermission(db *gorm.DB, code string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
userMap, ok := userVal.(map[string]interface{})
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||
return
|
||||
}
|
||||
role, _ := userMap["role"].(string)
|
||||
if role == model.RoleAdmin {
|
||||
// admin 拥有全部权限,直接放行
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !hasPermission(db, role, code) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足,需要:" + code})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// loginAttempt 登录失败计数(按 IP + 用户名维度)
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
lockUntil time.Time
|
||||
lastFail time.Time
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]*loginAttempt
|
||||
}
|
||||
|
||||
const (
|
||||
maxFailures = 5 // 连续失败 5 次后锁定
|
||||
lockDuration = 15 * time.Minute
|
||||
failureWindow = 10 * time.Minute // 失败计数窗口
|
||||
cleanupInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
var defaultLoginLimiter = newLoginLimiter()
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
l := &loginLimiter{seen: make(map[string]*loginAttempt)}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *loginLimiter) cleanupLoop() {
|
||||
t := time.NewTicker(cleanupInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range l.seen {
|
||||
if now.After(v.lockUntil) && now.Sub(v.lastFail) > failureWindow {
|
||||
delete(l.seen, k)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// key = ip + "|" + username(小写)
|
||||
func limiterKey(c *gin.Context, username string) string {
|
||||
return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
// checkLock 返回是否被锁定及剩余锁定时间
|
||||
func (l *loginLimiter) checkLock(key string) (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.seen[key]
|
||||
if !ok {
|
||||
return false, 0
|
||||
}
|
||||
if time.Now().Before(a.lockUntil) {
|
||||
return true, time.Until(a.lockUntil)
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// recordFailure 记录一次失败,达到阈值则锁定
|
||||
func (l *loginLimiter) recordFailure(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.seen[key]
|
||||
if !ok {
|
||||
a = &loginAttempt{}
|
||||
l.seen[key] = a
|
||||
}
|
||||
now := time.Now()
|
||||
// 窗口外重置
|
||||
if now.Sub(a.lastFail) > failureWindow {
|
||||
a.failures = 0
|
||||
}
|
||||
a.failures++
|
||||
a.lastFail = now
|
||||
if a.failures >= maxFailures {
|
||||
a.lockUntil = now.Add(lockDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// recordSuccess 登录成功后清空计数
|
||||
func (l *loginLimiter) recordSuccess(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.seen, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用)
|
||||
func CheckLoginLock(c *gin.Context, username string) bool {
|
||||
key := limiterKey(c, username)
|
||||
if locked, remain := defaultLoginLimiter.checkLock(key); locked {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "登录尝试过多,已锁定,请稍后再试",
|
||||
"retry": int(remain.Minutes()) + 1,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecordLoginFail 记录登录失败
|
||||
func RecordLoginFail(c *gin.Context, username string) {
|
||||
defaultLoginLimiter.recordFailure(limiterKey(c, username))
|
||||
}
|
||||
|
||||
// RecordLoginSuccess 登录成功后清空计数
|
||||
func RecordLoginSuccess(c *gin.Context, username string) {
|
||||
defaultLoginLimiter.recordSuccess(limiterKey(c, username))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SecurityHeaders 统一补齐 HTTP 安全响应头,缓解点击劫持、MIME 嗅探等风险
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// 限制内联脚本/样式以缓解 XSS;允许同源与必要的外部资源
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'")
|
||||
// 仅在内网 HTTPS 网关后运行时生效;HTTP 下浏览器会忽略
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// tokenBlacklist 登出令牌黑名单(内存版,进程重启后失效,令牌自然过期兜底)
|
||||
type tokenBlacklist struct {
|
||||
mu sync.RWMutex
|
||||
revoked map[string]time.Time // tokenID(jti) -> 过期时间
|
||||
}
|
||||
|
||||
var defaultBlacklist = &tokenBlacklist{revoked: make(map[string]time.Time)}
|
||||
|
||||
// RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间)
|
||||
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) {
|
||||
id := tokenIdentifier(claims)
|
||||
defaultBlacklist.mu.Lock()
|
||||
defaultBlacklist.revoked[id] = exp
|
||||
defaultBlacklist.mu.Unlock()
|
||||
}
|
||||
|
||||
// IsRevoked 判断令牌是否已被吊销
|
||||
func IsRevoked(claims *JWTClaims) bool {
|
||||
id := tokenIdentifier(claims)
|
||||
defaultBlacklist.mu.RLock()
|
||||
exp, ok := defaultBlacklist.revoked[id]
|
||||
defaultBlacklist.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// 已过期的黑名单项自动清理
|
||||
if time.Now().After(exp) {
|
||||
defaultBlacklist.mu.Lock()
|
||||
delete(defaultBlacklist.revoked, id)
|
||||
defaultBlacklist.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
||||
func ExtractClaims(tokenStr string, secret string) (*JWTClaims, *jwt.Token, error) {
|
||||
claims := &JWTClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
return claims, token, err
|
||||
}
|
||||
|
||||
// tokenIdentifier 返回令牌唯一标识:优先 jti,否则用 subject+签发时间
|
||||
func tokenIdentifier(claims *JWTClaims) string {
|
||||
if claims.ID != "" {
|
||||
return claims.ID
|
||||
}
|
||||
iat := ""
|
||||
if claims.IssuedAt != nil {
|
||||
iat = claims.IssuedAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
return claims.Subject + "|" + iat
|
||||
}
|
||||
Reference in New Issue
Block a user