111 lines
3.2 KiB
Go
111 lines
3.2 KiB
Go
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"))
|
|
}
|
|
}
|