Add AI service API token auth
All checks were successful
CI / test (push) Successful in 15s
Build and Deploy / build-and-deploy (push) Successful in 23s

This commit is contained in:
Grendgi
2026-06-08 14:16:24 +03:00
parent eb59298135
commit 038ad8d7cf
6 changed files with 89 additions and 0 deletions

View File

@@ -60,12 +60,17 @@ or compact `system` / `user` fields. The completed job result contains
- `GET /healthz` returns process health. - `GET /healthz` returns process health.
- `GET /readyz` checks PostgreSQL readiness. - `GET /readyz` checks PostgreSQL readiness.
All `/api/v1/*` endpoints require `Authorization: Bearer <AI_SERVICE_TOKEN>`
when `AI_SERVICE_TOKEN` is configured. Health and readiness endpoints stay open
for Kubernetes probes.
## Configuration ## Configuration
- `HTTP_HOST`, default `0.0.0.0` - `HTTP_HOST`, default `0.0.0.0`
- `HTTP_PORT`, default `8080` - `HTTP_PORT`, default `8080`
- `DATABASE_URL`, required - `DATABASE_URL`, required
- `MIGRATE_ON_START`, default `true` - `MIGRATE_ON_START`, default `true`
- `AI_SERVICE_TOKEN`, optional bearer token for service-to-service API calls
- `LLM_BASE_URL`, primary OpenAI-compatible LLM endpoint - `LLM_BASE_URL`, primary OpenAI-compatible LLM endpoint
- `LLM_API_KEY`, primary LLM API key - `LLM_API_KEY`, primary LLM API key
- `LLM_MODEL`, default `qwen2.5-14b` - `LLM_MODEL`, default `qwen2.5-14b`

View File

@@ -11,6 +11,7 @@ type Config struct {
HTTPPort int HTTPPort int
DatabaseURL string DatabaseURL string
MigrateOnStart bool MigrateOnStart bool
APIAuthToken string
LLMBaseURL string LLMBaseURL string
LLMAPIKey string LLMAPIKey string
@@ -30,6 +31,7 @@ func Load() Config {
HTTPPort: envInt("HTTP_PORT", 8080), HTTPPort: envInt("HTTP_PORT", 8080),
DatabaseURL: envString("DATABASE_URL", ""), DatabaseURL: envString("DATABASE_URL", ""),
MigrateOnStart: envBool("MIGRATE_ON_START", true), MigrateOnStart: envBool("MIGRATE_ON_START", true),
APIAuthToken: envString("AI_SERVICE_TOKEN", ""),
LLMBaseURL: envString("LLM_BASE_URL", ""), LLMBaseURL: envString("LLM_BASE_URL", ""),
LLMAPIKey: envString("LLM_API_KEY", ""), LLMAPIKey: envString("LLM_API_KEY", ""),

30
internal/httpapi/auth.go Normal file
View File

@@ -0,0 +1,30 @@
package httpapi
import (
"crypto/subtle"
"net/http"
"strings"
)
func (s *Server) requireAPIToken(path string, r *http.Request) bool {
if !strings.HasPrefix(path, "/api/v1/") && path != "/api/v1" {
return true
}
expected := strings.TrimSpace(s.cfg.APIAuthToken)
if expected == "" {
return true
}
got := bearerToken(r.Header.Get("Authorization"))
if got == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}
func bearerToken(header string) string {
header = strings.TrimSpace(header)
if len(header) < len("Bearer ") || !strings.EqualFold(header[:len("Bearer ")], "Bearer ") {
return ""
}
return strings.TrimSpace(header[len("Bearer "):])
}

View File

@@ -0,0 +1,47 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"ai-service/internal/config"
)
func TestAPITokenProtectsAPIRoutes(t *testing.T) {
srv := NewServer(nil, config.Config{APIAuthToken: "secret"})
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized API request to be 401, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/not-found", nil)
req.Header.Set("Authorization", "Bearer wrong")
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected wrong token to be 401, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/not-found", nil)
req.Header.Set("Authorization", "Bearer secret")
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected authorized unknown route to be 404, got %d", rec.Code)
}
}
func TestAPITokenDoesNotProtectHealth(t *testing.T) {
srv := NewServer(nil, config.Config{APIAuthToken: "secret"})
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected healthz to stay open, got %d", rec.Code)
}
}

View File

@@ -29,6 +29,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if path == "" { if path == "" {
path = "/" path = "/"
} }
if !s.requireAPIToken(path, r) {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
switch { switch {
case r.Method == http.MethodGet && path == "/healthz": case r.Method == http.MethodGet && path == "/healthz":
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})

View File

@@ -18,3 +18,4 @@ type: Opaque
stringData: stringData:
DATABASE_URL: "postgres://ai_service:ai_service@postgres:5432/ai_service?sslmode=disable" DATABASE_URL: "postgres://ai_service:ai_service@postgres:5432/ai_service?sslmode=disable"
LLM_API_KEY: "sk-111f838ccec43406e078cd9094b6797307cb895236179f32" LLM_API_KEY: "sk-111f838ccec43406e078cd9094b6797307cb895236179f32"
AI_SERVICE_TOKEN: "d18bcacf9e02bae1806ee6b6eeda62b95be6a915c0a22936d9a700128b275442"