mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-02 07:42:37 +02:00
chore: Apply dashboard audit remediations, sync engine counts, update APIs
This commit is contained in:
parent
53c87c972d
commit
5ddfa74771
14 changed files with 354 additions and 153 deletions
|
|
@ -13,13 +13,9 @@ type LoginRequest struct {
|
|||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// TokenResponse is returned on successful login/refresh.
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"` // seconds
|
||||
TokenType string `json:"token_type"`
|
||||
User *User `json:"user"`
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
User *User `json:"user"`
|
||||
}
|
||||
|
||||
// HandleLogin creates an HTTP handler for POST /api/auth/login.
|
||||
|
|
@ -73,12 +69,30 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// SEC: H1 - Use httpOnly Cookies instead of localStorage
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "syntrex_token",
|
||||
Value: accessToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 900,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "syntrex_refresh",
|
||||
Value: refreshToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 7 * 24 * 3600,
|
||||
})
|
||||
|
||||
// SEC: M2 - Generate stateless CSRF token
|
||||
csrfToken := hmacSign([]byte(accessToken), secret)[:32]
|
||||
|
||||
resp := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: 900, // 15 minutes
|
||||
TokenType: "Bearer",
|
||||
User: user,
|
||||
CSRFToken: csrfToken,
|
||||
User: user,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -89,15 +103,14 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
|
|||
// HandleRefresh creates an HTTP handler for POST /api/auth/refresh.
|
||||
func HandleRefresh(secret []byte) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeAuthError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
// Extract refresh token from cookie
|
||||
cookie, err := r.Cookie("syntrex_refresh")
|
||||
if err != nil {
|
||||
writeAuthError(w, http.StatusUnauthorized, "missing refresh token cookie")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := Verify(req.RefreshToken, secret)
|
||||
claims, err := Verify(cookie.Value, secret)
|
||||
if err != nil {
|
||||
writeAuthError(w, http.StatusUnauthorized, "invalid or expired refresh token")
|
||||
return
|
||||
|
|
@ -115,11 +128,21 @@ func HandleRefresh(secret []byte) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// SEC: H1 - Set new httpOnly token
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "syntrex_token",
|
||||
Value: accessToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 900,
|
||||
})
|
||||
|
||||
csrfToken := hmacSign([]byte(accessToken), secret)[:32]
|
||||
|
||||
resp := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: req.RefreshToken,
|
||||
ExpiresIn: 900,
|
||||
TokenType: "Bearer",
|
||||
CSRFToken: csrfToken,
|
||||
User: &User{Email: claims.Sub, Role: claims.Role}, // Mock user to provide payload
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -127,6 +150,31 @@ func HandleRefresh(secret []byte) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// HandleLogout clears the auth cookies.
|
||||
// POST /api/auth/logout
|
||||
func HandleLogout() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "syntrex_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "syntrex_refresh",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMe returns the current authenticated user profile.
|
||||
// GET /api/auth/me
|
||||
func HandleMe(store *UserStore) http.HandlerFunc {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ func NewJWTMiddleware(secret []byte) *JWTMiddleware {
|
|||
"/readyz": true,
|
||||
"/metrics": true,
|
||||
"/api/auth/login": true,
|
||||
"/api/auth/logout": true,
|
||||
"/api/auth/refresh": true,
|
||||
"/api/auth/register": true,
|
||||
"/api/auth/verify": true,
|
||||
|
|
@ -51,20 +52,43 @@ func (m *JWTMiddleware) Middleware(next http.Handler) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
// Extract Bearer token.
|
||||
var tokenStr string
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeAuthError(w, http.StatusUnauthorized, "missing Authorization header")
|
||||
return
|
||||
if strings.HasPrefix(authHeader, "Bearer stx_") {
|
||||
// Allow API keys via header
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
tokenStr = parts[1]
|
||||
} else {
|
||||
// SEC: H1 - Read token from httpOnly cookie
|
||||
cookie, err := r.Cookie("syntrex_token")
|
||||
if err != nil || cookie.Value == "" {
|
||||
// Fallback to legacy bearer token (for clients that haven't migrated yet or testing)
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
} else {
|
||||
writeAuthError(w, http.StatusUnauthorized, "missing authentication cookie")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
tokenStr = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
writeAuthError(w, http.StatusUnauthorized, "invalid Authorization format (expected: Bearer <token>)")
|
||||
return
|
||||
// SEC: M2 - Validate CSRF Token on mutating requests
|
||||
if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" {
|
||||
// Exempt API keys from CSRF if they used the header
|
||||
if !strings.HasPrefix(authHeader, "Bearer stx_") {
|
||||
csrfHeader := r.Header.Get("X-CSRF-Token")
|
||||
expectedCSRF := hmacSign([]byte(tokenStr), m.secret)[:32]
|
||||
if csrfHeader == "" || csrfHeader != expectedCSRF {
|
||||
slog.Warn("CSRF token missing or invalid", "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
writeAuthError(w, http.StatusForbidden, "invalid CSRF token")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
claims, err := Verify(parts[1], m.secret)
|
||||
claims, err := Verify(tokenStr, m.secret)
|
||||
if err != nil {
|
||||
slog.Warn("JWT auth failed",
|
||||
"error", err,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []
|
|||
}
|
||||
|
||||
// Create tenant
|
||||
tenant, err := tenantStore.CreateTenant(req.OrgName, req.OrgSlug, user.ID, "starter")
|
||||
tenant, err := tenantStore.CreateTenant(req.OrgName, req.OrgSlug, user.ID, "free")
|
||||
if err != nil {
|
||||
if err == ErrTenantExists {
|
||||
http.Error(w, `{"error":"organization slug already taken"}`, http.StatusConflict)
|
||||
|
|
@ -260,7 +260,7 @@ func HandleUpdateTenantPlan(tenantStore *TenantStore) http.HandlerFunc {
|
|||
func HandleListPlans() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
plans := make([]Plan, 0, len(DefaultPlans))
|
||||
order := []string{"starter", "professional", "enterprise"}
|
||||
order := []string{"free", "starter", "professional", "enterprise"}
|
||||
for _, id := range order {
|
||||
if p, ok := DefaultPlans[id]; ok {
|
||||
plans = append(plans, p)
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ type Plan struct {
|
|||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
MaxEventsMonth int `json:"max_events_month"`
|
||||
MaxEventsMonth int `json:"max_events_month"` // SOC event ingestion quota (-1=unlimited)
|
||||
MaxIncidents int `json:"max_incidents"`
|
||||
MaxSensors int `json:"max_sensors"`
|
||||
MaxScansMonth int `json:"max_scans_month"` // /api/v1/scan quota (-1=unlimited, 0=none)
|
||||
RetentionDays int `json:"retention_days"`
|
||||
SOCEnabled bool `json:"soc_enabled"` // SOC Dashboard access
|
||||
SLAEnabled bool `json:"sla_enabled"`
|
||||
SOAREnabled bool `json:"soar_enabled"`
|
||||
ComplianceEnabled bool `json:"compliance_enabled"`
|
||||
|
|
@ -35,25 +37,40 @@ type Plan struct {
|
|||
|
||||
// DefaultPlans defines the standard pricing tiers (prices in RUB kopecks).
|
||||
var DefaultPlans = map[string]Plan{
|
||||
"free": {
|
||||
ID: "free", Name: "Free",
|
||||
Description: "Scanner API — 1 000 сканов/мес, все 66 движков, без SOC Dashboard",
|
||||
MaxUsers: 1, MaxEventsMonth: 0, MaxIncidents: 0, MaxSensors: 0,
|
||||
MaxScansMonth: 1000,
|
||||
RetentionDays: 0,
|
||||
SOCEnabled: false, SLAEnabled: false, SOAREnabled: false, ComplianceEnabled: false,
|
||||
PriceMonthCents: 0,
|
||||
},
|
||||
"starter": {
|
||||
ID: "starter", Name: "Starter",
|
||||
Description: "AI-мониторинг: до 5 сенсоров, базовая корреляция и алерты",
|
||||
MaxUsers: 10, MaxEventsMonth: 100000, MaxIncidents: 200, MaxSensors: 5,
|
||||
RetentionDays: 30, SLAEnabled: true, SOAREnabled: false, ComplianceEnabled: false,
|
||||
MaxScansMonth: 100000,
|
||||
RetentionDays: 30,
|
||||
SOCEnabled: true, SLAEnabled: true, SOAREnabled: false, ComplianceEnabled: false,
|
||||
PriceMonthCents: 8990000, // 89 900 ₽/мес
|
||||
},
|
||||
"professional": {
|
||||
ID: "professional", Name: "Professional",
|
||||
Description: "Полный AI SOC: SOAR, compliance, расширенная аналитика",
|
||||
MaxUsers: 50, MaxEventsMonth: 500000, MaxIncidents: 1000, MaxSensors: 25,
|
||||
RetentionDays: 90, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true,
|
||||
MaxScansMonth: 500000,
|
||||
RetentionDays: 90,
|
||||
SOCEnabled: true, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true,
|
||||
PriceMonthCents: 14990000, // 149 900 ₽/мес
|
||||
},
|
||||
"enterprise": {
|
||||
ID: "enterprise", Name: "Enterprise",
|
||||
Description: "On-premise / выделенный инстанс. Сертификация — на стороне заказчика",
|
||||
MaxUsers: -1, MaxEventsMonth: -1, MaxIncidents: -1, MaxSensors: -1,
|
||||
RetentionDays: 365, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true,
|
||||
MaxScansMonth: -1, // unlimited
|
||||
RetentionDays: 365,
|
||||
SOCEnabled: true, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true,
|
||||
OnPremise: true,
|
||||
PriceMonthCents: -1, // по запросу
|
||||
},
|
||||
|
|
@ -79,7 +96,17 @@ func (t *Tenant) GetPlan() Plan {
|
|||
if p, ok := DefaultPlans[t.PlanID]; ok {
|
||||
return p
|
||||
}
|
||||
return DefaultPlans["starter"]
|
||||
return DefaultPlans["free"] // secure default: unknown plan → free tier
|
||||
}
|
||||
|
||||
// CanAccessSOC returns true if the tenant's plan includes SOC Dashboard access.
|
||||
func (t *Tenant) CanAccessSOC() bool {
|
||||
return t.GetPlan().SOCEnabled
|
||||
}
|
||||
|
||||
// ScanLimit returns the monthly scan quota for this tenant (-1=unlimited).
|
||||
func (t *Tenant) ScanLimit() int {
|
||||
return t.GetPlan().MaxScansMonth
|
||||
}
|
||||
|
||||
// CanIngestEvent checks if the tenant can still ingest events this month.
|
||||
|
|
|
|||
|
|
@ -65,12 +65,24 @@ func currentPeriod() (time.Time, time.Time) {
|
|||
}
|
||||
|
||||
// RecordScan atomically increments the scan counter and checks quota.
|
||||
// Returns remaining scans. Returns error if quota exceeded.
|
||||
// Uses the free tier default limit (1000). For plan-aware quotas, use RecordScanWithLimit.
|
||||
func (t *UsageTracker) RecordScan(userID, ip string) (int, error) {
|
||||
return t.RecordScanWithLimit(userID, ip, 1000)
|
||||
}
|
||||
|
||||
// RecordScanWithLimit atomically increments the scan counter and checks against planLimit.
|
||||
// planLimit: -1=unlimited, 0=no scans allowed, >0=monthly cap.
|
||||
// Returns remaining scans. Returns error if quota exceeded.
|
||||
func (t *UsageTracker) RecordScanWithLimit(userID, ip string, planLimit int) (int, error) {
|
||||
if t.db == nil {
|
||||
return 999, nil // no DB = no limits
|
||||
}
|
||||
|
||||
// Plan explicitly forbids scanning
|
||||
if planLimit == 0 {
|
||||
return 0, fmt.Errorf("scanning not available on current plan")
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
|
|
@ -84,6 +96,12 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) {
|
|||
lookupVal = userID
|
||||
}
|
||||
|
||||
// Resolve effective limit for DB storage (unlimited = 0 sentinel in DB)
|
||||
dbLimit := planLimit
|
||||
if planLimit < 0 {
|
||||
dbLimit = 0 // 0 in DB = unlimited
|
||||
}
|
||||
|
||||
// Try to get existing quota record for current period
|
||||
var scansUsed, scansLimit int
|
||||
var quotaID string
|
||||
|
|
@ -94,10 +112,14 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) {
|
|||
err := t.db.QueryRow(query, lookupVal, periodStart).Scan("aID, &scansUsed, &scansLimit)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Create new quota record
|
||||
// Create new quota record with plan-based limit
|
||||
quotaID = generateID("usg")
|
||||
plan := "free"
|
||||
limit := 1000
|
||||
if planLimit > 1000 {
|
||||
plan = "paid"
|
||||
} else if planLimit < 0 {
|
||||
plan = "unlimited"
|
||||
}
|
||||
var insertQuery string
|
||||
if userID != "" {
|
||||
insertQuery = `INSERT INTO usage_quotas (id, user_id, plan, scans_used, scans_limit, period_start, period_end)
|
||||
|
|
@ -106,12 +128,15 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) {
|
|||
insertQuery = `INSERT INTO usage_quotas (id, ip_addr, plan, scans_used, scans_limit, period_start, period_end)
|
||||
VALUES ($1, $2, $3, 1, $4, $5, $6)`
|
||||
}
|
||||
_, err = t.db.Exec(insertQuery, quotaID, lookupVal, plan, limit, periodStart, periodEnd)
|
||||
_, err = t.db.Exec(insertQuery, quotaID, lookupVal, plan, dbLimit, periodStart, periodEnd)
|
||||
if err != nil {
|
||||
slog.Error("usage: create quota", "error", err)
|
||||
return 999, nil // fail open — don't block on DB errors
|
||||
}
|
||||
return limit - 1, nil
|
||||
if planLimit < 0 {
|
||||
return -1, nil // unlimited
|
||||
}
|
||||
return planLimit - 1, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -119,7 +144,13 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) {
|
|||
return 999, nil // fail open
|
||||
}
|
||||
|
||||
// Unlimited plan (scans_limit = 0)
|
||||
// Update stored limit if plan changed (e.g. upgrade mid-month)
|
||||
if scansLimit != dbLimit {
|
||||
t.db.Exec(`UPDATE usage_quotas SET scans_limit = $1 WHERE id = $2`, dbLimit, quotaID)
|
||||
scansLimit = dbLimit
|
||||
}
|
||||
|
||||
// Unlimited plan (scans_limit = 0 in DB)
|
||||
if scansLimit == 0 {
|
||||
t.db.Exec(`UPDATE usage_quotas SET scans_used = scans_used + 1 WHERE id = $1`, quotaID)
|
||||
return -1, nil // unlimited
|
||||
|
|
@ -127,7 +158,7 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) {
|
|||
|
||||
// Check quota
|
||||
if scansUsed >= scansLimit {
|
||||
return 0, fmt.Errorf("quota exceeded: %d/%d scans used this month", scansUsed, scansLimit)
|
||||
return 0, fmt.Errorf("quota exceeded: %d/%d scans used this month — upgrade at syntrex.pro/pricing", scansUsed, scansLimit)
|
||||
}
|
||||
|
||||
// Increment
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue