Files
backend/internal/server/auth.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"))
}
}