feat(api): API keys + Redis rate limiting + usage stats
Add an optional API-key layer to the public read-only API. Keys grant higher per-minute rate limits and attribute usage; anonymous callers are still allowed at a lower IP-based budget. - migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once) - apikey pkg: key generation + hashing - ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open - public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After - admin: issue/list/revoke keys + usage view (API + UI tab) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// Package apikey handles generation and hashing of public-API keys.
|
||||
//
|
||||
// A key looks like "og_live_<random>". Only the SHA-256 hash is ever persisted;
|
||||
// the plaintext is returned once at creation time and cannot be recovered.
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Prefix is the human-readable scheme prefix every key carries.
|
||||
const Prefix = "og_live_"
|
||||
|
||||
// prefixLen is how many leading characters (including Prefix) are stored in
|
||||
// api_key.key_prefix for identifying a key without revealing its secret.
|
||||
const prefixLen = 12
|
||||
|
||||
// Generate returns a new random key (plaintext), its SHA-256 hash, and a short
|
||||
// display prefix. The plaintext must be shown to the caller exactly once.
|
||||
func Generate() (key, hash, displayPrefix string, err error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
// URL-safe, no padding => stable, copy-pasteable token body.
|
||||
body := base64.RawURLEncoding.EncodeToString(buf)
|
||||
key = Prefix + body
|
||||
hash = Hash(key)
|
||||
displayPrefix = key
|
||||
if len(displayPrefix) > prefixLen {
|
||||
displayPrefix = displayPrefix[:prefixLen]
|
||||
}
|
||||
return key, hash, displayPrefix, nil
|
||||
}
|
||||
|
||||
// Hash returns the hex-encoded SHA-256 of a key, used for storage and lookup.
|
||||
func Hash(key string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(key)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Looks like a key issued by this service (cheap pre-check before hashing).
|
||||
func IsWellFormed(key string) bool {
|
||||
return strings.HasPrefix(key, Prefix) && len(key) > len(Prefix)+8
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package apikey
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
key, hash, prefix, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !IsWellFormed(key) {
|
||||
t.Fatalf("generated key not well-formed: %q", key)
|
||||
}
|
||||
if Hash(key) != hash {
|
||||
t.Fatalf("Hash(key) != returned hash")
|
||||
}
|
||||
if len(prefix) != prefixLen || key[:prefixLen] != prefix {
|
||||
t.Fatalf("prefix %q not a %d-char prefix of key %q", prefix, prefixLen, key)
|
||||
}
|
||||
if len(hash) != 64 {
|
||||
t.Fatalf("hash not hex sha-256: %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
k, _, _, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[k] {
|
||||
t.Fatalf("duplicate key generated: %q", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashStableAndTrimmed(t *testing.T) {
|
||||
if Hash("og_live_abc") != Hash(" og_live_abc ") {
|
||||
t.Fatal("Hash should ignore surrounding whitespace")
|
||||
}
|
||||
if Hash("a") == Hash("b") {
|
||||
t.Fatal("distinct inputs must hash differently")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormed(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"og_live_abcdefghijkl": true, // body longer than 8 chars
|
||||
"og_live_": false, // empty body
|
||||
"og_live_abc": false, // body too short
|
||||
"nope_abcdefghijkl": false, // wrong prefix
|
||||
"": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsWellFormed(in); got != want {
|
||||
t.Errorf("IsWellFormed(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user