237 lines
7.1 KiB
Go
237 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const sessionCookie = "am_session"
|
|
|
|
var loginLimiter = struct {
|
|
sync.Mutex
|
|
attempts map[string][]time.Time
|
|
}{attempts: map[string][]time.Time{}}
|
|
|
|
func loginAllowed(key string) bool {
|
|
now := time.Now()
|
|
loginLimiter.Lock()
|
|
defer loginLimiter.Unlock()
|
|
items := loginLimiter.attempts[key]
|
|
cutoff := now.Add(-15 * time.Minute)
|
|
kept := items[:0]
|
|
for _, t := range items {
|
|
if t.After(cutoff) {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
if len(kept) >= 10 {
|
|
return false
|
|
}
|
|
loginLimiter.attempts[key] = append(kept, now)
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
key := c.ClientIP() + ":" + strings.ToLower(strings.TrimSpace(in.Username))
|
|
if !loginAllowed(key) {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts; try again later"})
|
|
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
|
|
}
|
|
// Keep the session table bounded without requiring a background scheduler.
|
|
a.db.Where("expires_at <= ?", time.Now()).Delete(&Session{})
|
|
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})
|
|
user.PasswordHash = ""
|
|
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) changePassword(c *gin.Context) {
|
|
user := c.MustGet("user").(*User)
|
|
var in struct {
|
|
CurrentPassword string `json:"currentPassword"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
if c.BindJSON(&in) != nil || in.CurrentPassword == "" || len(in.NewPassword) < 8 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "currentPassword and newPassword(8+) required"})
|
|
return
|
|
}
|
|
var stored User
|
|
if err := a.db.First(&stored, user.ID).Error; err != nil || checkPassword(stored.PasswordHash, in.CurrentPassword) != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"})
|
|
return
|
|
}
|
|
hash, err := hashPassword(in.NewPassword)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "password hashing failed"})
|
|
return
|
|
}
|
|
if err := a.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&User{}).Where("id = ?", user.ID).Update("password_hash", hash).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Where("user_id = ?", user.ID).Delete(&Session{}).Error
|
|
}); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "password change failed"})
|
|
return
|
|
}
|
|
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
|
a.audit(user.ID, "auth.password.change", "user", fmt.Sprint(user.ID), "password changed; sessions revoked")
|
|
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)
|
|
}
|
|
}
|