Added implementation

This commit is contained in:
Turbootzz committed 2025-10-17 13:32:58 +02:00
1 parent 6ae6201dad
commit 5f937f6213
15 files changed
+1454

No files matched your search

+54
View File
@@ -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
}
+93
View File
@@ -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
}
+66
View File
@@ -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",
})
}
+269
View File
@@ -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)
}
}
}