mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 22:04:44 +08:00
330 lines
8.7 KiB
Go
330 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"clicd/internal/config"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
type ApiKey struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Key string `json:"key,omitempty"`
|
|
Prefix string `json:"prefix"`
|
|
IPWhitelist string `json:"ip_whitelist"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastUsed string `json:"last_used"`
|
|
}
|
|
|
|
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
|
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
listApiKeys(w, r)
|
|
case http.MethodPost:
|
|
createApiKey(w, r)
|
|
default:
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
|
}
|
|
}
|
|
|
|
// HandleApiKeyDelete handles DELETE for a specific API key
|
|
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
|
return
|
|
}
|
|
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
|
|
if keyID == "" {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
|
return
|
|
}
|
|
config.DeleteApiKey(keyID)
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
|
}
|
|
|
|
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
|
keys := make([]ApiKey, 0)
|
|
for _, k := range config.AppConfig.ApiKeys {
|
|
keys = append(keys, ApiKey{
|
|
ID: k.ID,
|
|
Name: k.Name,
|
|
Prefix: k.Prefix,
|
|
IPWhitelist: k.IPWhitelist,
|
|
CreatedAt: k.CreatedAt,
|
|
LastUsed: k.LastUsed,
|
|
})
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
|
}
|
|
|
|
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
IPWhitelist string `json:"ip_whitelist"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
|
return
|
|
}
|
|
|
|
// Generate key: clicd_sk_ + 32 hex chars
|
|
rawBytes := make([]byte, 16)
|
|
if _, err := rand.Read(rawBytes); err != nil {
|
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate API key"})
|
|
return
|
|
}
|
|
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
|
|
|
|
keyHash, err := hashAPIKey(rawKey)
|
|
if err != nil {
|
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to store API key"})
|
|
return
|
|
}
|
|
|
|
now := time.Now().Format("2006-01-02 15:04:05")
|
|
key := config.ApiKeyConfig{
|
|
ID: generateShortID(),
|
|
Name: req.Name,
|
|
KeyHash: keyHash,
|
|
Prefix: rawKey[:13] + "...",
|
|
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
|
CreatedAt: now,
|
|
}
|
|
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
|
config.SaveConfig()
|
|
|
|
jsonResponse(w, http.StatusCreated, APIResponse{
|
|
Success: true,
|
|
Message: "API key created. Save this key now - it won't be shown again.",
|
|
Data: ApiKey{
|
|
ID: key.ID,
|
|
Name: key.Name,
|
|
Key: rawKey,
|
|
Prefix: key.Prefix,
|
|
IPWhitelist: key.IPWhitelist,
|
|
CreatedAt: key.CreatedAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func generateShortID() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
const (
|
|
apiKeyHashPrefix = "argon2id"
|
|
apiKeyHashTime = uint32(3)
|
|
apiKeyHashMemory = uint32(64 * 1024)
|
|
apiKeyHashThreads = uint8(1)
|
|
apiKeyHashSaltLength = 16
|
|
apiKeyHashKeyLength = uint32(32)
|
|
)
|
|
|
|
// hashAPIKey stores API keys using a salted slow password-hash style function.
|
|
func hashAPIKey(key string) (string, error) {
|
|
salt := make([]byte, apiKeyHashSaltLength)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
return hashAPIKeyWithSalt(key, salt), nil
|
|
}
|
|
|
|
func hashAPIKeyWithSalt(key string, salt []byte) string {
|
|
digest := argon2.IDKey([]byte(key), salt, apiKeyHashTime, apiKeyHashMemory, apiKeyHashThreads, apiKeyHashKeyLength)
|
|
return fmt.Sprintf("%s$v=19$m=%d,t=%d,p=%d$%s$%s",
|
|
apiKeyHashPrefix,
|
|
apiKeyHashMemory,
|
|
apiKeyHashTime,
|
|
apiKeyHashThreads,
|
|
hex.EncodeToString(salt),
|
|
hex.EncodeToString(digest),
|
|
)
|
|
}
|
|
|
|
func verifyAPIKeyHash(rawKey, storedHash string) bool {
|
|
parts := strings.Split(storedHash, "$")
|
|
if len(parts) != 5 || parts[0] != apiKeyHashPrefix || parts[1] != "v=19" {
|
|
return false
|
|
}
|
|
var memory, iterations uint32
|
|
var threads uint8
|
|
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
|
return false
|
|
}
|
|
if memory != apiKeyHashMemory || iterations != apiKeyHashTime || threads != apiKeyHashThreads {
|
|
return false
|
|
}
|
|
salt, err := hex.DecodeString(parts[3])
|
|
if err != nil || len(salt) == 0 {
|
|
return false
|
|
}
|
|
expected, err := hex.DecodeString(parts[4])
|
|
if err != nil || len(expected) == 0 {
|
|
return false
|
|
}
|
|
digest := argon2.IDKey([]byte(rawKey), salt, iterations, memory, threads, uint32(len(expected)))
|
|
return subtle.ConstantTimeCompare(digest, expected) == 1
|
|
}
|
|
|
|
func legacyHashKey(key string) string {
|
|
b := make([]byte, 32)
|
|
for i := range key {
|
|
b[i%32] ^= key[i]
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
|
legacyHashed := legacyHashKey(rawKey)
|
|
for i, k := range config.AppConfig.ApiKeys {
|
|
if verifyAPIKeyHash(rawKey, k.KeyHash) {
|
|
return i, false
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
|
return i, true
|
|
}
|
|
}
|
|
return -1, false
|
|
}
|
|
|
|
// validateApiKey checks if the given key is valid and IP is allowed.
|
|
func validateApiKey(rawKey, clientIP string) bool {
|
|
idx, needsRehash := matchApiKey(rawKey)
|
|
if idx < 0 {
|
|
return false
|
|
}
|
|
k := config.AppConfig.ApiKeys[idx]
|
|
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
|
return false
|
|
}
|
|
if needsRehash {
|
|
if newHash, err := hashAPIKey(rawKey); err == nil {
|
|
config.AppConfig.ApiKeys[idx].KeyHash = newHash
|
|
config.SaveConfig()
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func apiKeyFromRequest(r *http.Request) string {
|
|
if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" {
|
|
return apiKey
|
|
}
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isValidApiKeyRequest(r *http.Request) bool {
|
|
apiKey := apiKeyFromRequest(r)
|
|
if apiKey == "" {
|
|
return false
|
|
}
|
|
if !validateApiKey(apiKey, clientIP(r)) {
|
|
return false
|
|
}
|
|
updateApiKeyLastUsed(apiKey)
|
|
return true
|
|
}
|
|
|
|
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
|
func isIPAllowed(clientIP, whitelist string) bool {
|
|
clientIP = strings.TrimSpace(clientIP)
|
|
// Strip port if present
|
|
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
|
clientIP = clientIP[:idx]
|
|
}
|
|
for _, entry := range strings.Split(whitelist, "\n") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(entry, "/") {
|
|
// CIDR match
|
|
if ipInCIDR(clientIP, entry) {
|
|
return true
|
|
}
|
|
} else if entry == clientIP {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ipInCIDR(ipStr, cidr string) bool {
|
|
parts := strings.Split(cidr, "/")
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
// Simple prefix match for IPv4
|
|
ip := netParseIP(ipStr)
|
|
cidrIP := netParseIP(parts[0])
|
|
if ip == nil || cidrIP == nil {
|
|
return false
|
|
}
|
|
bits, err := strconv.Atoi(parts[1])
|
|
if err != nil || bits < 0 || bits > 32 {
|
|
return false
|
|
}
|
|
mask := uint32(0xFFFFFFFF) << (32 - bits)
|
|
ipVal := ip4ToUint32(ip)
|
|
cidrVal := ip4ToUint32(cidrIP)
|
|
return (ipVal & mask) == (cidrVal & mask)
|
|
}
|
|
|
|
func netParseIP(s string) net.IP {
|
|
s = strings.TrimSpace(s)
|
|
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
|
|
s = s[:idx]
|
|
}
|
|
return net.ParseIP(s)
|
|
}
|
|
|
|
func ip4ToUint32(ip net.IP) uint32 {
|
|
ip = ip.To4()
|
|
if ip == nil {
|
|
return 0
|
|
}
|
|
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
|
}
|
|
|
|
// updateApiKeyLastUsed marks the key as recently used.
|
|
func updateApiKeyLastUsed(rawKey string) {
|
|
idx, _ := matchApiKey(rawKey)
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
|
config.SaveConfig()
|
|
}
|
|
|
|
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
|
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
apiKey := apiKeyFromRequest(r)
|
|
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
|
return
|
|
}
|
|
|
|
updateApiKeyLastUsed(apiKey)
|
|
next(w, r)
|
|
}
|
|
}
|