feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s

This commit is contained in:
2026-05-04 02:27:36 +03:00
parent dc379ea6a6
commit d7cbba1bf2
14 changed files with 1688 additions and 271 deletions

188
lib/server/user_auth.go Normal file
View File

@@ -0,0 +1,188 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/userstore"
)
const userSessionCookie = "warpbox_user_session"
type requestActor struct {
User userstore.User
FromAPIKey bool
KeyID string
}
func requestBearerToken(ctx *gin.Context) string {
auth := strings.TrimSpace(ctx.GetHeader("Authorization"))
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
return ""
}
return strings.TrimSpace(auth[7:])
}
func (app *App) sessionSecret() string {
return app.config.AdminUsername + "|" + app.config.AdminPassword + "|warpbox"
}
func (app *App) signSessionToken(userID string, expiresAt time.Time) string {
payload := userID + "|" + expiresAt.UTC().Format(time.RFC3339)
mac := hmac.New(sha256.New, []byte(app.sessionSecret()))
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
}
func (app *App) parseSessionToken(token string) (string, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
mac := hmac.New(sha256.New, []byte(app.sessionSecret()))
mac.Write([]byte(payload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(parts[1])) {
return "", false
}
items := strings.Split(payload, "|")
if len(items) != 2 {
return "", false
}
expiresAt, err := time.Parse(time.RFC3339, items[1])
if err != nil || time.Now().UTC().After(expiresAt) {
return "", false
}
return items[0], true
}
func (app *App) resolveActor(ctx *gin.Context) (*requestActor, bool) {
if app.userStore == nil {
return nil, false
}
if rawKey := requestBearerToken(ctx); rawKey != "" {
user, key, ok := app.userStore.FindByAPIKey(rawKey)
if ok {
app.userStore.TouchAPIKey(user.ID, key.ID)
return &requestActor{User: user, FromAPIKey: true, KeyID: key.ID}, true
}
return nil, false
}
if token, err := ctx.Cookie(userSessionCookie); err == nil {
if userID, ok := app.parseSessionToken(token); ok {
if user, found := app.userStore.FindByID(userID); found {
app.userStore.TouchUser(user.ID)
return &requestActor{User: user}, true
}
}
}
return nil, false
}
func (app *App) denyActor(ctx *gin.Context, status int, message string) bool {
ctx.JSON(status, gin.H{"error": message})
return false
}
func (app *App) authorizeUpload(ctx *gin.Context) (*requestActor, bool) {
actor, ok := app.resolveActor(ctx)
if !ok {
if requestBearerToken(ctx) != "" {
return nil, app.denyActor(ctx, http.StatusUnauthorized, "Invalid API key")
}
return nil, true
}
if actor.User.Status != userstore.StatusActive {
return nil, app.denyActor(ctx, http.StatusForbidden, "User account is disabled")
}
if !actor.User.Permissions.CanUseAPI {
return nil, app.denyActor(ctx, http.StatusForbidden, "API access is not allowed for this user")
}
if !actor.User.Permissions.CanCreateBox {
return nil, app.denyActor(ctx, http.StatusForbidden, "Creating boxes is not allowed for this user")
}
if !actor.User.Permissions.CanUploadFile {
return nil, app.denyActor(ctx, http.StatusForbidden, "Uploading files is not allowed for this user")
}
return actor, true
}
func (app *App) handleUserLogin(ctx *gin.Context) {
var payload struct {
APIKey string `json:"api_key"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid login payload"})
return
}
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
user, key, ok := app.userStore.FindByAPIKey(payload.APIKey)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
return
}
if user.Status != userstore.StatusActive {
ctx.JSON(http.StatusForbidden, gin.H{"error": "User account is disabled"})
return
}
if !user.Permissions.CanUseWeb {
ctx.JSON(http.StatusForbidden, gin.H{"error": "Web access is not allowed for this user"})
return
}
app.userStore.TouchAPIKey(user.ID, key.ID)
expiresAt := time.Now().UTC().Add(time.Duration(app.config.SessionTTLSeconds) * time.Second)
ctx.SetCookie(userSessionCookie, app.signSessionToken(user.ID, expiresAt), int(app.config.SessionTTLSeconds), "/", "", false, true)
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": gin.H{"id": user.ID, "email": user.Email, "username": user.Username}})
}
func (app *App) handleUserLogout(ctx *gin.Context) {
ctx.SetCookie(userSessionCookie, "", -1, "/", "", false, true)
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func (app *App) handleUserMe(ctx *gin.Context) {
actor, ok := app.resolveActor(ctx)
if !ok || actor == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
ctx.JSON(http.StatusOK, gin.H{"user": toAdminUserView(actor.User)})
}
func (app *App) handleSelfCreateAPIKey(ctx *gin.Context) {
actor, ok := app.resolveActor(ctx)
if !ok || actor == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
if actor.User.Status != userstore.StatusActive {
ctx.JSON(http.StatusForbidden, gin.H{"error": "User account is disabled"})
return
}
var payload struct {
Name string `json:"name"`
}
_ = ctx.ShouldBindJSON(&payload)
key, raw, err := app.userStore.CreateAPIKey(actor.User.ID, payload.Name)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "api_key": raw, "key": toAdminAPIKeyView(key)})
}