From 76283909668300a81b052a56cd6664023e6c8391 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Thu, 17 Sep 2026 02:09:24 +0800 Subject: [PATCH] feat: add multi-user auth and permission schema --- auth.go | 168 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 24 ++++--- models.go | 42 +++++------ multiuser_models.go | 125 ++++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 29 deletions(-) create mode 100644 auth.go create mode 100644 multiuser_models.go diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..395cddc --- /dev/null +++ b/auth.go @@ -0,0 +1,168 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +const sessionCookie = "am_session" + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func hashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +func checkPassword(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +func (a *App) currentUser(c *gin.Context) *User { + value, ok := c.Get("user") + if !ok { + return nil + } + u, _ := value.(*User) + return u +} + +func (a *App) requireAuth(c *gin.Context) { + token, err := c.Cookie(sessionCookie) + if err != nil || token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + var session Session + if a.db.Where("token_hash = ? AND expires_at > ?", hashToken(token), time.Now()).First(&session).Error != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired session"}) + return + } + var user User + if a.db.First(&user, session.UserID).Error != nil || user.Status != UserActive { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user unavailable"}) + return + } + c.Set("user", &user) + c.Next() +} + +func (a *App) requireAdmin(c *gin.Context) { + u := a.currentUser(c) + if u == nil || u.Role != RoleAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) + return + } + c.Next() +} + +func (a *App) requirePermission(permission string) gin.HandlerFunc { + return func(c *gin.Context) { + u := a.currentUser(c) + if u == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + if u.Role == RoleAdmin || a.hasPermission(u.ID, permission) { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + } +} + +func (a *App) hasPermission(userID uint, permission string) bool { + var p UserPermission + return a.db.Where("user_id = ? AND permission = ? AND enabled = ?", userID, permission, true).First(&p).Error == nil +} + +func (a *App) login(c *gin.Context) { + var in struct { + Username string `json:"username"` + Password string `json:"password"` + } + if c.BindJSON(&in) != nil || strings.TrimSpace(in.Username) == "" || in.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required"}) + return + } + var user User + if a.db.Where("username = ?", strings.TrimSpace(in.Username)).First(&user).Error != nil || user.Status != UserActive || checkPassword(user.PasswordHash, in.Password) != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + token, err := randomToken(32) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session generation failed"}) + return + } + session := Session{UserID: user.ID, TokenHash: hashToken(token), ExpiresAt: time.Now().Add(7 * 24 * time.Hour)} + if err := a.db.Create(&session).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session creation failed"}) + return + } + http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: c.Request.TLS != nil, Expires: session.ExpiresAt}) + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +func (a *App) logout(c *gin.Context) { + if token, err := c.Cookie(sessionCookie); err == nil && token != "" { + a.db.Where("token_hash = ?", hashToken(token)).Delete(&Session{}) + } + http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + c.Status(http.StatusNoContent) +} + +func (a *App) me(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"user": a.currentUser(c)}) +} + +func (a *App) bootstrapAdmin() { + var count int64 + a.db.Model(&User{}).Count(&count) + if count != 0 { + return + } + username := getenv("ADMIN_USERNAME", "admin") + password := getenv("ADMIN_PASSWORD", "") + if password == "" { + generated, err := randomToken(12) + if err != nil { + panic(err) + } + password = generated + fmt.Printf("AM Downloader initial admin password: %s\n", password) + } + hash, err := hashPassword(password) + if err != nil { + panic(err) + } + u := User{Username: username, PasswordHash: hash, Role: RoleAdmin, Status: UserActive} + if err := a.db.Create(&u).Error; err != nil { + panic(err) + } + if err := a.db.Model(&Task{}).Where("user_id = 0").Update("user_id", u.ID).Error; err != nil { + panic(err) + } + if err := a.db.Create(&CreditAccount{UserID: u.ID}).Error; err != nil { + panic(err) + } +} diff --git a/main.go b/main.go index 325cc69..cf46d6e 100644 --- a/main.go +++ b/main.go @@ -15,12 +15,13 @@ func main() { _ = os.MkdirAll(filepath.Dir(dbPath), 0755) db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) must(err) - must(db.AutoMigrate(&Task{}, &Setting{})) + must(db.AutoMigrate(&Task{}, &Setting{}, &User{}, &UserPermission{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}, &RedeemBatch{}, &RedeemCode{}, &RedeemRecord{}, &WebDAVConfig{}, &Session{}, &AuditLog{})) client := &http.Client{Timeout: 2 * 60 * 1e9} cfg := loadConfig(db) a := &App{db: db, cfg: cfg, queue: make(chan uint, 100), httpClient: client} a.loadSettings() + a.bootstrapAdmin() normalizeConfig(&a.cfg) a.am = &AMAPIClient{BaseURL: a.cfg.AMAPI, HTTP: client} if a.cfg.WebDAVURL != "" { @@ -31,14 +32,19 @@ func main() { r := gin.Default() r.StaticFile("/", "/app/index.html") api := r.Group("/api") - api.GET("/search", a.search) - api.GET("/tasks", a.listTasks) - api.POST("/tasks", a.addTask) - api.POST("/tasks/:id/retry", a.retryTask) - api.DELETE("/tasks/:id", a.deleteTask) - api.GET("/settings", a.getSettings) - api.PUT("/settings", a.putSettings) - api.POST("/webdav/test", a.testWebDAV) + api.POST("/auth/login", a.login) + api.POST("/auth/logout", a.requireAuth, a.logout) + api.GET("/auth/me", a.requireAuth, a.me) + auth := api.Group("", a.requireAuth) + auth.GET("/search", a.requirePermission(PermDownload), a.search) + auth.GET("/tasks", a.requirePermission(PermDownload), a.listTasks) + auth.POST("/tasks", a.requirePermission(PermDownload), a.addTask) + auth.POST("/tasks/:id/retry", a.requirePermission(PermDownload), a.retryTask) + auth.DELETE("/tasks/:id", a.requirePermission(PermDownload), a.deleteTask) + admin := api.Group("/admin", a.requireAuth, a.requireAdmin) + admin.GET("/settings", a.getSettings) + admin.PUT("/settings", a.putSettings) + admin.POST("/webdav/test", a.testWebDAV) must(r.Run(":" + getenv("PORT", "9540"))) } diff --git a/models.go b/models.go index 853a5f9..0416d24 100644 --- a/models.go +++ b/models.go @@ -3,26 +3,28 @@ package main import "time" type Task struct { - ID uint `gorm:"primaryKey" json:"id"` - Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"` - RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"` - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album"` - Pic string `json:"pic"` - Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"` - ResolvedQuality string `json:"resolvedQuality,omitempty"` - ActualQuality string `json:"actualQuality,omitempty"` - Format string `json:"format"` - Status string `json:"status"` - Error string `json:"error,omitempty"` - LocalPath string `json:"localPath,omitempty"` - RemotePath string `json:"remotePath,omitempty"` - TotalBytes int64 `json:"totalBytes"` - DownloadedBytes int64 `json:"downloadedBytes"` - Progress float64 `json:"progress"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"userId"` + CreditReservationID uint `gorm:"index" json:"creditReservationId,omitempty"` + Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"` + RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + Pic string `json:"pic"` + Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"` + ResolvedQuality string `json:"resolvedQuality,omitempty"` + ActualQuality string `json:"actualQuality,omitempty"` + Format string `json:"format"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + LocalPath string `json:"localPath,omitempty"` + RemotePath string `json:"remotePath,omitempty"` + TotalBytes int64 `json:"totalBytes"` + DownloadedBytes int64 `json:"downloadedBytes"` + Progress float64 `json:"progress"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type Setting struct { diff --git a/multiuser_models.go b/multiuser_models.go new file mode 100644 index 0000000..b0daab5 --- /dev/null +++ b/multiuser_models.go @@ -0,0 +1,125 @@ +package main + +import "time" + +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;size:64" json:"username"` + PasswordHash string `json:"-"` + Role string `gorm:"size:20;index" json:"role"` + Status string `gorm:"size:20;index" json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserPermission struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex:idx_user_permission" json:"userId"` + Permission string `gorm:"uniqueIndex:idx_user_permission;size:64" json:"permission"` + Enabled bool `json:"enabled"` +} + +type CreditAccount struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex" json:"userId"` + Balance int64 `json:"balance"` + Reserved int64 `json:"reserved"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreditTransaction struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"index" json:"userId"` + Type string `gorm:"index;size:32" json:"type"` + Amount int64 `json:"amount"` + BalanceAfter int64 `json:"balanceAfter"` + ReferenceType string `gorm:"size:32" json:"referenceType"` + ReferenceID string `gorm:"size:64" json:"referenceId"` + Reason string `json:"reason,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type CreditReservation struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"index" json:"userId"` + TaskID uint `gorm:"uniqueIndex" json:"taskId"` + Amount int64 `json:"amount"` + Status string `gorm:"index;size:20" json:"status"` + CreatedAt time.Time `json:"createdAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} + +type RedeemBatch struct { + ID uint `gorm:"primaryKey"` + Code string `gorm:"uniqueIndex;size:64" json:"code"` + Credits int64 `json:"credits"` + Count int `json:"count"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Note string `json:"note,omitempty"` + CreatedBy uint `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` +} + +type RedeemCode struct { + ID uint `gorm:"primaryKey"` + BatchID uint `gorm:"index" json:"batchId"` + CodeHash string `gorm:"uniqueIndex;size:64" json:"-"` + Prefix string `gorm:"size:24" json:"prefix"` + Credits int64 `json:"credits"` + Status string `gorm:"index;size:20" json:"status"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + RedeemedBy *uint `json:"redeemedBy,omitempty"` + RedeemedAt *time.Time `json:"redeemedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type RedeemRecord struct { + ID uint `gorm:"primaryKey"` + CodeID uint `gorm:"index" json:"codeId"` + BatchID uint `gorm:"index" json:"batchId"` + UserID uint `gorm:"index" json:"userId"` + Credits int64 `json:"credits"` + CreatedAt time.Time `json:"createdAt"` +} + +type WebDAVConfig struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex" json:"userId"` + Enabled bool `json:"enabled"` + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"-"` + RootPath string `json:"rootPath"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Session struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"index" json:"userId"` + TokenHash string `gorm:"uniqueIndex;size:64" json:"-"` + ExpiresAt time.Time `gorm:"index" json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type AuditLog struct { + ID uint `gorm:"primaryKey"` + OperatorID uint `gorm:"index" json:"operatorId"` + Action string `gorm:"index;size:64" json:"action"` + TargetType string `gorm:"size:32" json:"targetType"` + TargetID string `gorm:"size:64" json:"targetId"` + Metadata string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +const ( + RoleAdmin = "admin" + RolePrivileged = "privileged" + RoleUser = "user" + UserActive = "active" + UserDisabled = "disabled" + PermDownload = "download" + PermRedeem = "redeem" + PermWebDAV = "webdav" + PermWebDAVConfig = "webdav_config" +)