package server import ( "errors" "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "code.littlelan.cn/CarrotAssistant/backend/internal/store" "gorm.io/gorm" ) // tokenLifetime bounds how long an admin session token remains valid. The // SPA re-logs-in after expiry rather than refreshing, keeping the flow simple. const tokenLifetime = 24 * time.Hour // issueToken signs a JWT for the given admin username. func (d Deps) issueToken(username string) (string, error) { claims := jwt.MapClaims{ "sub": username, "exp": time.Now().Add(tokenLifetime).Unix(), "iat": time.Now().Unix(), } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return tok.SignedString([]byte(d.Config.JWTSecret)) } // RequireAdmin is the middleware guarding /admin routes (other than login). // It validates the Bearer JWT and loads the AdminUser row onto the context // as "admin_user". func (d Deps) RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { u, err := d.adminFromToken(c) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录或会话已过期"}) return } c.Set("admin_user", u) c.Next() } } func (d Deps) adminFromToken(c *gin.Context) (*store.AdminUser, error) { auth := c.GetHeader("Authorization") if !strings.HasPrefix(auth, "Bearer ") { return nil, errors.New("missing bearer token") } raw := strings.TrimPrefix(auth, "Bearer ") tok, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return []byte(d.Config.JWTSecret), nil }) if err != nil || !tok.Valid { return nil, errors.New("invalid token") } claims, ok := tok.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("invalid claims") } username, _ := claims["sub"].(string) if username == "" { return nil, errors.New("missing subject") } var u store.AdminUser if err := d.DB.Where("username = ?", username).First(&u).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("admin not found") } return nil, err } return &u, nil } // adminCtx returns the authenticated admin user placed by RequireAdmin. func adminCtx(c *gin.Context) *store.AdminUser { u, _ := c.Get("admin_user") if u == nil { return nil } return u.(*store.AdminUser) } // audit is a small helper to record an audit log row from any handler. It // never fails the request: auditing is best-effort but logged on error. func (d Deps) audit(c *gin.Context, appID *int64, sessionID *string, action string, detail map[string]any) { actor := "system" if u := adminCtx(c); u != nil { actor = u.Username } d.auditBy(actor, appID, sessionID, action, detail) } func (d Deps) auditBy(actor string, appID *int64, sessionID *string, action string, detail map[string]any) { row := store.AuditLog{AppID: appID, SessionID: sessionID, Actor: actor, Action: action} if detail != nil { row.Detail = marshalJSON(detail) } if err := d.DB.Create(&row).Error; err != nil { // Best-effort: surface to stderr but never interrupt the caller. gin.DefaultErrorWriter.Write([]byte("audit log: " + err.Error() + "\n")) } }