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:
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)})
|
||||
}
|
||||
Reference in New Issue
Block a user