feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen
PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden
PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeySession holds the resolved API-key context for downstream handlers.
|
||||
type APIKeySession struct {
|
||||
KeyID int64
|
||||
KeyName string
|
||||
TenantID int64
|
||||
Role string
|
||||
}
|
||||
|
||||
type apiKeyContextKey string
|
||||
|
||||
const apiKeySessionKey apiKeyContextKey = "apikey_session"
|
||||
|
||||
// APIKeySessionFromCtx extracts the API-key session from the request context.
|
||||
func APIKeySessionFromCtx(ctx context.Context) *APIKeySession {
|
||||
v, _ := ctx.Value(apiKeySessionKey).(*APIKeySession)
|
||||
return v
|
||||
}
|
||||
|
||||
// APIKeyLookup is the interface the middleware uses to resolve a hashed token.
|
||||
type APIKeyLookup interface {
|
||||
// LookupAPIKey returns the key metadata for the given SHA-256 hex hash.
|
||||
// Returns nil if not found or inactive.
|
||||
LookupAPIKey(ctx context.Context, tokenHash string) (*APIKeyRow, error)
|
||||
// TouchAPIKeyLastUsed updates last_used_at for the given key ID.
|
||||
TouchAPIKeyLastUsed(ctx context.Context, keyID int64) error
|
||||
}
|
||||
|
||||
// APIKeyRow holds a single API key record from the database.
|
||||
type APIKeyRow struct {
|
||||
ID int64
|
||||
TenantID int64
|
||||
Name string
|
||||
Role string
|
||||
Active bool
|
||||
RateLimit int
|
||||
}
|
||||
|
||||
// tokenBucket implements a simple per-key token-bucket rate limiter.
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
limit float64
|
||||
lastCheck time.Time
|
||||
}
|
||||
|
||||
// APIKeyMiddleware returns an http middleware that authenticates requests
|
||||
// via "Authorization: Bearer am_<token>" and enforces per-key rate limits.
|
||||
type APIKeyMiddleware struct {
|
||||
lookup APIKeyLookup
|
||||
buckets sync.Map // map[int64]*tokenBucket
|
||||
}
|
||||
|
||||
// NewAPIKeyMiddleware creates a new API-key authentication middleware.
|
||||
func NewAPIKeyMiddleware(lookup APIKeyLookup) *APIKeyMiddleware {
|
||||
return &APIKeyMiddleware{lookup: lookup}
|
||||
}
|
||||
|
||||
// Wrap returns an http.HandlerFunc that performs API-key auth before calling next.
|
||||
func (m *APIKeyMiddleware) Wrap(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractAPIKeyToken(r)
|
||||
if token == "" {
|
||||
writeAPIError(w, http.StatusUnauthorized, "missing or invalid authorization")
|
||||
return
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the raw token.
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
row, err := m.lookup.LookupAPIKey(r.Context(), tokenHash)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if row == nil || !row.Active {
|
||||
writeAPIError(w, http.StatusUnauthorized, "invalid or inactive API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limiting (token bucket).
|
||||
if !m.allow(row.ID, row.RateLimit) {
|
||||
w.Header().Set("Retry-After", "30")
|
||||
writeAPIError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
// Update last_used_at (best-effort, do not block request).
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = m.lookup.TouchAPIKeyLastUsed(ctx, row.ID)
|
||||
}()
|
||||
|
||||
// Inject API-key session into context.
|
||||
sess := &APIKeySession{
|
||||
KeyID: row.ID,
|
||||
KeyName: row.Name,
|
||||
TenantID: row.TenantID,
|
||||
Role: row.Role,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), apiKeySessionKey, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// allow checks and updates the token-bucket for the given key.
|
||||
func (m *APIKeyMiddleware) allow(keyID int64, limitPerMin int) bool {
|
||||
now := time.Now()
|
||||
limit := float64(limitPerMin)
|
||||
|
||||
val, _ := m.buckets.LoadOrStore(keyID, &tokenBucket{
|
||||
tokens: limit,
|
||||
limit: limit,
|
||||
lastCheck: now,
|
||||
})
|
||||
bucket := val.(*tokenBucket)
|
||||
|
||||
elapsed := now.Sub(bucket.lastCheck).Seconds()
|
||||
bucket.lastCheck = now
|
||||
|
||||
// Refill tokens based on elapsed time.
|
||||
bucket.tokens += elapsed * (limit / 60.0)
|
||||
if bucket.tokens > limit {
|
||||
bucket.tokens = limit
|
||||
}
|
||||
|
||||
if bucket.tokens < 1.0 {
|
||||
return false
|
||||
}
|
||||
bucket.tokens -= 1.0
|
||||
return true
|
||||
}
|
||||
|
||||
// extractAPIKeyToken extracts the token from "Authorization: Bearer am_<token>".
|
||||
func extractAPIKeyToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
token := strings.TrimPrefix(h, "Bearer ")
|
||||
if !strings.HasPrefix(token, "am_") {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// GenerateAPIKey creates a new random API key with the "am_" prefix.
|
||||
// Returns the raw token (to show once) and its SHA-256 hex hash (to store).
|
||||
func GenerateAPIKey() (rawToken string, tokenHash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate api key: %w", err)
|
||||
}
|
||||
rawToken = "am_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash = hex.EncodeToString(hash[:])
|
||||
return rawToken, tokenHash, nil
|
||||
}
|
||||
|
||||
// writeAPIError writes a JSON error response. Duplicated here to avoid
|
||||
// importing the api package (which would create a circular dependency).
|
||||
func writeAPIError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprintf(w, `{"error":%q}`, msg)
|
||||
}
|
||||
Reference in New Issue
Block a user