This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
88
backend/middleware/auth.go
Normal file
88
backend/middleware/auth.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// AuthMiddleware validates JWT tokens from the Authorization header.
|
||||
func AuthMiddleware(jwtSecret string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
authHeader := c.Get("Authorization")
|
||||
tokenString := ""
|
||||
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
tokenString = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to query parameter for img/video tags
|
||||
if tokenString == "" {
|
||||
tokenString = c.Query("token")
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "missing or invalid authorization",
|
||||
})
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "unexpected signing method")
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid token claims",
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", claims["sub"])
|
||||
c.Locals("username", claims["username"])
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a short-lived JWT access token.
|
||||
func GenerateAccessToken(userID int, username, secret string, expirySecs int) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"username": username,
|
||||
"exp": time.Now().Add(time.Duration(expirySecs) * time.Second).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"type": "access",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a longer-lived refresh token.
|
||||
func GenerateRefreshToken(userID int, username, secret string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"username": username,
|
||||
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(), // 7 days
|
||||
"iat": time.Now().Unix(),
|
||||
"type": "refresh",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
104
backend/middleware/bruteforce.go
Normal file
104
backend/middleware/bruteforce.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type loginAttempt struct {
|
||||
Count int
|
||||
LockedAt time.Time
|
||||
}
|
||||
|
||||
// BruteForceGuard tracks login attempts per IP and enforces exponential backoff.
|
||||
type BruteForceGuard struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*loginAttempt
|
||||
maxAttempts int
|
||||
lockoutSecs int
|
||||
}
|
||||
|
||||
func NewBruteForceGuard(maxAttempts, lockoutSecs int) *BruteForceGuard {
|
||||
guard := &BruteForceGuard{
|
||||
attempts: make(map[string]*loginAttempt),
|
||||
maxAttempts: maxAttempts,
|
||||
lockoutSecs: lockoutSecs,
|
||||
}
|
||||
|
||||
// Clean up stale entries every 10 minutes
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
guard.cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
return guard
|
||||
}
|
||||
|
||||
// Check returns a Fiber handler that blocks requests from locked-out IPs.
|
||||
func (g *BruteForceGuard) Check() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
ip := c.IP()
|
||||
|
||||
g.mu.Lock()
|
||||
attempt, exists := g.attempts[ip]
|
||||
g.mu.Unlock()
|
||||
|
||||
if exists && attempt.Count >= g.maxAttempts {
|
||||
lockoutDuration := time.Duration(g.lockoutSecs) * time.Second
|
||||
if time.Since(attempt.LockedAt) < lockoutDuration {
|
||||
remaining := lockoutDuration - time.Since(attempt.LockedAt)
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
||||
"error": "too many login attempts, account temporarily locked",
|
||||
"retry_after": int(remaining.Seconds()),
|
||||
})
|
||||
}
|
||||
// Lockout expired, reset
|
||||
g.mu.Lock()
|
||||
delete(g.attempts, ip)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure count for an IP.
|
||||
func (g *BruteForceGuard) RecordFailure(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
attempt, exists := g.attempts[ip]
|
||||
if !exists {
|
||||
g.attempts[ip] = &loginAttempt{Count: 1, LockedAt: time.Now()}
|
||||
return
|
||||
}
|
||||
|
||||
attempt.Count++
|
||||
if attempt.Count >= g.maxAttempts {
|
||||
attempt.LockedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// RecordSuccess resets the failure count for an IP after a successful login.
|
||||
func (g *BruteForceGuard) RecordSuccess(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.attempts, ip)
|
||||
}
|
||||
|
||||
func (g *BruteForceGuard) cleanup() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
cutoff := time.Duration(g.lockoutSecs*2) * time.Second
|
||||
for ip, attempt := range g.attempts {
|
||||
if time.Since(attempt.LockedAt) > cutoff {
|
||||
delete(g.attempts, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
backend/middleware/pathjail.go
Normal file
68
backend/middleware/pathjail.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// PathJail middleware ensures all file path parameters are confined within
|
||||
// the storage root. Any path that resolves outside the jail is rejected
|
||||
// immediately, preventing directory traversal attacks.
|
||||
func PathJail(storageRoot string) fiber.Handler {
|
||||
// Resolve the absolute jail root once at startup
|
||||
absRoot, err := filepath.Abs(storageRoot)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("pathjail: invalid storage root: %v", err))
|
||||
}
|
||||
// Ensure it ends with separator for prefix matching
|
||||
absRootSlash := absRoot + string(filepath.Separator)
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Extract the path parameter from the request.
|
||||
// Handlers attach the relative file path as "filepath" param or query.
|
||||
rawPath := c.Params("*")
|
||||
if rawPath == "" {
|
||||
rawPath = c.Query("path", "")
|
||||
}
|
||||
|
||||
// If no path param, assume root path '.'
|
||||
if rawPath == "" {
|
||||
rawPath = "."
|
||||
}
|
||||
|
||||
// Clean and resolve the path
|
||||
cleaned := filepath.Clean(rawPath)
|
||||
|
||||
// Reject obvious traversal attempts before even joining
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "path traversal detected",
|
||||
})
|
||||
}
|
||||
|
||||
// Build the full absolute path within the jail
|
||||
fullPath := filepath.Join(absRoot, cleaned)
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid path",
|
||||
})
|
||||
}
|
||||
|
||||
// Final check: resolved path must be within the jail root
|
||||
if absPath != absRoot && !strings.HasPrefix(absPath, absRootSlash) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "access denied: path outside storage boundary",
|
||||
})
|
||||
}
|
||||
|
||||
// Store the validated absolute path for downstream handlers
|
||||
c.Locals("resolvedPath", absPath)
|
||||
c.Locals("relativePath", cleaned)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
Reference in a new issue