Added implementation
This commit is contained in:
1 parent
6ae6201dad
commit
5f937f6213
15 files changed
+1454
No files matched your search
@@ -0,0 +1,54 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||
)
|
||||
|
||||
// Middleware creates an authentication middleware for API key validation
|
||||
func Middleware(apiKey string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Get the Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
|
||||
if authHeader == "" {
|
||||
logger.Warn.Println("Missing Authorization header")
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "missing authorization header",
|
||||
})
|
||||
}
|
||||
|
||||
// Expected format: "Bearer <API_KEY>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
logger.Warn.Println("Invalid Authorization header format")
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid authorization header format",
|
||||
})
|
||||
}
|
||||
|
||||
providedKey := parts[1]
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
if !secureCompare(providedKey, apiKey) {
|
||||
logger.Warn.Printf("Invalid API key from IP: %s", c.IP())
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid api key",
|
||||
})
|
||||
}
|
||||
|
||||
// Authentication successful
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// secureCompare performs a constant-time comparison of two strings
|
||||
// This prevents timing attacks that could be used to guess the API key
|
||||
func secureCompare(a, b string) bool {
|
||||
// If lengths differ, still perform comparison to maintain constant time
|
||||
// Use subtle.ConstantTimeCompare which returns 1 if equal, 0 otherwise
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
// Server settings
|
||||
Port string
|
||||
Environment string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// Security
|
||||
APIKey string
|
||||
|
||||
// Vaultwarden settings
|
||||
VaultwardenURL string
|
||||
VaultwardenToken string
|
||||
|
||||
// Cache settings
|
||||
CacheTTL time.Duration
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnv("API_PORT", "8080"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
APIKey: os.Getenv("API_KEY"),
|
||||
|
||||
VaultwardenURL: os.Getenv("VAULTWARDEN_URL"),
|
||||
VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"),
|
||||
|
||||
ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")),
|
||||
WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")),
|
||||
CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")),
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("API_KEY is required")
|
||||
}
|
||||
if len(cfg.APIKey) < 16 {
|
||||
return nil, fmt.Errorf("API_KEY must be at least 16 characters for security")
|
||||
}
|
||||
if cfg.VaultwardenURL == "" {
|
||||
return nil, fmt.Errorf("VAULTWARDEN_URL is required")
|
||||
}
|
||||
if cfg.VaultwardenToken == "" {
|
||||
return nil, fmt.Errorf("VAULTWARDEN_ACCESS_TOKEN is required")
|
||||
}
|
||||
|
||||
// Validate and normalize URL
|
||||
parsedURL, err := url.Parse(cfg.VaultwardenURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VAULTWARDEN_URL is invalid: %w", err)
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("VAULTWARDEN_URL must use http or https scheme")
|
||||
}
|
||||
// Remove trailing slash for consistency
|
||||
cfg.VaultwardenURL = strings.TrimSuffix(cfg.VaultwardenURL, "/")
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// IsProd returns true if running in production
|
||||
func (c *Config) IsProd() bool {
|
||||
return c.Environment == "production"
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable with a fallback default value
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseDuration parses a duration string with a fallback
|
||||
func parseDuration(s string) time.Duration {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/thijsherman/vaultwarden-api/internal/vaultwarden"
|
||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||
)
|
||||
|
||||
// Handler contains all HTTP handlers
|
||||
type Handler struct {
|
||||
vaultClient *vaultwarden.Client
|
||||
}
|
||||
|
||||
// NewHandler creates a new handler instance
|
||||
func NewHandler(vaultClient *vaultwarden.Client) *Handler {
|
||||
return &Handler{
|
||||
vaultClient: vaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
// HealthCheck handles GET /health
|
||||
func (h *Handler) HealthCheck(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"service": "vaultwarden-api",
|
||||
})
|
||||
}
|
||||
|
||||
// GetSecret handles GET /secret/:name
|
||||
func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
secretName := c.Params("name")
|
||||
|
||||
if secretName == "" {
|
||||
logger.Warn.Println("Secret name not provided")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "secret name is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch secret from Vaultwarden
|
||||
value, err := h.vaultClient.GetSecret(secretName)
|
||||
if err != nil {
|
||||
// Don't expose internal error details to client
|
||||
logger.Error.Printf("Failed to fetch secret '%s': %v", secretName, err)
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
"error": "secret not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Return the secret value
|
||||
return c.JSON(fiber.Map{
|
||||
"name": secretName,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshCache handles POST /refresh
|
||||
func (h *Handler) RefreshCache(c *fiber.Ctx) error {
|
||||
h.vaultClient.ClearCache()
|
||||
|
||||
logger.Info.Println("Cache refresh requested")
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"message": "cache cleared successfully",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||
)
|
||||
|
||||
// Client handles communication with Vaultwarden API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
cache *secretCache
|
||||
}
|
||||
|
||||
// secretCache provides a simple in-memory cache with TTL
|
||||
type secretCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*cacheItem
|
||||
ttl time.Duration
|
||||
enabled bool
|
||||
}
|
||||
|
||||
type cacheItem struct {
|
||||
value string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// CipherResponse represents a Bitwarden/Vaultwarden cipher (item)
|
||||
type CipherResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type int `json:"type"` // 1 = Login, 2 = Note, 3 = Card, 4 = Identity
|
||||
Name string `json:"name"`
|
||||
Login *struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
URIs []struct {
|
||||
URI string `json:"uri"`
|
||||
} `json:"uris"`
|
||||
} `json:"login,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Type int `json:"type"` // 0 = Text, 1 = Hidden, 2 = Boolean
|
||||
} `json:"fields,omitempty"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NewClient creates a new Vaultwarden client
|
||||
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
||||
cache := &secretCache{
|
||||
items: make(map[string]*cacheItem),
|
||||
ttl: cacheTTL,
|
||||
enabled: cacheTTL > 0,
|
||||
}
|
||||
|
||||
// Start cache cleanup goroutine if caching is enabled
|
||||
if cacheTTL > 0 {
|
||||
go cache.startCleanup(cacheTTL)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret by name from Vaultwarden
|
||||
// It first checks the cache, then queries the API if needed
|
||||
func (c *Client) GetSecret(name string) (string, error) {
|
||||
// Validate input
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("secret name cannot be empty")
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if c.cache.enabled {
|
||||
if value, found := c.cache.get(name); found {
|
||||
logger.Info.Printf("Cache hit for secret: %s", name)
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - fetch from API
|
||||
logger.Info.Printf("Fetching secret from Vaultwarden: %s", name)
|
||||
value, err := c.fetchSecret(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
if c.cache.enabled {
|
||||
c.cache.set(name, value)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// fetchSecret queries the Vaultwarden API for a secret
|
||||
func (c *Client) fetchSecret(name string) (string, error) {
|
||||
// Build the API request with context for timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/ciphers", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set authentication header
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// SECURITY: Do NOT log response body - may contain sensitive data
|
||||
logger.Error.Printf("Vaultwarden API error (status %d)", resp.StatusCode)
|
||||
return "", fmt.Errorf("vaultwarden api returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var cipherResp CipherResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cipherResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Search for the secret by name
|
||||
for _, cipher := range cipherResp.Data {
|
||||
if cipher.Name == name {
|
||||
return c.extractSecretValue(cipher)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("secret not found: %s", name)
|
||||
}
|
||||
|
||||
// extractSecretValue extracts the secret value from a cipher based on its type
|
||||
func (c *Client) extractSecretValue(cipher struct {
|
||||
ID string `json:"id"`
|
||||
Type int `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Login *struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
URIs []struct {
|
||||
URI string `json:"uri"`
|
||||
} `json:"uris"`
|
||||
} `json:"login,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Type int `json:"type"`
|
||||
} `json:"fields,omitempty"`
|
||||
}) (string, error) {
|
||||
// Type 1 = Login item
|
||||
if cipher.Type == 1 && cipher.Login != nil {
|
||||
// Return password by default for login items
|
||||
if cipher.Login.Password != "" {
|
||||
return cipher.Login.Password, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Type 2 = Secure note
|
||||
if cipher.Type == 2 && cipher.Notes != "" {
|
||||
return cipher.Notes, nil
|
||||
}
|
||||
|
||||
// Check custom fields
|
||||
for _, field := range cipher.Fields {
|
||||
if field.Name == "value" || field.Name == "secret" {
|
||||
return field.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific field, return notes if available
|
||||
if cipher.Notes != "" {
|
||||
return cipher.Notes, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract secret value from cipher")
|
||||
}
|
||||
|
||||
// ClearCache clears all cached secrets
|
||||
func (c *Client) ClearCache() {
|
||||
if c.cache.enabled {
|
||||
c.cache.clear()
|
||||
logger.Info.Println("Cache cleared")
|
||||
}
|
||||
}
|
||||
|
||||
// Cache methods
|
||||
func (sc *secretCache) get(key string) (string, bool) {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
|
||||
item, found := sc.items[key]
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(item.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return item.value, true
|
||||
}
|
||||
|
||||
func (sc *secretCache) set(key, value string) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
|
||||
sc.items[key] = &cacheItem{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(sc.ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *secretCache) clear() {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
|
||||
sc.items = make(map[string]*cacheItem)
|
||||
}
|
||||
|
||||
// startCleanup runs a background goroutine to periodically remove expired cache entries
|
||||
// This prevents memory leaks from accumulating expired items
|
||||
func (sc *secretCache) startCleanup(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
sc.removeExpired()
|
||||
}
|
||||
}
|
||||
|
||||
// removeExpired removes all expired items from the cache
|
||||
func (sc *secretCache) removeExpired() {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, item := range sc.items {
|
||||
if now.After(item.expiresAt) {
|
||||
delete(sc.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user