feat: add multi-user auth and permission schema

This commit is contained in:
OpenClaw Agent
2026-09-17 02:09:24 +08:00
parent 72fdd3b74c
commit 7628390966
4 changed files with 330 additions and 29 deletions
+168
View File
@@ -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)
}
}
+15 -9
View File
@@ -15,12 +15,13 @@ func main() {
_ = os.MkdirAll(filepath.Dir(dbPath), 0755) _ = os.MkdirAll(filepath.Dir(dbPath), 0755)
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
must(err) 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} client := &http.Client{Timeout: 2 * 60 * 1e9}
cfg := loadConfig(db) cfg := loadConfig(db)
a := &App{db: db, cfg: cfg, queue: make(chan uint, 100), httpClient: client} a := &App{db: db, cfg: cfg, queue: make(chan uint, 100), httpClient: client}
a.loadSettings() a.loadSettings()
a.bootstrapAdmin()
normalizeConfig(&a.cfg) normalizeConfig(&a.cfg)
a.am = &AMAPIClient{BaseURL: a.cfg.AMAPI, HTTP: client} a.am = &AMAPIClient{BaseURL: a.cfg.AMAPI, HTTP: client}
if a.cfg.WebDAVURL != "" { if a.cfg.WebDAVURL != "" {
@@ -31,14 +32,19 @@ func main() {
r := gin.Default() r := gin.Default()
r.StaticFile("/", "/app/index.html") r.StaticFile("/", "/app/index.html")
api := r.Group("/api") api := r.Group("/api")
api.GET("/search", a.search) api.POST("/auth/login", a.login)
api.GET("/tasks", a.listTasks) api.POST("/auth/logout", a.requireAuth, a.logout)
api.POST("/tasks", a.addTask) api.GET("/auth/me", a.requireAuth, a.me)
api.POST("/tasks/:id/retry", a.retryTask) auth := api.Group("", a.requireAuth)
api.DELETE("/tasks/:id", a.deleteTask) auth.GET("/search", a.requirePermission(PermDownload), a.search)
api.GET("/settings", a.getSettings) auth.GET("/tasks", a.requirePermission(PermDownload), a.listTasks)
api.PUT("/settings", a.putSettings) auth.POST("/tasks", a.requirePermission(PermDownload), a.addTask)
api.POST("/webdav/test", a.testWebDAV) 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"))) must(r.Run(":" + getenv("PORT", "9540")))
} }
+2
View File
@@ -4,6 +4,8 @@ import "time"
type Task struct { type Task struct {
ID uint `gorm:"primaryKey" json:"id"` 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"` Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"` RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
Title string `json:"title"` Title string `json:"title"`
+125
View File
@@ -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"
)