diff --git a/internal/infrastructure/auth/handlers.go b/internal/infrastructure/auth/handlers.go index a92be3b..d5154d8 100644 --- a/internal/infrastructure/auth/handlers.go +++ b/internal/infrastructure/auth/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strings" + "time" ) // LoginRequest is the POST /api/auth/login body. @@ -48,13 +49,23 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc { return } - accessToken, err := NewAccessToken(user.Email, user.Role, secret, 0) + accessToken, err := Sign(Claims{ + Sub: user.Email, + Role: user.Role, + TenantID: user.TenantID, + Exp: time.Now().Add(15 * time.Minute).Unix(), + }, secret) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") return } - refreshToken, err := NewRefreshToken(user.Email, user.Role, secret, 0) + refreshToken, err := Sign(Claims{ + Sub: user.Email, + Role: user.Role, + TenantID: user.TenantID, + Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), + }, secret) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") return @@ -129,15 +140,29 @@ func HandleMe(store *UserStore) http.HandlerFunc { } } -// HandleListUsers returns all users (admin only). +// HandleListUsers returns users scoped to the caller's tenant (admin only). // GET /api/auth/users func HandleListUsers(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - users := store.ListUsers() + claims := GetClaims(r.Context()) + if claims == nil || claims.Role != "admin" { + writeAuthError(w, http.StatusForbidden, "admin role required") + return + } + + // SEC: Filter users by tenant_id to prevent cross-tenant data leak + allUsers := store.ListUsers() + var filtered []*User + for _, u := range allUsers { + if u.TenantID == claims.TenantID { + filtered = append(filtered, u) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "users": users, - "total": len(users), + "users": filtered, + "total": len(filtered), }) } } diff --git a/internal/infrastructure/auth/tenant_handlers.go b/internal/infrastructure/auth/tenant_handlers.go index 4273050..7bd9236 100644 --- a/internal/infrastructure/auth/tenant_handlers.go +++ b/internal/infrastructure/auth/tenant_handlers.go @@ -88,8 +88,10 @@ func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret [] // Still return success — code is in DB, user can retry } } else { - // Dev mode — include code in response - resp["verification_code_dev"] = code + // SEC: Never expose verification code in API response. + // Log server-side only for development debugging. + slog.Warn("email service not configured — verification code logged (dev only)", + "email", req.Email, "code", code) } w.Header().Set("Content-Type", "application/json") diff --git a/internal/infrastructure/auth/users.go b/internal/infrastructure/auth/users.go index 44ca00f..aef7175 100644 --- a/internal/infrastructure/auth/users.go +++ b/internal/infrastructure/auth/users.go @@ -30,6 +30,7 @@ type User struct { Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` // admin, analyst, viewer + TenantID string `json:"tenant_id,omitempty"` Active bool `json:"active"` EmailVerified bool `json:"email_verified"` PasswordHash string `json:"-"` // never serialized @@ -121,12 +122,13 @@ func (s *UserStore) migrate() error { s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false`) s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_token TEXT DEFAULT ''`) s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_expiry TIMESTAMPTZ`) + s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id TEXT DEFAULT ''`) return nil } // loadFromDB loads all users from DB into memory cache. func (s *UserStore) loadFromDB() { - rows, err := s.db.Query(`SELECT id, email, display_name, role, active, password_hash, created_at, last_login_at FROM users`) + rows, err := s.db.Query(`SELECT id, email, display_name, role, active, password_hash, created_at, last_login_at, COALESCE(tenant_id, '') FROM users`) if err != nil { slog.Error("load users from DB", "error", err) return @@ -138,7 +140,7 @@ func (s *UserStore) loadFromDB() { for rows.Next() { var u User var lastLogin sql.NullTime - if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Active, &u.PasswordHash, &u.CreatedAt, &lastLogin); err != nil { + if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Active, &u.PasswordHash, &u.CreatedAt, &lastLogin, &u.TenantID); err != nil { slog.Warn("load user row scan", "error", err) continue } @@ -156,8 +158,8 @@ func (s *UserStore) persistUser(u *User) { return } _, err := s.db.Exec(` - INSERT INTO users (id, email, display_name, role, active, email_verified, password_hash, verify_token, verify_expiry, created_at, last_login_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO users (id, email, display_name, role, active, email_verified, password_hash, verify_token, verify_expiry, created_at, last_login_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name, @@ -167,8 +169,9 @@ func (s *UserStore) persistUser(u *User) { password_hash = EXCLUDED.password_hash, verify_token = EXCLUDED.verify_token, verify_expiry = EXCLUDED.verify_expiry, - last_login_at = EXCLUDED.last_login_at`, - u.ID, u.Email, u.DisplayName, u.Role, u.Active, u.EmailVerified, u.PasswordHash, u.VerifyToken, u.VerifyExpiry, u.CreatedAt, u.LastLoginAt, + last_login_at = EXCLUDED.last_login_at, + tenant_id = EXCLUDED.tenant_id`, + u.ID, u.Email, u.DisplayName, u.Role, u.Active, u.EmailVerified, u.PasswordHash, u.VerifyToken, u.VerifyExpiry, u.CreatedAt, u.LastLoginAt, u.TenantID, ) if err != nil { slog.Error("persist user", "email", u.Email, "error", err) diff --git a/internal/transport/http/middleware.go b/internal/transport/http/middleware.go index 1d5796b..cf52256 100644 --- a/internal/transport/http/middleware.go +++ b/internal/transport/http/middleware.go @@ -3,29 +3,40 @@ package httpserver import ( "net/http" "os" + "strings" ) -// corsAllowedOrigin returns the configured CORS origin. -// Set SOC_CORS_ORIGIN in production (e.g. "https://soc.отражение.рус"). +// corsAllowedOrigins returns the configured CORS origins. +// Set SOC_CORS_ORIGIN in production (e.g. "https://syntrex.pro,https://xn--80akacl3adqr.xn--p1acf"). // Defaults to "*" for local development. -func corsAllowedOrigin() string { +func corsAllowedOrigins() []string { if v := os.Getenv("SOC_CORS_ORIGIN"); v != "" { - return v + parts := strings.Split(v, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts } - return "*" + return []string{"*"} } // corsMiddleware adds CORS headers with configurable origin. -// Production: set SOC_CORS_ORIGIN=https://your-domain.com +// Production: set SOC_CORS_ORIGIN=https://syntrex.pro,https://xn--80akacl3adqr.xn--p1acf func corsMiddleware(next http.Handler) http.Handler { - origin := corsAllowedOrigin() + origins := corsAllowedOrigins() + allowAll := len(origins) == 1 && origins[0] == "*" + allowedSet := make(map[string]bool, len(origins)) + for _, o := range origins { + allowedSet[o] = true + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if origin == "*" { + if allowAll { w.Header().Set("Access-Control-Allow-Origin", "*") } else { reqOrigin := r.Header.Get("Origin") - if reqOrigin == origin { - w.Header().Set("Access-Control-Allow-Origin", origin) + if allowedSet[reqOrigin] { + w.Header().Set("Access-Control-Allow-Origin", reqOrigin) w.Header().Set("Vary", "Origin") } }