diff --git a/QUICKSTART.md b/QUICKSTART.md index 97c5bbb9..da70ecbd 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -78,16 +78,26 @@ kubectl port-forward -n streamspace svc/streamspace-ui 8080:80 Open your browser to: `http://localhost:8080` -### 2. Create Your First User +### 2. Log In with Admin Account -**Using Local Authentication**: +**Retrieve Admin Credentials** (Helm deployment): ```bash -# Create user via API or web UI -# Default admin credentials are set during installation -# See chart/values.yaml for configuration +# Get auto-generated admin password +kubectl get secret streamspace-admin-credentials \ + -n streamspace \ + -o jsonpath='{.data.password}' | base64 -d && echo + +# Username: admin +# Email: admin@streamspace.local ``` +**Alternative Methods**: +- **Environment Variable**: Set `ADMIN_PASSWORD` in API deployment +- **Setup Wizard**: Visit `/setup` if no password is configured + +See [Admin Onboarding Guide](docs/ADMIN_ONBOARDING.md) for complete details. + **Using SSO** (Authentik/Keycloak): Configure OIDC or SAML in `chart/values.yaml`: diff --git a/api/cmd/main.go b/api/cmd/main.go index 9ecc7662..c1fb0264 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -258,6 +258,7 @@ func main() { schedulingHandler := handlers.NewSchedulingHandler(database) securityHandler := handlers.NewSecurityHandler(database) templateVersioningHandler := handlers.NewTemplateVersioningHandler(database) + setupHandler := handlers.NewSetupHandler(database) // NOTE: Billing is now handled by the streamspace-billing plugin // SECURITY: Initialize webhook authentication @@ -268,7 +269,7 @@ func main() { } // Setup routes - setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, websocketHandler, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, jwtManager, userDB, redisCache, webhookSecret) + setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, websocketHandler, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret) // Create HTTP server with security timeouts srv := &http.Server{ @@ -349,7 +350,7 @@ func main() { log.Println("Graceful shutdown completed") } -func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, websocketHandler *handlers.WebSocketHandler, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { +func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, websocketHandler *handlers.WebSocketHandler, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { // SECURITY: Create authentication middleware authMiddleware := auth.Middleware(jwtManager, userDB) adminMiddleware := auth.RequireRole("admin") @@ -372,6 +373,7 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH authGroup := v1.Group("/auth") { authHandler.RegisterRoutes(authGroup) + setupHandler.RegisterRoutes(authGroup) } // PROTECTED ROUTES - Require authentication @@ -392,8 +394,8 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH sessions.PATCH("/:id/tags", cache.InvalidateCacheMiddleware(redisCache, cache.SessionPattern()), h.UpdateSessionTags) sessions.GET("/:id/connect", h.ConnectSession) sessions.POST("/:id/disconnect", h.DisconnectSession) - sessions.POST("/:id/heartbeat", h.SessionHeartbeat) + // NOTE: Session heartbeat is registered by ActivityHandler.RegisterRoutes() // NOTE: Session recording is now handled by the streamspace-recording plugin // Install it via: Admin → Plugins → streamspace-recording @@ -590,30 +592,30 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH templatesWrite.DELETE("/:id", cache.InvalidateCacheMiddleware(redisCache, cache.TemplatePattern()), h.DeleteTemplate) // Template Versioning (operator only) - templatesWrite.POST("/:templateId/versions", templateVersioningHandler.CreateTemplateVersion) - templatesWrite.GET("/:templateId/versions", templateVersioningHandler.ListTemplateVersions) - templatesWrite.GET("/versions/:versionId", templateVersioningHandler.GetTemplateVersion) - templatesWrite.POST("/versions/:versionId/publish", templateVersioningHandler.PublishTemplateVersion) - templatesWrite.POST("/versions/:versionId/deprecate", templateVersioningHandler.DeprecateTemplateVersion) - templatesWrite.POST("/versions/:versionId/set-default", templateVersioningHandler.SetDefaultTemplateVersion) - templatesWrite.POST("/versions/:versionId/clone", templateVersioningHandler.CloneTemplateVersion) + templatesWrite.POST("/:id/versions", templateVersioningHandler.CreateTemplateVersion) + templatesWrite.GET("/:id/versions", templateVersioningHandler.ListTemplateVersions) + templatesWrite.GET("/:id/versions/:versionId", templateVersioningHandler.GetTemplateVersion) + templatesWrite.POST("/:id/versions/:versionId/publish", templateVersioningHandler.PublishTemplateVersion) + templatesWrite.POST("/:id/versions/:versionId/deprecate", templateVersioningHandler.DeprecateTemplateVersion) + templatesWrite.POST("/:id/versions/:versionId/set-default", templateVersioningHandler.SetDefaultTemplateVersion) + templatesWrite.POST("/:id/versions/:versionId/clone", templateVersioningHandler.CloneTemplateVersion) // Template Testing (operator only) - templatesWrite.POST("/versions/:versionId/tests", templateVersioningHandler.CreateTemplateTest) - templatesWrite.GET("/versions/:versionId/tests", templateVersioningHandler.ListTemplateTests) - templatesWrite.PATCH("/tests/:testId", templateVersioningHandler.UpdateTemplateTestStatus) + templatesWrite.POST("/:id/versions/:versionId/tests", templateVersioningHandler.CreateTemplateTest) + templatesWrite.GET("/:id/versions/:versionId/tests", templateVersioningHandler.ListTemplateTests) + templatesWrite.PATCH("/:id/versions/:versionId/tests/:testId", templateVersioningHandler.UpdateTemplateTestStatus) // Template Inheritance - templatesWrite.GET("/:templateId/inheritance", templateVersioningHandler.GetTemplateInheritance) + templatesWrite.GET("/:id/inheritance", templateVersioningHandler.GetTemplateInheritance) } } - // Catalog (read: all users, write: operators/admins) + // Catalog repositories (read: all users, write: operators/admins) + // NOTE: Template catalog routes are handled by CatalogHandler.RegisterRoutes() catalog := protected.Group("/catalog") { - // Cache catalog data for 10 minutes (changes on sync) + // Repository management catalog.GET("/repositories", cache.CacheMiddleware(redisCache, 10*time.Minute), h.ListRepositories) - catalog.GET("/templates", cache.CacheMiddleware(redisCache, 10*time.Minute), h.BrowseCatalog) // Write operations require operator role catalogWrite := catalog.Group("") @@ -702,7 +704,7 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH } // Session activity recording and queries - sessionActivity := protected.Group("/sessions/:sessionId/activity") + sessionActivity := protected.Group("/sessions/:id/activity") { // Log new activity event (for internal API use) sessionActivity.POST("/log", sessionActivityHandler.LogActivityEvent) diff --git a/api/internal/db/database.go b/api/internal/db/database.go index b1a2720a..f943c0ad 100644 --- a/api/internal/db/database.go +++ b/api/internal/db/database.go @@ -69,11 +69,14 @@ package db import ( "database/sql" "fmt" + "log" "net" + "os" "regexp" "strconv" "strings" + "golang.org/x/crypto/bcrypt" _ "github.com/lib/pq" ) @@ -397,8 +400,8 @@ func (d *Database) Migrate() error { `CREATE INDEX IF NOT EXISTS idx_catalog_templates_category ON catalog_templates(category)`, `CREATE INDEX IF NOT EXISTS idx_catalog_templates_app_type ON catalog_templates(app_type)`, - // Template versions (track template version history) - `CREATE TABLE IF NOT EXISTS template_versions ( + // Catalog template versions (track version history from repositories) + `CREATE TABLE IF NOT EXISTS catalog_template_versions ( id SERIAL PRIMARY KEY, template_id INT REFERENCES catalog_templates(id) ON DELETE CASCADE, version VARCHAR(50) NOT NULL, @@ -1086,14 +1089,17 @@ func (d *Database) Migrate() error { max_memory INT, max_storage INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, COALESCE(team_id, '')) + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`, // Create indexes for resource quotas `CREATE INDEX IF NOT EXISTS idx_resource_quotas_user_id ON resource_quotas(user_id)`, `CREATE INDEX IF NOT EXISTS idx_resource_quotas_team_id ON resource_quotas(team_id)`, + // Unique constraint for user quotas (handle NULL team_id with partial indexes) + `CREATE UNIQUE INDEX IF NOT EXISTS idx_resource_quotas_user_team ON resource_quotas(user_id, team_id) WHERE team_id IS NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_resource_quotas_user_only ON resource_quotas(user_id) WHERE team_id IS NULL`, + // Quota policies table (defines quota enforcement rules) `CREATE TABLE IF NOT EXISTS quota_policies ( id VARCHAR(255) PRIMARY KEY, @@ -1960,5 +1966,133 @@ func (d *Database) Migrate() error { } } + // After migrations, ensure admin password is configured + if err := d.ensureAdminPassword(); err != nil { + return fmt.Errorf("failed to configure admin password: %w", err) + } + + // Check for password reset request + if err := d.checkPasswordReset(); err != nil { + return fmt.Errorf("failed to process password reset: %w", err) + } + + return nil +} + +// ensureAdminPassword configures the admin password using multiple fallback methods +// Priority order: +// 1. ADMIN_PASSWORD environment variable (Kubernetes Secret or manual) +// 2. Leave NULL - enables setup wizard mode +func (d *Database) ensureAdminPassword() error { + // Check if admin user exists and has a password + var passwordHash sql.NullString + err := d.db.QueryRow("SELECT password_hash FROM users WHERE id = 'admin'").Scan(&passwordHash) + if err != nil { + // Admin user doesn't exist yet, skip (will be created by migration) + if err == sql.ErrNoRows { + return nil + } + return fmt.Errorf("failed to check admin user: %w", err) + } + + // Admin already has a password - don't override + if passwordHash.Valid && passwordHash.String != "" { + log.Println("✓ Admin user already has a password configured") + return nil + } + + // Priority 1: Check ADMIN_PASSWORD environment variable + password := os.Getenv("ADMIN_PASSWORD") + if password != "" { + log.Println("🔑 Using admin password from ADMIN_PASSWORD environment variable") + + // Validate password strength + if len(password) < 8 { + return fmt.Errorf("ADMIN_PASSWORD must be at least 8 characters long") + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash admin password: %w", err) + } + + // Update admin user + _, err = d.db.Exec("UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = 'admin'", string(hashedPassword)) + if err != nil { + return fmt.Errorf("failed to set admin password: %w", err) + } + + log.Println("✓ Admin password configured successfully from environment variable") + return nil + } + + // Priority 2: No password - enable setup wizard mode + log.Println("⚠️ ═══════════════════════════════════════════════════════════════") + log.Println("⚠️ ADMIN USER HAS NO PASSWORD SET!") + log.Println("⚠️ ═══════════════════════════════════════════════════════════════") + log.Println("⚠️ ") + log.Println("⚠️ The admin account requires password configuration.") + log.Println("⚠️ ") + log.Println("⚠️ Setup wizard mode is ENABLED at: /api/v1/auth/setup") + log.Println("⚠️ ") + log.Println("⚠️ Alternative methods:") + log.Println("⚠️ 1. Set ADMIN_PASSWORD environment variable") + log.Println("⚠️ 2. Use the setup wizard in your browser") + log.Println("⚠️ 3. Check Helm chart for auto-generated credentials") + log.Println("⚠️ ") + log.Println("⚠️ ═══════════════════════════════════════════════════════════════") + + return nil // Not an error, just informational +} + +// checkPasswordReset checks for ADMIN_PASSWORD_RESET environment variable +// and resets the admin password if set. This is for account recovery. +func (d *Database) checkPasswordReset() error { + resetPassword := os.Getenv("ADMIN_PASSWORD_RESET") + if resetPassword == "" { + return nil // No reset requested + } + + log.Println("⚠️ ═══════════════════════════════════════════════════════════════") + log.Println("⚠️ ADMIN PASSWORD RESET DETECTED!") + log.Println("⚠️ ═══════════════════════════════════════════════════════════════") + + // Validate password strength + if len(resetPassword) < 8 { + return fmt.Errorf("ADMIN_PASSWORD_RESET must be at least 8 characters long") + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(resetPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash reset password: %w", err) + } + + // Update admin password + result, err := d.db.Exec("UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = 'admin'", string(hashedPassword)) + if err != nil { + return fmt.Errorf("failed to reset admin password: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check reset result: %w", err) + } + + if rowsAffected == 0 { + log.Println("⚠️ Admin user not found - password reset failed") + return fmt.Errorf("admin user not found") + } + + log.Println("✓ Admin password RESET successfully!") + log.Println("⚠️ ") + log.Println("⚠️ NEXT STEPS:") + log.Println("⚠️ 1. Remove ADMIN_PASSWORD_RESET environment variable") + log.Println("⚠️ 2. Restart the API deployment") + log.Println("⚠️ 3. Log in with the new password") + log.Println("⚠️ ") + log.Println("⚠️ ═══════════════════════════════════════════════════════════════") + return nil } diff --git a/api/internal/handlers/sessionactivity.go b/api/internal/handlers/sessionactivity.go index b73fb9ca..84838d5d 100644 --- a/api/internal/handlers/sessionactivity.go +++ b/api/internal/handlers/sessionactivity.go @@ -213,7 +213,7 @@ func (h *SessionActivityHandler) LogActivityEvent(c *gin.Context) { // GetSessionActivity returns activity log for a specific session func (h *SessionActivityHandler) GetSessionActivity(c *gin.Context) { ctx := context.Background() - sessionID := c.Param("sessionId") + sessionID := c.Param("id") // Pagination limit := 100 @@ -397,7 +397,7 @@ func (h *SessionActivityHandler) GetActivityStats(c *gin.Context) { // GetSessionTimeline returns a timeline view of session activity func (h *SessionActivityHandler) GetSessionTimeline(c *gin.Context) { ctx := context.Background() - sessionID := c.Param("sessionId") + sessionID := c.Param("id") query := ` SELECT id, event_type, event_category, description, diff --git a/api/internal/handlers/setup.go b/api/internal/handlers/setup.go new file mode 100644 index 00000000..fb675e48 --- /dev/null +++ b/api/internal/handlers/setup.go @@ -0,0 +1,361 @@ +// Package handlers provides HTTP handlers for the StreamSpace API. +// This file implements the first-run setup wizard for admin user onboarding. +// +// Purpose: +// - Provides a secure setup wizard for initial admin password configuration +// - Enables account recovery when admin password is lost or not set +// - Automatically disables after admin account is configured +// - Works as fallback when Helm secret or environment variable not available +// +// Security Features: +// - Only accessible when admin account has no password set +// - Password strength validation (minimum 12 characters) +// - Password confirmation to prevent typos +// - Email validation for admin contact +// - Single-use wizard (auto-disables after setup) +// - Atomic database transaction +// - Input sanitization and validation +// +// Integration: +// - Part of multi-layered admin onboarding strategy +// - Priority 3 fallback after Helm secret and environment variable +// - Works with database migration admin user creation +// - Compatible with all authentication modes (local, SAML, OIDC) +// +// Thread Safety: +// - All database operations are thread-safe via connection pooling +// - No shared mutable state between requests +package handlers + +import ( + "database/sql" + "fmt" + "net/http" + "regexp" + + "github.com/gin-gonic/gin" + "github.com/streamspace/streamspace/api/internal/db" + "golang.org/x/crypto/bcrypt" +) + +// SetupHandler handles initial admin setup wizard +type SetupHandler struct { + DB *db.Database +} + +// NewSetupHandler creates a new setup handler +func NewSetupHandler(database *db.Database) *SetupHandler { + return &SetupHandler{ + DB: database, + } +} + +// ============================================================================ +// SETUP WIZARD STATUS +// ============================================================================ + +// SetupStatusResponse returns whether the setup wizard should be displayed +type SetupStatusResponse struct { + SetupRequired bool `json:"setupRequired"` + AdminExists bool `json:"adminExists"` + HasPassword bool `json:"hasPassword"` + Message string `json:"message,omitempty"` +} + +// GetSetupStatus checks if the setup wizard should be enabled +// GET /api/v1/auth/setup/status +// +// Returns: +// 200 OK: Setup status information +// 500 Internal Server Error: Database error +// +// Response body: +// { +// "setupRequired": true/false, +// "adminExists": true/false, +// "hasPassword": true/false, +// "message": "Setup wizard is enabled/disabled" +// } +func (h *SetupHandler) GetSetupStatus(c *gin.Context) { + setupRequired, adminExists, hasPassword := h.isSetupRequired() + + var message string + if setupRequired { + message = "Setup wizard is available - admin account needs password configuration" + } else if !adminExists { + message = "Setup wizard unavailable - admin user not created yet (check database migration)" + } else if hasPassword { + message = "Setup wizard disabled - admin account is already configured" + } + + c.JSON(http.StatusOK, SetupStatusResponse{ + SetupRequired: setupRequired, + AdminExists: adminExists, + HasPassword: hasPassword, + Message: message, + }) +} + +// isSetupRequired checks if the setup wizard should be accessible +// Returns: (setupRequired, adminExists, hasPassword) +func (h *SetupHandler) isSetupRequired() (bool, bool, bool) { + var passwordHash sql.NullString + err := h.DB.DB().QueryRow("SELECT password_hash FROM users WHERE id = 'admin'").Scan(&passwordHash) + + if err != nil { + if err == sql.ErrNoRows { + // Admin user doesn't exist yet + return false, false, false + } + // Database error - don't allow setup + return false, true, false + } + + // Admin exists, check if password is set + hasPassword := passwordHash.Valid && passwordHash.String != "" + + // Setup required if admin exists but has no password + return !hasPassword, true, hasPassword +} + +// ============================================================================ +// SETUP WIZARD EXECUTION +// ============================================================================ + +// SetupAdminRequest is the request body for admin setup +type SetupAdminRequest struct { + Password string `json:"password" binding:"required"` + PasswordConfirm string `json:"passwordConfirm" binding:"required"` + Email string `json:"email" binding:"required,email"` +} + +// SetupAdminResponse is the response after successful setup +type SetupAdminResponse struct { + Message string `json:"message"` + Username string `json:"username"` + Email string `json:"email"` +} + +// SetupAdmin configures the initial admin password +// POST /api/v1/auth/setup +// +// This endpoint is only available when the admin account exists but has no password. +// After successful setup, it automatically disables the setup wizard. +// +// Request body: +// { +// "password": "secure-password-min-12-chars", +// "passwordConfirm": "secure-password-min-12-chars", +// "email": "admin@example.com" +// } +// +// Returns: +// 200 OK: Setup completed successfully +// 400 Bad Request: Invalid input or validation error +// 403 Forbidden: Setup wizard is disabled (admin already configured) +// 500 Internal Server Error: Database error +func (h *SetupHandler) SetupAdmin(c *gin.Context) { + // Check if setup is allowed + setupRequired, adminExists, hasPassword := h.isSetupRequired() + + if !setupRequired { + if !adminExists { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Setup wizard is not available - admin user not created yet", + "hint": "Check database migration logs for errors", + }) + return + } + if hasPassword { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Setup wizard is disabled - admin account is already configured", + "hint": "Use the login page or password reset mechanism instead", + }) + return + } + } + + // Parse and validate request + var req SetupAdminRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request format", + "details": err.Error(), + }) + return + } + + // Validate password confirmation + if req.Password != req.PasswordConfirm { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Passwords do not match", + }) + return + } + + // Validate password strength + if err := validatePasswordStrength(req.Password); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + // Validate email format (additional validation beyond Gin binding) + if err := validateEmailFormat(req.Email); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to process password", + }) + return + } + + // Update admin user in a transaction to ensure atomicity + tx, err := h.DB.DB().Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to start database transaction", + }) + return + } + defer tx.Rollback() + + // Update admin user (only if password is still NULL - prevents race conditions) + result, err := tx.Exec(` + UPDATE users + SET password_hash = $1, email = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = 'admin' AND (password_hash IS NULL OR password_hash = '') + `, string(hashedPassword), req.Email) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to configure admin account", + }) + return + } + + // Check if the update actually modified a row + rowsAffected, err := result.RowsAffected() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to verify setup completion", + }) + return + } + + if rowsAffected == 0 { + // Another request beat us to it (race condition) or password was already set + c.JSON(http.StatusConflict, gin.H{ + "error": "Admin account was already configured by another request", + "hint": "Setup wizard is now disabled - use the login page", + }) + return + } + + // Commit transaction + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to commit admin configuration", + }) + return + } + + // Log success (for audit and monitoring) + c.JSON(http.StatusOK, SetupAdminResponse{ + Message: "Admin account configured successfully - setup wizard is now disabled", + Username: "admin", + Email: req.Email, + }) +} + +// ============================================================================ +// INPUT VALIDATION HELPERS +// ============================================================================ + +// validatePasswordStrength checks if a password meets minimum security requirements +// +// Requirements: +// - Minimum 12 characters (NIST 800-63B recommendation) +// - No maximum length restriction (allows passphrases) +// - Future: Can add complexity requirements if needed +// +// Returns: +// error: Validation error message or nil if valid +func validatePasswordStrength(password string) error { + if len(password) < 12 { + return fmt.Errorf("password must be at least 12 characters long (NIST recommendation for admin accounts)") + } + + if len(password) > 128 { + return fmt.Errorf("password must be 128 characters or less") + } + + // Check for common weak passwords (optional - can expand this list) + weakPasswords := []string{ + "123456789012", + "password1234", + "admin1234567", + "changeme1234", + } + + for _, weak := range weakPasswords { + if password == weak { + return fmt.Errorf("password is too common - please choose a stronger password") + } + } + + return nil +} + +// validateEmailFormat validates email address format +// +// Uses RFC 5322 simplified regex pattern for validation +// Additional validation beyond Gin's email binding tag +// +// Returns: +// error: Validation error message or nil if valid +func validateEmailFormat(email string) error { + if len(email) == 0 { + return fmt.Errorf("email is required") + } + + if len(email) > 254 { + return fmt.Errorf("email must be 254 characters or less (RFC 5321 limit)") + } + + // Simplified RFC 5322 regex (catches most common errors) + // Full RFC 5322 regex is extremely complex and unnecessary for basic validation + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(email) { + return fmt.Errorf("invalid email format") + } + + return nil +} + +// ============================================================================ +// ROUTE REGISTRATION +// ============================================================================ + +// RegisterRoutes registers setup wizard endpoints +// These routes are public (no authentication required) as they are needed +// for initial admin account setup before authentication is possible. +// +// Routes: +// GET /setup/status - Check if setup wizard is enabled +// POST /setup - Configure admin account +func (h *SetupHandler) RegisterRoutes(router *gin.RouterGroup) { + // GET /api/v1/auth/setup/status - Check setup status + router.GET("/setup/status", h.GetSetupStatus) + + // POST /api/v1/auth/setup - Execute setup wizard + router.POST("/setup", h.SetupAdmin) +} diff --git a/api/internal/handlers/template_versioning.go b/api/internal/handlers/template_versioning.go index 168d4f6a..e68bde83 100644 --- a/api/internal/handlers/template_versioning.go +++ b/api/internal/handlers/template_versioning.go @@ -141,7 +141,7 @@ func NewTemplateVersioningHandler(database *db.Database) *TemplateVersioningHand // CreateTemplateVersion creates a new version of a template func (h *TemplateVersioningHandler) CreateTemplateVersion(c *gin.Context) { - templateID := c.Param("templateId") + templateID := c.Param("id") userID := c.GetString("user_id") var req struct { @@ -196,7 +196,7 @@ func (h *TemplateVersioningHandler) CreateTemplateVersion(c *gin.Context) { // ListTemplateVersions lists all versions of a template func (h *TemplateVersioningHandler) ListTemplateVersions(c *gin.Context) { - templateID := c.Param("templateId") + templateID := c.Param("id") status := c.Query("status") query := ` @@ -519,7 +519,7 @@ func (h *TemplateVersioningHandler) UpdateTemplateTestStatus(c *gin.Context) { // GetTemplateInheritance retrieves the inheritance chain for a template func (h *TemplateVersioningHandler) GetTemplateInheritance(c *gin.Context) { - templateID := c.Param("templateId") + templateID := c.Param("id") // Get parent template if exists var parentTemplateID sql.NullString diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 8899b667..21de7826 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,185 +1,151 @@ +================================================================================ + 🚀 StreamSpace v{{ .Chart.AppVersion }} has been installed! +================================================================================ -████████████████████████████████████████████████████████████████ -█ █ -█ StreamSpace v{{ .Chart.AppVersion }} has been installed! █ -█ █ -████████████████████████████████████████████████████████████████ +{{- if .Values.api.enabled }} -🚀 Stream any app, anywhere - 100% open source +📋 INITIAL ADMIN CREDENTIALS +──────────────────────────────────────────────────────────────────────────── -Your StreamSpace installation is ready. Here's what to do next: +{{- if .Values.auth.admin.existingSecret }} -📋 INSTALLATION STATUS -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Your admin credentials are stored in the existing secret: + {{ .Values.auth.admin.existingSecret }} -Namespace: {{ .Release.Namespace }} -Release Name: {{ .Release.Name }} +{{- else }} + +Username: admin -Components installed: - {{- if .Values.controller.enabled }} - ✓ Controller ({{ .Values.controller.replicaCount }} replica{{ if gt (.Values.controller.replicaCount | int) 1 }}s{{ end }}) - {{- end }} - {{- if .Values.api.enabled }} - ✓ API Backend ({{ .Values.api.replicaCount }} replica{{ if gt (.Values.api.replicaCount | int) 1 }}s{{ end }}) - {{- end }} - {{- if .Values.ui.enabled }} - ✓ Web UI ({{ .Values.ui.replicaCount }} replica{{ if gt (.Values.ui.replicaCount | int) 1 }}s{{ end }}) - {{- end }} - {{- if and .Values.postgresql.enabled (not .Values.postgresql.external.enabled) }} - ✓ PostgreSQL Database - {{- end }} - {{- if .Values.monitoring.enabled }} - ✓ Monitoring (ServiceMonitor, PrometheusRules, Dashboard) - {{- end }} +{{- if .Values.auth.admin.password }} -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Password: (You provided a custom password) -🔍 CHECK STATUS +{{- else }} -Watch pods starting up: - kubectl get pods -n {{ .Release.Namespace }} -w +Password: Run this command to retrieve your auto-generated password: -Check controller logs: - kubectl logs -n {{ .Release.Namespace }} deploy/{{ include "streamspace.fullname" . }}-controller -f + kubectl get secret {{ include "streamspace.fullname" . }}-admin-credentials \ + -n {{ .Release.Namespace }} \ + -o jsonpath='{.data.password}' | base64 -d && echo -Check API logs: - kubectl logs -n {{ .Release.Namespace }} deploy/{{ include "streamspace.fullname" . }}-api -f +{{- end }} -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Email: {{ .Values.auth.admin.email | default "admin@streamspace.local" }} + +⚠️ IMPORTANT SECURITY NOTES: + 1. SAVE THIS PASSWORD - This is the only time it will be displayed! + 2. Change the password after first login via the web UI + 3. The secret is marked with "keep" policy - it won't be deleted on uninstall + +{{- end }} + +{{- end }} 🌐 ACCESS STREAMSPACE +──────────────────────────────────────────────────────────────────────────── {{- if .Values.ingress.enabled }} +{{- range .Values.ingress.hosts }} -Web UI URL: - {{- range .Values.ingress.hosts }} - {{- if .host }} - {{ if $.Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ .host }} - {{- end }} - {{- end }} +Web UI: https://{{ .host }} +API: https://{{ .host }}/api +Health: https://{{ .host }}/health -{{- if not .Values.ingress.tls.enabled }} +{{- end }} -⚠️ WARNING: TLS is disabled. Your connection is NOT encrypted. - Enable TLS in production with: - --set ingress.tls.enabled=true +{{- if .Values.ingress.tls.enabled }} +✓ TLS/HTTPS is ENABLED +{{- else }} +⚠️ TLS/HTTPS is DISABLED - Enable it for production! {{- end }} {{- else }} -Port-forward to access locally: - kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "streamspace.fullname" . }}-ui 3000:80 +Ingress is disabled. To access StreamSpace: -Then visit: http://localhost:3000 +1. Port-forward the UI service: + kubectl port-forward -n {{ .Release.Namespace }} \ + svc/{{ include "streamspace.fullname" . }}-ui 8080:80 -{{- end }} +2. Port-forward the API service: + kubectl port-forward -n {{ .Release.Namespace }} \ + svc/{{ include "streamspace.fullname" . }}-api 8000:8000 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. Access the application: + Web UI: http://localhost:8080 + API: http://localhost:8000 -📚 NEXT STEPS - -1. Verify all pods are running: - kubectl get pods -n {{ .Release.Namespace }} - -2. {{- if and .Values.postgresql.enabled (not .Values.postgresql.external.enabled) }} - Check database is ready: - kubectl get statefulset {{ include "streamspace.fullname" . }}-postgres -n {{ .Release.Namespace }} - {{- end }} - -3. {{- if .Values.templates.deploy }} - View available templates: - kubectl get templates -n {{ .Release.Namespace }} - {{- else }} - Create application templates: - kubectl apply -f manifests/templates/ - {{- end }} - -4. Create your first session: - kubectl apply -f - < }> + {/* Public routes */} } /> + } /> + + {/* Protected user routes */} { + const response = await this.client.get('/auth/setup/status'); + return response.data; + } + + async setupAdmin(password: string, passwordConfirm: string, email: string): Promise<{ message: string; username: string; email: string }> { + const response = await this.client.post('/auth/setup', { password, passwordConfirm, email }); + return response.data; + } + // ============================================================================ // User Management // ============================================================================ diff --git a/ui/src/pages/SetupWizard.tsx b/ui/src/pages/SetupWizard.tsx new file mode 100644 index 00000000..82a9fd6d --- /dev/null +++ b/ui/src/pages/SetupWizard.tsx @@ -0,0 +1,391 @@ +import { useState, useEffect } from 'react'; +import { + Box, + Paper, + TextField, + Button, + Typography, + Container, + Alert, + CircularProgress, + LinearProgress, + List, + ListItem, + ListItemIcon, + ListItemText, +} from '@mui/material'; +import { + AdminPanelSettings as AdminIcon, + Check as CheckIcon, + Error as ErrorIcon, + Info as InfoIcon, +} from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../lib/api'; + +/** + * SetupWizard - First-run admin user onboarding + * + * Provides a browser-based setup wizard for configuring the initial admin password + * when no password has been set via Helm chart or environment variable. + * + * Features: + * - Check if setup is required (admin has no password) + * - Password strength validation (12+ characters for admin) + * - Password confirmation to prevent typos + * - Email validation for admin contact + * - Auto-redirect to login after successful setup + * - Fallback for account recovery + * + * Priority in admin onboarding: + * 1. Helm Chart with Kubernetes Secret (production) + * 2. ADMIN_PASSWORD environment variable (docker-compose, manual) + * 3. Setup Wizard (this page) - fallback for first-run or recovery + * + * @page + * @route /setup - Admin setup wizard (public, only when setup required) + * @access public - Available to anyone when admin has no password + * + * @component + * + * @returns {JSX.Element} Setup wizard interface + * + * @example + * // Route configuration: + * } /> + */ +export default function SetupWizard() { + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [email, setEmail] = useState('admin@streamspace.local'); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [loading, setLoading] = useState(false); + const [checkingStatus, setCheckingStatus] = useState(true); + const [setupRequired, setSetupRequired] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const navigate = useNavigate(); + + // Check if setup is required on component mount + useEffect(() => { + const checkSetupStatus = async () => { + try { + const status = await api.getSetupStatus(); + setSetupRequired(status.setupRequired); + setStatusMessage(status.message || ''); + + if (!status.setupRequired) { + // Setup not required - redirect to login after 3 seconds + setTimeout(() => { + navigate('/login'); + }, 3000); + } + } catch (err: any) { + console.error('Failed to check setup status:', err); + setError('Failed to check setup status. Please try again.'); + } finally { + setCheckingStatus(false); + } + }; + + checkSetupStatus(); + }, [navigate]); + + const validatePassword = (pwd: string): string | null => { + if (pwd.length < 12) { + return 'Password must be at least 12 characters long (NIST recommendation for admin accounts)'; + } + if (pwd.length > 128) { + return 'Password must be 128 characters or less'; + } + // Check for common weak passwords + const weakPasswords = [ + '123456789012', + 'password1234', + 'admin1234567', + 'changeme1234', + ]; + if (weakPasswords.includes(pwd.toLowerCase())) { + return 'Password is too common - please choose a stronger password'; + } + return null; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + + // Validate password + const passwordError = validatePassword(password); + if (passwordError) { + setError(passwordError); + return; + } + + // Validate password confirmation + if (password !== passwordConfirm) { + setError('Passwords do not match'); + return; + } + + // Validate email + if (!email || !email.includes('@')) { + setError('Please enter a valid email address'); + return; + } + + setLoading(true); + + try { + const response = await api.setupAdmin(password, passwordConfirm, email); + setSuccess(response.message); + + // Redirect to login after 2 seconds + setTimeout(() => { + navigate('/login'); + }, 2000); + } catch (err: any) { + const errorMessage = err.response?.data?.error || err.message || 'Failed to configure admin account'; + const hint = err.response?.data?.hint; + setError(hint ? `${errorMessage}\n${hint}` : errorMessage); + } finally { + setLoading(false); + } + }; + + // Loading state while checking setup status + if (checkingStatus) { + return ( + + + + + Checking setup status... + + + + ); + } + + // Setup not required - show message and redirect + if (!setupRequired) { + return ( + + + + + + Setup Not Required + + + {statusMessage || 'Admin account is already configured.'} + + + Redirecting to login page... + + + + + + ); + } + + // Setup required - show wizard + return ( + + + {/* Header */} + + + + StreamSpace Setup Wizard + + + Configure Your Admin Account + + + + {/* Setup Form */} + + {error && ( + setError('')}> + {error} + + )} + + {success && ( + + {success} + + )} + + + Initial Admin Configuration + + + + No admin password has been configured via Helm chart or environment variable. + Please set up your admin credentials to continue. + + +
+ + + + ), + }} + /> + + setEmail(e.target.value)} + margin="normal" + required + helperText="Admin contact email for notifications" + /> + + setPassword(e.target.value)} + margin="normal" + required + helperText="Minimum 12 characters (NIST recommendation for admin accounts)" + /> + + setPasswordConfirm(e.target.value)} + margin="normal" + required + helperText="Re-enter password to confirm" + /> + + + +
+ + {/* Information Panel */} + + + About Admin Onboarding + + + + StreamSpace uses a multi-layered approach for admin configuration: + + + + + + + + + + + + + + + + + + + + + + + + + + + + Note: This setup wizard automatically disables after you configure the + admin password. For security, change the password via the web UI after first login. + + + + + {/* Footer */} + + + StreamSpace v1.0.0 - Open Source Container Streaming Platform + + +
+
+ ); +}