feat: add multi-user auth and permission schema
This commit is contained in:
@@ -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,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")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,28 @@ package main
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
|
UserID uint `gorm:"index" json:"userId"`
|
||||||
RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
|
CreditReservationID uint `gorm:"index" json:"creditReservationId,omitempty"`
|
||||||
Title string `json:"title"`
|
Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
|
||||||
Artist string `json:"artist"`
|
RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
|
||||||
Album string `json:"album"`
|
Title string `json:"title"`
|
||||||
Pic string `json:"pic"`
|
Artist string `json:"artist"`
|
||||||
Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"`
|
Album string `json:"album"`
|
||||||
ResolvedQuality string `json:"resolvedQuality,omitempty"`
|
Pic string `json:"pic"`
|
||||||
ActualQuality string `json:"actualQuality,omitempty"`
|
Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"`
|
||||||
Format string `json:"format"`
|
ResolvedQuality string `json:"resolvedQuality,omitempty"`
|
||||||
Status string `json:"status"`
|
ActualQuality string `json:"actualQuality,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Format string `json:"format"`
|
||||||
LocalPath string `json:"localPath,omitempty"`
|
Status string `json:"status"`
|
||||||
RemotePath string `json:"remotePath,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
TotalBytes int64 `json:"totalBytes"`
|
LocalPath string `json:"localPath,omitempty"`
|
||||||
DownloadedBytes int64 `json:"downloadedBytes"`
|
RemotePath string `json:"remotePath,omitempty"`
|
||||||
Progress float64 `json:"progress"`
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
DownloadedBytes int64 `json:"downloadedBytes"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
Progress float64 `json:"progress"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user