feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
This commit is contained in:
@@ -26,6 +26,11 @@ type Handlers struct {
|
||||
AdminBoxes gin.HandlerFunc
|
||||
AdminBoxesAction gin.HandlerFunc
|
||||
AdminUsers gin.HandlerFunc
|
||||
AdminUsersList gin.HandlerFunc
|
||||
AdminUsersSave gin.HandlerFunc
|
||||
AdminUsersDelete gin.HandlerFunc
|
||||
AdminUserKeyCreate gin.HandlerFunc
|
||||
AdminUserKeyRevoke gin.HandlerFunc
|
||||
AdminActivity gin.HandlerFunc
|
||||
AdminSecurity gin.HandlerFunc
|
||||
AdminAlertsAction gin.HandlerFunc
|
||||
@@ -36,6 +41,10 @@ type Handlers struct {
|
||||
AdminSettingsImport gin.HandlerFunc
|
||||
AdminSettingsReset gin.HandlerFunc
|
||||
AdminAuth gin.HandlerFunc
|
||||
UserLogin gin.HandlerFunc
|
||||
UserLogout gin.HandlerFunc
|
||||
UserMe gin.HandlerFunc
|
||||
UserCreateAPIKey gin.HandlerFunc
|
||||
}
|
||||
|
||||
func Register(router *gin.Engine, handlers Handlers) {
|
||||
@@ -57,6 +66,10 @@ func Register(router *gin.Engine, handlers Handlers) {
|
||||
// Legacy upload routes are kept for compatibility with older clients.
|
||||
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
|
||||
router.POST("/upload", handlers.LegacyUpload)
|
||||
router.POST("/auth/login", handlers.UserLogin)
|
||||
router.POST("/auth/logout", handlers.UserLogout)
|
||||
router.GET("/auth/me", handlers.UserMe)
|
||||
router.POST("/auth/api-keys/create", handlers.UserCreateAPIKey)
|
||||
|
||||
admin := router.Group("/admin")
|
||||
admin.GET("/login", handlers.AdminLogin)
|
||||
@@ -70,6 +83,11 @@ func Register(router *gin.Engine, handlers Handlers) {
|
||||
protected.GET("/boxes", handlers.AdminBoxes)
|
||||
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||
protected.GET("/users", handlers.AdminUsers)
|
||||
protected.GET("/users/list", handlers.AdminUsersList)
|
||||
protected.POST("/users/save", handlers.AdminUsersSave)
|
||||
protected.POST("/users/delete", handlers.AdminUsersDelete)
|
||||
protected.POST("/users/api-keys/create", handlers.AdminUserKeyCreate)
|
||||
protected.POST("/users/api-keys/revoke", handlers.AdminUserKeyRevoke)
|
||||
protected.GET("/activity", handlers.AdminActivity)
|
||||
protected.GET("/security", handlers.AdminSecurity)
|
||||
protected.POST("/security/actions", handlers.AdminSecurityAction)
|
||||
|
||||
@@ -2,10 +2,82 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/userstore"
|
||||
)
|
||||
|
||||
const bytesPerMegabyte = 1024 * 1024
|
||||
|
||||
type adminUserView struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Permissions userstore.Permissions `json:"permissions"`
|
||||
Limits userstore.Limits `json:"limits"`
|
||||
APIKeys []adminAPIKeyView `json:"api_keys"`
|
||||
APIKeyCount int `json:"api_key_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type adminAPIKeyView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at"`
|
||||
RevokedAt string `json:"revoked_at"`
|
||||
}
|
||||
|
||||
func formatMaybeTime(value *time.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func toAdminAPIKeyView(key userstore.APIKey) adminAPIKeyView {
|
||||
return adminAPIKeyView{
|
||||
ID: key.ID,
|
||||
Name: key.Name,
|
||||
Prefix: key.Prefix,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
LastUsedAt: formatMaybeTime(key.LastUsedAt),
|
||||
RevokedAt: formatMaybeTime(key.RevokedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func toAdminAPIKeyViews(keys []userstore.APIKey) []adminAPIKeyView {
|
||||
views := make([]adminAPIKeyView, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
views = append(views, toAdminAPIKeyView(key))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func toAdminUserView(user userstore.User) adminUserView {
|
||||
return adminUserView{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Status: user.Status,
|
||||
Permissions: user.Permissions,
|
||||
Limits: user.Limits,
|
||||
APIKeys: toAdminAPIKeyViews(user.APIKeys),
|
||||
APIKeyCount: len(user.APIKeys),
|
||||
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: user.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
LastSeenAt: formatMaybeTime(user.LastSeenAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
@@ -18,3 +90,154 @@ func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
"ActivePage": "users",
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersList(ctx *gin.Context) {
|
||||
if app.userStore == nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
||||
return
|
||||
}
|
||||
users := app.userStore.List()
|
||||
items := make([]adminUserView, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, toAdminUserView(user))
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"users": items})
|
||||
}
|
||||
|
||||
func parseInt64OrZero(value string) int64 {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseMegabytesToBytesOrZero(value string) int64 {
|
||||
megabytes := parseInt64OrZero(value)
|
||||
if megabytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return megabytes * bytesPerMegabyte
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersSave(ctx *gin.Context) {
|
||||
if app.userStore == nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
MaxFileMB string `json:"max_file_size_mb"`
|
||||
MaxBoxMB string `json:"max_box_size_mb"`
|
||||
MaxFileSize string `json:"max_file_size_bytes"`
|
||||
MaxBoxSize string `json:"max_box_size_bytes"`
|
||||
Permissions struct {
|
||||
CanUseWeb bool `json:"can_use_web"`
|
||||
CanUseAPI bool `json:"can_use_api"`
|
||||
CanCreateBox bool `json:"can_create_box"`
|
||||
CanUploadFile bool `json:"can_upload_file"`
|
||||
} `json:"permissions"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&payload); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user payload"})
|
||||
return
|
||||
}
|
||||
permissions := userstore.Permissions{
|
||||
CanUseWeb: payload.Permissions.CanUseWeb,
|
||||
CanUseAPI: payload.Permissions.CanUseAPI,
|
||||
CanCreateBox: payload.Permissions.CanCreateBox,
|
||||
CanUploadFile: payload.Permissions.CanUploadFile,
|
||||
}
|
||||
limits := userstore.Limits{
|
||||
MaxFileSizeBytes: parseMegabytesToBytesOrZero(payload.MaxFileMB),
|
||||
MaxBoxSizeBytes: parseMegabytesToBytesOrZero(payload.MaxBoxMB),
|
||||
}
|
||||
if limits.MaxFileSizeBytes == 0 && strings.TrimSpace(payload.MaxFileSize) != "" {
|
||||
limits.MaxFileSizeBytes = parseInt64OrZero(payload.MaxFileSize)
|
||||
}
|
||||
if limits.MaxBoxSizeBytes == 0 && strings.TrimSpace(payload.MaxBoxSize) != "" {
|
||||
limits.MaxBoxSizeBytes = parseInt64OrZero(payload.MaxBoxSize)
|
||||
}
|
||||
|
||||
var (
|
||||
user userstore.User
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(payload.ID) == "" {
|
||||
user, err = app.userStore.Create(payload.Username, payload.Email, permissions, limits, payload.Status)
|
||||
} else {
|
||||
user, err = app.userStore.Update(payload.ID, payload.Username, payload.Email, permissions, limits, payload.Status)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": toAdminUserView(user)})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersDelete(ctx *gin.Context) {
|
||||
if app.userStore == nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.ID) == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
|
||||
return
|
||||
}
|
||||
if err := app.userStore.Delete(payload.ID); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUserAPIKeyCreate(ctx *gin.Context) {
|
||||
if app.userStore == nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
|
||||
return
|
||||
}
|
||||
key, raw, err := app.userStore.CreateAPIKey(payload.UserID, 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)})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUserAPIKeyRevoke(ctx *gin.Context) {
|
||||
if app.userStore == nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
UserID string `json:"user_id"`
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" || strings.TrimSpace(payload.KeyID) == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id and key id are required"})
|
||||
return
|
||||
}
|
||||
if err := app.userStore.RevokeAPIKey(payload.UserID, payload.KeyID); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/routing"
|
||||
"warpbox/lib/security"
|
||||
"warpbox/lib/userstore"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
@@ -26,6 +27,7 @@ type App struct {
|
||||
alertStore *alerts.Store
|
||||
securityGuard *security.Guard
|
||||
appVersion string
|
||||
userStore *userstore.Store
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
@@ -61,6 +63,11 @@ func Run(addr string) error {
|
||||
securityGuard: security.NewGuard(),
|
||||
appVersion: currentAppVersion(),
|
||||
}
|
||||
userStore, err := userstore.NewStore(filepath.Join(cfg.DBDir, "users.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.userStore = userStore
|
||||
if err := app.reloadSecurityConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,6 +106,11 @@ func Run(addr string) error {
|
||||
AdminBoxes: app.handleAdminBoxes,
|
||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||
AdminUsers: app.handleAdminUsers,
|
||||
AdminUsersList: app.handleAdminUsersList,
|
||||
AdminUsersSave: app.handleAdminUsersSave,
|
||||
AdminUsersDelete: app.handleAdminUsersDelete,
|
||||
AdminUserKeyCreate: app.handleAdminUserAPIKeyCreate,
|
||||
AdminUserKeyRevoke: app.handleAdminUserAPIKeyRevoke,
|
||||
AdminActivity: app.handleAdminActivity,
|
||||
AdminSecurity: app.handleAdminSecurity,
|
||||
AdminAlertsAction: app.handleAdminAlertsAction,
|
||||
@@ -109,6 +121,10 @@ func Run(addr string) error {
|
||||
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||
AdminAuth: app.adminAuthMiddleware,
|
||||
UserLogin: app.handleUserLogin,
|
||||
UserLogout: app.handleUserLogout,
|
||||
UserMe: app.handleUserMe,
|
||||
UserCreateAPIKey: app.handleSelfCreateAPIKey,
|
||||
})
|
||||
|
||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
@@ -17,7 +17,11 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
actor, ok := app.authorizeUpload(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
app.limitRequestBodyForActor(ctx, actor)
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
@@ -35,7 +39,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||
return
|
||||
}
|
||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||
if err := app.validateCreateBoxRequestForActor(&request, actor); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -60,7 +64,11 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
actor, ok := app.authorizeUpload(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
app.limitRequestBodyForActor(ctx, actor)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
@@ -75,7 +83,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
|
||||
if err := app.validateManifestFileUploadForActor(boxID, fileID, file.Size, actor); err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -135,7 +143,11 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
actor, ok := app.authorizeUpload(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
app.limitRequestBodyForActor(ctx, actor)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
@@ -148,7 +160,7 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
|
||||
if err := app.validateIncomingFileForActor(boxID, file.Size, actor); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -169,7 +181,11 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
actor, ok := app.authorizeUpload(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
app.limitRequestBodyForActor(ctx, actor)
|
||||
|
||||
form, err := ctx.MultipartForm()
|
||||
if err != nil {
|
||||
@@ -184,13 +200,13 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
}
|
||||
totalSize := int64(0)
|
||||
for _, file := range files {
|
||||
if err := app.validateFileSize(file.Size); err != nil {
|
||||
if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
if err := app.validateBoxSize(totalSize); err != nil {
|
||||
if err := app.validateBoxSizeForActor(totalSize, actor); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -226,7 +242,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
for _, file := range files {
|
||||
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
|
||||
}
|
||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||
if err := app.validateCreateBoxRequestForActor(&request, actor); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
188
lib/server/user_auth.go
Normal file
188
lib/server/user_auth.go
Normal 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)})
|
||||
}
|
||||
@@ -29,6 +29,10 @@ func (app *App) requireGuestUploads(ctx *gin.Context) bool {
|
||||
}
|
||||
|
||||
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
|
||||
return app.validateCreateBoxRequestForActor(request, nil)
|
||||
}
|
||||
|
||||
func (app *App) validateCreateBoxRequestForActor(request *models.CreateBoxRequest, actor *requestActor) error {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -45,19 +49,23 @@ func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error
|
||||
|
||||
totalSize := int64(0)
|
||||
for _, file := range request.Files {
|
||||
if err := app.validateFileSize(file.Size); err != nil {
|
||||
if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
return app.validateBoxSizeForActor(totalSize, actor)
|
||||
}
|
||||
|
||||
func (app *App) validateIncomingFile(boxID string, size int64) error {
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return app.validateIncomingFileForActor(boxID, size, nil)
|
||||
}
|
||||
|
||||
func (app *App) validateIncomingFileForActor(boxID string, size int64, actor *requestActor) error {
|
||||
if err := app.validateFileSizeForActor(size, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
if app.effectiveMaxBoxBytes(actor) <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,23 +77,27 @@ func (app *App) validateIncomingFile(boxID string, size int64) error {
|
||||
for _, file := range files {
|
||||
totalSize += file.Size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
return app.validateBoxSizeForActor(totalSize, actor)
|
||||
}
|
||||
|
||||
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return app.validateManifestFileUploadForActor(boxID, fileID, size, nil)
|
||||
}
|
||||
|
||||
func (app *App) validateManifestFileUploadForActor(boxID string, fileID string, size int64, actor *requestActor) error {
|
||||
if err := app.validateFileSizeForActor(size, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return app.validateIncomingFile(boxID, size)
|
||||
return app.validateIncomingFileForActor(boxID, size, actor)
|
||||
}
|
||||
if boxstore.IsExpired(manifest) {
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
if app.effectiveMaxBoxBytes(actor) <= 0 {
|
||||
return nil
|
||||
}
|
||||
totalSize := int64(0)
|
||||
@@ -101,24 +113,54 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int
|
||||
if !found {
|
||||
totalSize += size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
return app.validateBoxSizeForActor(totalSize, actor)
|
||||
}
|
||||
|
||||
func (app *App) validateFileSize(size int64) error {
|
||||
return app.validateFileSizeForActor(size, nil)
|
||||
}
|
||||
|
||||
func (app *App) effectiveMaxFileBytes(actor *requestActor) int64 {
|
||||
if actor == nil {
|
||||
return app.config.GlobalMaxFileSizeBytes
|
||||
}
|
||||
return actor.User.Limits.MaxFileSizeBytes
|
||||
}
|
||||
|
||||
func (app *App) effectiveMaxBoxBytes(actor *requestActor) int64 {
|
||||
if actor == nil {
|
||||
return app.config.GlobalMaxBoxSizeBytes
|
||||
}
|
||||
return actor.User.Limits.MaxBoxSizeBytes
|
||||
}
|
||||
|
||||
func (app *App) validateFileSizeForActor(size int64, actor *requestActor) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("File size cannot be negative")
|
||||
}
|
||||
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
|
||||
limit := app.effectiveMaxFileBytes(actor)
|
||||
if limit > 0 && size > limit {
|
||||
if actor != nil {
|
||||
return fmt.Errorf("File exceeds this account's max file size")
|
||||
}
|
||||
return fmt.Errorf("File exceeds the global max file size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) validateBoxSize(size int64) error {
|
||||
return app.validateBoxSizeForActor(size, nil)
|
||||
}
|
||||
|
||||
func (app *App) validateBoxSizeForActor(size int64, actor *requestActor) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("Box size cannot be negative")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
|
||||
limit := app.effectiveMaxBoxBytes(actor)
|
||||
if limit > 0 && size > limit {
|
||||
if actor != nil {
|
||||
return fmt.Errorf("Box exceeds this account's max box size")
|
||||
}
|
||||
return fmt.Errorf("Box exceeds the global max box size")
|
||||
}
|
||||
return nil
|
||||
@@ -137,7 +179,11 @@ func (app *App) rejectExpiredManifestBox(boxID string) error {
|
||||
}
|
||||
|
||||
func (app *App) limitRequestBody(ctx *gin.Context) {
|
||||
limit := app.maxRequestBodyBytes()
|
||||
app.limitRequestBodyForActor(ctx, nil)
|
||||
}
|
||||
|
||||
func (app *App) limitRequestBodyForActor(ctx *gin.Context, actor *requestActor) {
|
||||
limit := app.maxRequestBodyBytesForActor(actor)
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -145,9 +191,14 @@ func (app *App) limitRequestBody(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (app *App) maxRequestBodyBytes() int64 {
|
||||
limit := app.config.GlobalMaxBoxSizeBytes
|
||||
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
|
||||
limit = app.config.GlobalMaxFileSizeBytes
|
||||
return app.maxRequestBodyBytesForActor(nil)
|
||||
}
|
||||
|
||||
func (app *App) maxRequestBodyBytesForActor(actor *requestActor) int64 {
|
||||
limit := app.effectiveMaxBoxBytes(actor)
|
||||
fileLimit := app.effectiveMaxFileBytes(actor)
|
||||
if limit <= 0 || fileLimit > limit {
|
||||
limit = fileLimit
|
||||
}
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
|
||||
369
lib/userstore/store.go
Normal file
369
lib/userstore/store.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
type Permissions struct {
|
||||
CanUseWeb bool `json:"can_use_web"`
|
||||
CanUseAPI bool `json:"can_use_api"`
|
||||
CanCreateBox bool `json:"can_create_box"`
|
||||
CanUploadFile bool `json:"can_upload_file"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
|
||||
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
Limits Limits `json:"limits"`
|
||||
APIKeys []APIKey `json:"api_keys"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
type diskState struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
users map[string]User
|
||||
}
|
||||
|
||||
func NewStore(path string) (*Store, error) {
|
||||
s := &Store{path: path, users: map[string]User{}}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
bytes, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(bytes) == 0 {
|
||||
return nil
|
||||
}
|
||||
var state diskState
|
||||
if err := json.Unmarshal(bytes, &state); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range state.Users {
|
||||
s.users[user.ID] = user
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
state := diskState{Users: make([]User, 0, len(s.users))}
|
||||
for _, user := range s.users {
|
||||
state.Users = append(state.Users, user)
|
||||
}
|
||||
sort.Slice(state.Users, func(i, j int) bool {
|
||||
return state.Users[i].CreatedAt.After(state.Users[j].CreatedAt)
|
||||
})
|
||||
bytes, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, bytes, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, s.path)
|
||||
}
|
||||
|
||||
func (s *Store) List() []User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
users := make([]User, 0, len(s.users))
|
||||
for _, user := range s.users {
|
||||
users = append(users, user)
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
return users[i].CreatedAt.After(users[j].CreatedAt)
|
||||
})
|
||||
return users
|
||||
}
|
||||
|
||||
func normalizeStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case StatusDisabled:
|
||||
return StatusDisabled
|
||||
default:
|
||||
return StatusActive
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissions(p Permissions) Permissions {
|
||||
return Permissions{
|
||||
CanUseWeb: p.CanUseWeb,
|
||||
CanUseAPI: p.CanUseAPI,
|
||||
CanCreateBox: p.CanCreateBox,
|
||||
CanUploadFile: p.CanUploadFile,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEmail(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeUsername(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func validateUserInput(username string, email string) error {
|
||||
if normalizeUsername(username) == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if normalizeEmail(email) == "" || !strings.Contains(email, "@") {
|
||||
return fmt.Errorf("valid email is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(username string, email string, permissions Permissions, limits Limits, status string) (User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := validateUserInput(username, email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
normEmail := normalizeEmail(email)
|
||||
for _, existing := range s.users {
|
||||
if strings.EqualFold(existing.Email, normEmail) {
|
||||
return User{}, fmt.Errorf("email already exists")
|
||||
}
|
||||
}
|
||||
id, err := helpers.RandomHexID(8)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user := User{
|
||||
ID: "u_" + id,
|
||||
Username: normalizeUsername(username),
|
||||
Email: normEmail,
|
||||
Status: normalizeStatus(status),
|
||||
Permissions: normalizePermissions(permissions),
|
||||
Limits: limits,
|
||||
APIKeys: []APIKey{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.users[user.ID] = user
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(id string, username string, email string, permissions Permissions, limits Limits, status string) (User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := validateUserInput(username, email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user, ok := s.users[id]
|
||||
if !ok {
|
||||
return User{}, fmt.Errorf("user not found")
|
||||
}
|
||||
normEmail := normalizeEmail(email)
|
||||
for _, existing := range s.users {
|
||||
if existing.ID == id {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(existing.Email, normEmail) {
|
||||
return User{}, fmt.Errorf("email already exists")
|
||||
}
|
||||
}
|
||||
user.Username = normalizeUsername(username)
|
||||
user.Email = normEmail
|
||||
user.Status = normalizeStatus(status)
|
||||
user.Permissions = normalizePermissions(permissions)
|
||||
user.Limits = limits
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
s.users[id] = user
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.users[id]; !ok {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
delete(s.users, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) FindByID(id string) (User, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
user, ok := s.users[id]
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func hashKey(value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIKey(userID string, name string) (APIKey, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user, ok := s.users[userID]
|
||||
if !ok {
|
||||
return APIKey{}, "", fmt.Errorf("user not found")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "default"
|
||||
}
|
||||
rawSuffix, err := helpers.RandomHexID(20)
|
||||
if err != nil {
|
||||
return APIKey{}, "", err
|
||||
}
|
||||
keyValue := "wbk_" + rawSuffix
|
||||
id, err := helpers.RandomHexID(8)
|
||||
if err != nil {
|
||||
return APIKey{}, "", err
|
||||
}
|
||||
prefix := keyValue
|
||||
if len(prefix) > 12 {
|
||||
prefix = prefix[:12]
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
key := APIKey{
|
||||
ID: "k_" + id,
|
||||
Name: strings.TrimSpace(name),
|
||||
Prefix: prefix,
|
||||
KeyHash: hashKey(keyValue),
|
||||
CreatedAt: now,
|
||||
}
|
||||
user.APIKeys = append(user.APIKeys, key)
|
||||
user.UpdatedAt = now
|
||||
s.users[userID] = user
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return APIKey{}, "", err
|
||||
}
|
||||
return key, keyValue, nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAPIKey(userID string, keyID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user, ok := s.users[userID]
|
||||
if !ok {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := range user.APIKeys {
|
||||
if user.APIKeys[i].ID == keyID {
|
||||
user.APIKeys[i].RevokedAt = &now
|
||||
user.UpdatedAt = now
|
||||
s.users[userID] = user
|
||||
return s.saveLocked()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("api key not found")
|
||||
}
|
||||
|
||||
func (s *Store) FindByAPIKey(raw string) (User, APIKey, bool) {
|
||||
h := hashKey(strings.TrimSpace(raw))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, user := range s.users {
|
||||
for _, key := range user.APIKeys {
|
||||
if key.RevokedAt != nil {
|
||||
continue
|
||||
}
|
||||
if key.KeyHash == h {
|
||||
return user, key, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return User{}, APIKey{}, false
|
||||
}
|
||||
|
||||
func (s *Store) TouchAPIKey(userID string, keyID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user, ok := s.users[userID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := range user.APIKeys {
|
||||
if user.APIKeys[i].ID == keyID {
|
||||
user.APIKeys[i].LastUsedAt = &now
|
||||
break
|
||||
}
|
||||
}
|
||||
user.LastSeenAt = &now
|
||||
user.UpdatedAt = now
|
||||
s.users[userID] = user
|
||||
_ = s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) TouchUser(userID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user, ok := s.users[userID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user.LastSeenAt = &now
|
||||
user.UpdatedAt = now
|
||||
s.users[userID] = user
|
||||
_ = s.saveLocked()
|
||||
}
|
||||
Reference in New Issue
Block a user