Harden API-key model: per-key scoping + revocable keys, and configurable/whitelist-aware rate limiting (#16)
* API scope and rate limiting * improvements from review
This commit is contained in:
1 parent
a400e07ae2
commit
495edffde3
11 files changed
+843
-42
No files matched your search
@@ -5,12 +5,69 @@ import (
|
||||
"crypto/subtle"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Middleware creates an authentication middleware for API key validation
|
||||
func Middleware(apiKey string) fiber.Handler {
|
||||
// Scope limits which secrets a key may read, enforced server-side.
|
||||
// Entries may be organization/collection names or UUIDs (resolved per-request).
|
||||
// An empty scope (no organizations and no collections) grants full access.
|
||||
type Scope struct {
|
||||
Organizations []string
|
||||
Collections []string
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the scope imposes no constraint (full access).
|
||||
func (s Scope) IsEmpty() bool {
|
||||
return len(s.Organizations) == 0 && len(s.Collections) == 0
|
||||
}
|
||||
|
||||
// APIKey is a single configured key with its server-side scope.
|
||||
type APIKey struct {
|
||||
Name string
|
||||
Key string
|
||||
Scope Scope
|
||||
}
|
||||
|
||||
// Store holds the configured API keys and resolves a presented key to its scope.
|
||||
type Store struct {
|
||||
keys []APIKey
|
||||
}
|
||||
|
||||
// NewStore builds a key store from the configured keys.
|
||||
func NewStore(keys []APIKey) *Store {
|
||||
return &Store{keys: keys}
|
||||
}
|
||||
|
||||
// Match returns the configured key matching the presented secret, if any.
|
||||
// It compares against every key without short-circuiting so that timing does
|
||||
// not reveal a key's position in the list.
|
||||
func (s *Store) Match(provided string) (APIKey, bool) {
|
||||
var matched APIKey
|
||||
found := false
|
||||
for _, k := range s.keys {
|
||||
if secureCompare(provided, k.Key) {
|
||||
matched = k
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return matched, found
|
||||
}
|
||||
|
||||
// ctxKey is the unexported type for the scope stored in the request context.
|
||||
type ctxKey struct{}
|
||||
|
||||
var scopeKey ctxKey
|
||||
|
||||
// ScopeFromCtx returns the authenticated key's scope from the request context.
|
||||
func ScopeFromCtx(c *fiber.Ctx) (Scope, bool) {
|
||||
scope, ok := c.Locals(scopeKey).(Scope)
|
||||
return scope, ok
|
||||
}
|
||||
|
||||
// Middleware creates an authentication middleware that validates the bearer
|
||||
// API key against the store and attaches the matched key's scope to the context.
|
||||
func Middleware(store *Store) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Get the Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
@@ -33,14 +90,16 @@ func Middleware(apiKey string) fiber.Handler {
|
||||
|
||||
providedKey := parts[1]
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
if !secureCompare(providedKey, apiKey) {
|
||||
key, ok := store.Match(providedKey)
|
||||
if !ok {
|
||||
logger.Warn.Printf("Invalid API key from IP: %s", c.IP())
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid api key",
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals(scopeKey, key.Scope)
|
||||
|
||||
// Authentication successful
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
const (
|
||||
keyFull = "full-access-key-0000000000000000000000"
|
||||
keyScoped = "scoped-key-1111111111111111111111111111"
|
||||
)
|
||||
|
||||
func testStore() *Store {
|
||||
return NewStore([]APIKey{
|
||||
{Name: "full", Key: keyFull},
|
||||
{Name: "dev", Key: keyScoped, Scope: Scope{Collections: []string{"Secrets - DEV"}}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestStoreMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := testStore()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provided string
|
||||
wantOK bool
|
||||
wantName string
|
||||
wantScope Scope
|
||||
}{
|
||||
{"full access key", keyFull, true, "full", Scope{}},
|
||||
{"scoped key", keyScoped, true, "dev", Scope{Collections: []string{"Secrets - DEV"}}},
|
||||
{"unknown key", "nope-nope-nope-nope-nope-nope-nope-nope", false, "", Scope{}},
|
||||
{"empty provided", "", false, "", Scope{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := store.Match(tt.provided)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("Match ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !tt.wantOK {
|
||||
return
|
||||
}
|
||||
if got.Name != tt.wantName {
|
||||
t.Errorf("Match name = %q, want %q", got.Name, tt.wantName)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Scope, tt.wantScope) {
|
||||
t.Errorf("Match scope = %+v, want %+v", got.Scope, tt.wantScope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMatchEmptyStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := NewStore(nil).Match(keyFull); ok {
|
||||
t.Error("empty store should not match any key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !(Scope{}).IsEmpty() {
|
||||
t.Error("zero scope should be empty")
|
||||
}
|
||||
if (Scope{Organizations: []string{"x"}}).IsEmpty() {
|
||||
t.Error("scope with organizations should not be empty")
|
||||
}
|
||||
if (Scope{Collections: []string{"x"}}).IsEmpty() {
|
||||
t.Error("scope with collections should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(Middleware(testStore()))
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
scope, ok := ScopeFromCtx(c)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusInternalServerError).SendString("no scope")
|
||||
}
|
||||
if scope.IsEmpty() {
|
||||
return c.SendString("full")
|
||||
}
|
||||
return c.SendString("scoped")
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{"missing header", "", http.StatusUnauthorized, "missing authorization header"},
|
||||
{"malformed header", "Token abc", http.StatusUnauthorized, "invalid authorization header format"},
|
||||
{"unknown key", "Bearer wrong-key", http.StatusUnauthorized, "invalid api key"},
|
||||
{"full access key", "Bearer " + keyFull, http.StatusOK, "full"},
|
||||
{"scoped key", "Bearer " + keyScoped, http.StatusOK, "scoped"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
if tt.header != "" {
|
||||
req.Header.Set("Authorization", tt.header)
|
||||
}
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), tt.wantBody) {
|
||||
t.Errorf("body = %q, want substring %q", body, tt.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeFromCtxAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
app := fiber.New()
|
||||
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(ctx)
|
||||
if _, ok := ScopeFromCtx(ctx); ok {
|
||||
t.Error("ScopeFromCtx should report false when no scope set")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user