diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go new file mode 100644 index 000000000..20dbc8e7d --- /dev/null +++ b/web/backend/api/auth.go @@ -0,0 +1,260 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/sipeed/picoclaw/web/backend/auth" +) + +type AuthHandler struct { + configStore *auth.AuthConfigStore + sessionStore auth.SessionStore +} + +func NewAuthHandler(configStore *auth.AuthConfigStore, sessionStore auth.SessionStore) *AuthHandler { + return &AuthHandler{ + configStore: configStore, + sessionStore: sessionStore, + } +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +type StatusResponse struct { + Enabled bool `json:"enabled"` + Configured bool `json:"configured"` + LoggedIn bool `json:"logged_in"` +} + +type SetupRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type SetupResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (h *AuthHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/auth/login", h.Login) + mux.HandleFunc("POST /api/auth/logout", h.Logout) + mux.HandleFunc("GET /api/auth/status", h.Status) + mux.HandleFunc("POST /api/auth/setup", h.Setup) + mux.HandleFunc("POST /api/auth/change-password", h.ChangePassword) + mux.HandleFunc("GET /api/auth/check", h.Check) +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, "invalid request body", http.StatusBadRequest) + return + } + + config := h.configStore.Get() + if !config.IsConfigured() { + writeJSONError(w, "authentication not configured", http.StatusServiceUnavailable) + return + } + + if req.Username != config.Username || !auth.VerifyPassword(req.Password, config.PasswordHash) { + writeJSON(w, LoginResponse{Success: false, Message: "invalid credentials"}, http.StatusUnauthorized) + return + } + + sessionID, err := auth.GenerateSessionID() + if err != nil { + writeJSONError(w, "failed to create session", http.StatusInternalServerError) + return + } + + _, err = h.sessionStore.Create(sessionID, config.SessionTTL) + if err != nil { + writeJSONError(w, "failed to create session", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(config.SessionTTL), + }) + + writeJSON(w, LoginResponse{Success: true}, http.StatusOK) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + sessionID := extractSessionID(r) + if sessionID != "" { + h.sessionStore.Delete(sessionID) + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + + writeJSON(w, LoginResponse{Success: true}, http.StatusOK) +} + +func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) { + config := h.configStore.Get() + loggedIn := false + + if config.IsConfigured() { + sessionID := extractSessionID(r) + if sessionID != "" { + loggedIn = h.sessionStore.Validate(sessionID) + } + } + + writeJSON(w, StatusResponse{ + Enabled: config.Enabled, + Configured: config.IsConfigured(), + LoggedIn: loggedIn, + }, http.StatusOK) +} + +func (h *AuthHandler) Setup(w http.ResponseWriter, r *http.Request) { + config := h.configStore.Get() + if config.IsConfigured() { + writeJSONError(w, "authentication already configured", http.StatusBadRequest) + return + } + + var req SetupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Username == "" || req.Password == "" { + writeJSONError(w, "username and password are required", http.StatusBadRequest) + return + } + + if len(req.Password) < 6 { + writeJSONError(w, "password must be at least 6 characters", http.StatusBadRequest) + return + } + + if err := h.configStore.SetCredentials(req.Username, req.Password); err != nil { + writeJSONError(w, "failed to save credentials", http.StatusInternalServerError) + return + } + + sessionID, err := auth.GenerateSessionID() + if err != nil { + writeJSONError(w, "failed to create session", http.StatusInternalServerError) + return + } + + newConfig := h.configStore.Get() + _, err = h.sessionStore.Create(sessionID, newConfig.SessionTTL) + if err != nil { + writeJSONError(w, "failed to create session", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(newConfig.SessionTTL), + }) + + writeJSON(w, SetupResponse{Success: true}, http.StatusOK) +} + +func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + config := h.configStore.Get() + if !config.IsConfigured() { + writeJSONError(w, "authentication not configured", http.StatusServiceUnavailable) + return + } + + sessionID := extractSessionID(r) + if sessionID == "" || !h.sessionStore.Validate(sessionID) { + writeJSONError(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, "invalid request body", http.StatusBadRequest) + return + } + + if !auth.VerifyPassword(req.CurrentPassword, config.PasswordHash) { + writeJSONError(w, "current password is incorrect", http.StatusBadRequest) + return + } + + if len(req.NewPassword) < 6 { + writeJSONError(w, "new password must be at least 6 characters", http.StatusBadRequest) + return + } + + if err := h.configStore.SetCredentials(config.Username, req.NewPassword); err != nil { + writeJSONError(w, "failed to update password", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]bool{"success": true}, http.StatusOK) +} + +func (h *AuthHandler) Check(w http.ResponseWriter, r *http.Request) { + config := h.configStore.Get() + if !config.IsConfigured() { + writeJSON(w, map[string]bool{"authenticated": true}, http.StatusOK) + return + } + + sessionID := extractSessionID(r) + authenticated := sessionID != "" && h.sessionStore.Validate(sessionID) + + writeJSON(w, map[string]bool{"authenticated": authenticated}, http.StatusOK) +} + +func extractSessionID(r *http.Request) string { + if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" { + return cookie.Value + } + return "" +} + +func writeJSON(w http.ResponseWriter, data interface{}, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func writeJSONError(w http.ResponseWriter, message string, status int) { + writeJSON(w, map[string]string{"error": message}, status) +} diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index e149d5671..75a6978d7 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -12,6 +12,7 @@ type launcherConfigPayload struct { Port int `json:"port"` Public bool `json:"public"` AllowedCIDRs []string `json:"allowed_cidrs"` + AuthEnabled bool `json:"auth_enabled"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { @@ -32,6 +33,7 @@ func (h *Handler) launcherFallbackConfig() launcherconfig.Config { Port: port, Public: h.serverPublic, AllowedCIDRs: append([]string(nil), h.serverCIDRs...), + AuthEnabled: false, } } @@ -51,6 +53,7 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request Port: cfg.Port, Public: cfg.Public, AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + AuthEnabled: cfg.AuthEnabled, }) } @@ -65,6 +68,7 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ Port: payload.Port, Public: payload.Public, AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + AuthEnabled: payload.AuthEnabled, } if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -81,5 +85,6 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ Port: cfg.Port, Public: cfg.Public, AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + AuthEnabled: cfg.AuthEnabled, }) } diff --git a/web/backend/auth/auth.go b/web/backend/auth/auth.go new file mode 100644 index 000000000..6f3032137 --- /dev/null +++ b/web/backend/auth/auth.go @@ -0,0 +1,54 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "time" +) + +type Session struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type Credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type Config struct { + Enabled bool `json:"enabled"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + SessionTTL time.Duration `json:"session_ttl"` +} + +func DefaultConfig() Config { + return Config{ + Enabled: false, + Username: "", + PasswordHash: "", + SessionTTL: 24 * time.Hour, + } +} + +func (c *Config) IsConfigured() bool { + return c.Enabled && c.Username != "" && c.PasswordHash != "" +} + +func GenerateSessionID() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func (s *Session) IsExpired() bool { + return time.Now().After(s.ExpiresAt) +} + +func (s *Session) Refresh(ttl time.Duration) { + s.ExpiresAt = time.Now().Add(ttl) +} diff --git a/web/backend/auth/middleware.go b/web/backend/auth/middleware.go new file mode 100644 index 000000000..d3a695ba8 --- /dev/null +++ b/web/backend/auth/middleware.go @@ -0,0 +1,103 @@ +package auth + +import ( + "net/http" + "strings" +) + +const ( + SessionCookieName = "picoclaw_session" + AuthorizationHeader = "Authorization" +) + +type AuthMiddleware struct { + configStore *AuthConfigStore + sessionStore SessionStore +} + +func NewAuthMiddleware(configStore *AuthConfigStore, sessionStore SessionStore) *AuthMiddleware { + return &AuthMiddleware{ + configStore: configStore, + sessionStore: sessionStore, + } +} + +func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !m.configStore.IsEnabled() { + next.ServeHTTP(w, r) + return + } + + if IsPublicRoute(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + sessionID := m.extractSessionID(r) + if sessionID == "" { + m.unauthorized(w, r) + return + } + + if !m.sessionStore.Validate(sessionID) { + m.unauthorized(w, r) + return + } + + m.sessionStore.Refresh(sessionID, m.configStore.Get().SessionTTL) + next.ServeHTTP(w, r) + }) +} + +func (m *AuthMiddleware) extractSessionID(r *http.Request) string { + if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" { + return cookie.Value + } + + authHeader := r.Header.Get(AuthorizationHeader) + if authHeader != "" { + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + return authHeader + } + + return "" +} + +func (m *AuthMiddleware) unauthorized(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized","code":401}`)) + return + } + http.Error(w, "Unauthorized", http.StatusUnauthorized) +} + +func IsAuthRoute(path string) bool { + return path == "/api/auth/login" || + path == "/api/auth/status" || + path == "/api/auth/setup" || + strings.HasPrefix(path, "/login") +} + +func IsPublicRoute(path string) bool { + publicPrefixes := []string{ + "/api/auth/", + "/login", + "/assets/", + "/favicon", + "/web-app-manifest", + "/site.webmanifest", + } + + for _, prefix := range publicPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + + return path == "/" || path == "/index.html" +} diff --git a/web/backend/auth/session.go b/web/backend/auth/session.go new file mode 100644 index 000000000..d87858990 --- /dev/null +++ b/web/backend/auth/session.go @@ -0,0 +1,112 @@ +package auth + +import ( + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type SessionStore interface { + Create(sessionID string, ttl time.Duration) (*Session, error) + Get(sessionID string) (*Session, bool) + Delete(sessionID string) + Validate(sessionID string) bool + Refresh(sessionID string, ttl time.Duration) bool + CleanupExpired() +} + +type MemorySessionStore struct { + mu sync.RWMutex + sessions map[string]*Session +} + +func NewMemorySessionStore() *MemorySessionStore { + store := &MemorySessionStore{ + sessions: make(map[string]*Session), + } + go store.cleanupRoutine() + return store +} + +func (s *MemorySessionStore) Create(sessionID string, ttl time.Duration) (*Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + + session := &Session{ + ID: sessionID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(ttl), + } + s.sessions[sessionID] = session + return session, nil +} + +func (s *MemorySessionStore) Get(sessionID string) (*Session, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + session, exists := s.sessions[sessionID] + if !exists || session.IsExpired() { + return nil, false + } + return session, true +} + +func (s *MemorySessionStore) Delete(sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) +} + +func (s *MemorySessionStore) Validate(sessionID string) bool { + _, exists := s.Get(sessionID) + return exists +} + +func (s *MemorySessionStore) Refresh(sessionID string, ttl time.Duration) bool { + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists || session.IsExpired() { + return false + } + session.Refresh(ttl) + return true +} + +func (s *MemorySessionStore) CleanupExpired() { + s.mu.Lock() + defer s.mu.Unlock() + + for id, session := range s.sessions { + if session.IsExpired() { + delete(s.sessions, id) + } + } +} + +func (s *MemorySessionStore) cleanupRoutine() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + s.CleanupExpired() + } +} + +const bcryptCost = 12 + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func VerifyPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/web/backend/auth/store.go b/web/backend/auth/store.go new file mode 100644 index 000000000..17f20c5cd --- /dev/null +++ b/web/backend/auth/store.go @@ -0,0 +1,124 @@ +package auth + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +const AuthConfigFileName = "auth-config.json" + +type AuthConfigStore struct { + mu sync.RWMutex + path string + config Config +} + +func NewAuthConfigStore(path string) *AuthConfigStore { + return &AuthConfigStore{ + path: path, + config: DefaultConfig(), + } +} + +func (s *AuthConfigStore) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + s.config = DefaultConfig() + return nil + } + return err + } + + if err := json.Unmarshal(data, &s.config); err != nil { + return err + } + + return nil +} + +func (s *AuthConfigStore) Save() error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.saveUnlocked() +} + +func (s *AuthConfigStore) saveUnlocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(s.config, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + return os.WriteFile(s.path, data, 0o600) +} + +func (s *AuthConfigStore) Get() Config { + s.mu.RLock() + defer s.mu.RUnlock() + return s.config +} + +func (s *AuthConfigStore) Set(config Config) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.config = config + return s.saveUnlocked() +} + +func (s *AuthConfigStore) SetCredentials(username, password string) error { + s.mu.Lock() + defer s.mu.Unlock() + + hash, err := HashPassword(password) + if err != nil { + return err + } + + s.config.Enabled = true + s.config.Username = username + s.config.PasswordHash = hash + + return s.saveUnlocked() +} + +func (s *AuthConfigStore) Enable() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.config.Enabled = true + return s.saveUnlocked() +} + +func (s *AuthConfigStore) Disable() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.config.Enabled = false + return s.saveUnlocked() +} + +func (s *AuthConfigStore) IsEnabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.config.IsConfigured() +} + +func PathForAuthConfig(appConfigPath string) string { + dir := filepath.Dir(appConfigPath) + if dir == "" || dir == "." { + dir = "." + } + return filepath.Join(dir, AuthConfigFileName) +} diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index 4dca45b0e..d47c24647 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -21,6 +21,7 @@ type Config struct { Port int `json:"port"` Public bool `json:"public"` AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + AuthEnabled bool `json:"auth_enabled,omitempty"` } // Default returns default launcher settings. diff --git a/web/backend/main.go b/web/backend/main.go index 2f181603e..c310530cb 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -26,6 +26,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/api" + "github.com/sipeed/picoclaw/web/backend/auth" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/utils" @@ -167,6 +168,24 @@ func main() { // Initialize Server components mux := http.NewServeMux() + // Initialize authentication system + authConfigPath := auth.PathForAuthConfig(absPath) + authConfigStore := auth.NewAuthConfigStore(authConfigPath) + if err := authConfigStore.Load(); err != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load auth config: %v", err)) + } + // Sync auth enabled state from launcher config + if launcherCfg.AuthEnabled && !authConfigStore.Get().Enabled { + cfg := authConfigStore.Get() + cfg.Enabled = true + if err := authConfigStore.Set(cfg); err != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: Failed to enable auth: %v", err)) + } + } + sessionStore := auth.NewMemorySessionStore() + authMiddleware := auth.NewAuthMiddleware(authConfigStore, sessionStore) + authHandler := api.NewAuthHandler(authConfigStore, sessionStore) + // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) if _, err = apiHandler.EnsurePicoChannel(""); err != nil { @@ -175,6 +194,9 @@ func main() { apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) apiHandler.RegisterRoutes(mux) + // Auth API Routes + authHandler.RegisterRoutes(mux) + // Frontend Embedded Assets registerEmbedRoutes(mux) @@ -183,10 +205,13 @@ func main() { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } + // Apply authentication middleware (before other middlewares) + authProtectedHandler := authMiddleware.RequireAuth(accessControlledMux) + // Apply middleware stack handler := middleware.Recoverer( middleware.Logger( - middleware.JSONContentType(accessControlledMux), + middleware.JSONContentType(authProtectedHandler), ), ) diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 543c8694d..3ef8d088b 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -9,6 +9,7 @@ export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] + auth_enabled: boolean } async function request(path: string, options?: RequestInit): Promise { diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index 4f0688008..6464e4098 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -2,6 +2,7 @@ import { IconBook, IconLanguage, IconLoader2, + IconLogout, IconMenu2, IconMoon, IconPlayerPlay, @@ -39,6 +40,7 @@ import { } from "@/components/ui/tooltip" import { useGateway } from "@/hooks/use-gateway.ts" import { useTheme } from "@/hooks/use-theme.ts" +import { useAuth } from "@/features/auth" export function AppHeader() { const { i18n, t } = useTranslation() @@ -52,6 +54,7 @@ export function AppHeader() { restart, stop, } = useGateway() + const { status: authStatus, logout } = useAuth() const isRunning = gwState === "running" const isStarting = gwState === "starting" @@ -245,6 +248,22 @@ export function AppHeader() { )} + + {/* User Menu (only show when auth is enabled) */} + {authStatus.enabled && authStatus.configured && ( + + + + + + + {t("auth.logout")} + + + + )} ) diff --git a/web/frontend/src/components/auth/index.ts b/web/frontend/src/components/auth/index.ts new file mode 100644 index 000000000..0a9422d50 --- /dev/null +++ b/web/frontend/src/components/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginForm } from "./login-form" +export { SetupForm } from "./setup-form" diff --git a/web/frontend/src/components/auth/login-form.tsx b/web/frontend/src/components/auth/login-form.tsx new file mode 100644 index 000000000..0161e2af7 --- /dev/null +++ b/web/frontend/src/components/auth/login-form.tsx @@ -0,0 +1,108 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { IconEye, IconEyeOff, IconLoader2 } from "@tabler/icons-react" + +import { useAuth } from "@/features/auth" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription } from "@/components/ui/alert" + +export function LoginForm() { + const { t } = useTranslation() + const { login } = useAuth() + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const [showPassword, setShowPassword] = useState(false) + + const isNotSecure = typeof window !== "undefined" && window.location.protocol === "http:" && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1" + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setLoading(true) + + try { + const result = await login(username, password) + if (!result.success) { + setError(result.message || t("auth.loginFailed", "Login failed")) + } + } catch { + setError(t("auth.networkError", "Network error")) + } finally { + setLoading(false) + } + } + + return ( +
+ {isNotSecure && ( + + + {t("auth.insecureConnection", "Warning: Connection is not secure. Passwords will be sent in plain text.")} + + + )} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + autoFocus + placeholder={t("auth.usernamePlaceholder", "Enter your username")} + disabled={loading} + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + placeholder={t("auth.passwordPlaceholder", "Enter your password")} + disabled={loading} + className="pr-10" + /> + +
+
+ + {error && ( + + {error} + + )} + + +
+ ) +} diff --git a/web/frontend/src/components/auth/setup-form.tsx b/web/frontend/src/components/auth/setup-form.tsx new file mode 100644 index 000000000..b17ef7d4d --- /dev/null +++ b/web/frontend/src/components/auth/setup-form.tsx @@ -0,0 +1,222 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { IconEye, IconEyeOff, IconLoader2, IconCheck, IconX } from "@tabler/icons-react" + +import { useAuth } from "@/features/auth" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription } from "@/components/ui/alert" + +export function SetupForm() { + const { t } = useTranslation() + const { setup } = useAuth() + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const isNotSecure = typeof window !== "undefined" && window.location.protocol === "http:" && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1" + + const passwordStrength = getPasswordStrength(password) + const passwordsMatch = password === confirmPassword && confirmPassword !== "" + const isValid = username.length >= 2 && password.length >= 6 && passwordsMatch + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (password !== confirmPassword) { + setError(t("auth.passwordMismatch", "Passwords do not match")) + return + } + + if (password.length < 6) { + setError(t("auth.passwordTooShort", "Password must be at least 6 characters")) + return + } + + if (username.length < 2) { + setError(t("auth.usernameTooShort", "Username must be at least 2 characters")) + return + } + + setLoading(true) + + try { + const result = await setup(username, password) + if (!result.success) { + setError(result.message || t("auth.setupFailed", "Setup failed")) + } + } catch { + setError(t("auth.networkError", "Network error")) + } finally { + setLoading(false) + } + } + + return ( +
+ {isNotSecure && ( + + + {t("auth.insecureConnection", "Warning: Connection is not secure. Passwords will be sent in plain text.")} + + + )} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + autoFocus + placeholder={t("auth.usernamePlaceholder", "Enter your username")} + disabled={loading} + /> + {username.length > 0 && ( +

= 2 ? "text-green-600" : "text-muted-foreground"}`}> + {username.length >= 2 ? ( + + ) : ( + + )} + {t("auth.usernameRequirement", "At least 2 characters")} +

+ )} +
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="new-password" + placeholder={t("auth.passwordPlaceholder", "Enter your password")} + disabled={loading} + className="pr-10" + /> + +
+ {password.length > 0 && ( +
+
+ {[1, 2, 3, 4].map((level) => ( +
= level + ? passwordStrength <= 1 + ? "bg-red-500" + : passwordStrength === 2 + ? "bg-yellow-500" + : passwordStrength === 3 + ? "bg-blue-500" + : "bg-green-500" + : "bg-gray-200" + }`} + /> + ))} +
+

+ {password.length < 6 ? ( + <> + + {t("auth.passwordRequirement", "At least 6 characters")} + + ) : ( + <> + + {t("auth.passwordValid", "Password is valid")} + + )} +

+
+ )} +
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + placeholder={t("auth.confirmPasswordPlaceholder", "Confirm your password")} + disabled={loading} + className="pr-10" + /> + +
+ {confirmPassword.length > 0 && ( +

+ {passwordsMatch ? ( + + ) : ( + + )} + {passwordsMatch + ? t("auth.passwordsMatch", "Passwords match") + : t("auth.passwordsDoNotMatch", "Passwords do not match")} +

+ )} +
+ + {error && ( + + {error} + + )} + + + + ) +} + +function getPasswordStrength(password: string): number { + if (!password) return 0 + let strength = 0 + if (password.length >= 6) strength++ + if (password.length >= 10) strength++ + if (/[A-Z]/.test(password) && /[a-z]/.test(password)) strength++ + if (/[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password)) strength++ + return strength +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 24a719d86..28c8958e6 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -84,6 +84,7 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), + authEnabled: launcherConfig.auth_enabled ?? false, } setLauncherForm(parsed) setLauncherBaseline(parsed) @@ -253,6 +254,7 @@ export function ConfigPage() { port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, + auth_enabled: launcherForm.authEnabled, }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), @@ -260,6 +262,7 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), + authEnabled: savedLauncherConfig.auth_enabled ?? false, } setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 5482b0a35..1fe199340 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -423,6 +423,15 @@ export function LauncherSection({ onCheckedChange={(checked) => onFieldChange("publicAccess", checked)} /> + onFieldChange("authEnabled", checked)} + /> + svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/web/frontend/src/features/auth/hooks.ts b/web/frontend/src/features/auth/hooks.ts new file mode 100644 index 000000000..ebde21953 --- /dev/null +++ b/web/frontend/src/features/auth/hooks.ts @@ -0,0 +1,101 @@ +import { useAtom } from "jotai" +import { useCallback, useEffect } from "react" + +import { authStatusAtom, isAuthenticatedAtom, needsLoginAtom, needsSetupAtom } from "./store" +import type { AuthStatus, ChangePasswordRequest, LoginRequest, SetupRequest } from "./types" + +const API_BASE = "/api/auth" + +async function fetchJSON(url: string, options?: RequestInit): Promise { + const response = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + return response.json() +} + +export function useAuth() { + const [status, setStatus] = useAtom(authStatusAtom) + const isAuthenticated = useAtom(isAuthenticatedAtom)[0] + const needsLogin = useAtom(needsLoginAtom)[0] + const needsSetup = useAtom(needsSetupAtom)[0] + + const checkStatus = useCallback(async () => { + try { + const result = await fetchJSON(`${API_BASE}/status`) + setStatus(result) + return result + } catch { + return status + } + }, [setStatus, status]) + + const login = useCallback( + async (username: string, password: string) => { + const result = await fetchJSON<{ success: boolean; message?: string }>(`${API_BASE}/login`, { + method: "POST", + body: JSON.stringify({ username, password } as LoginRequest), + }) + + if (result.success) { + await checkStatus() + } + + return result + }, + [checkStatus] + ) + + const logout = useCallback(async () => { + await fetchJSON<{ success: boolean }>(`${API_BASE}/logout`, { + method: "POST", + }) + await checkStatus() + }, [checkStatus]) + + const setup = useCallback( + async (username: string, password: string) => { + const result = await fetchJSON<{ success: boolean; message?: string }>(`${API_BASE}/setup`, { + method: "POST", + body: JSON.stringify({ username, password } as SetupRequest), + }) + + if (result.success) { + await checkStatus() + } + + return result + }, + [checkStatus] + ) + + const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { + const result = await fetchJSON<{ success: boolean; error?: string }>(`${API_BASE}/change-password`, { + method: "POST", + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + } as ChangePasswordRequest), + }) + return result + }, []) + + useEffect(() => { + checkStatus() + }, [checkStatus]) + + return { + status, + isAuthenticated, + needsLogin, + needsSetup, + checkStatus, + login, + logout, + setup, + changePassword, + } +} diff --git a/web/frontend/src/features/auth/index.ts b/web/frontend/src/features/auth/index.ts new file mode 100644 index 000000000..68e3894eb --- /dev/null +++ b/web/frontend/src/features/auth/index.ts @@ -0,0 +1,3 @@ +export { authStatusAtom, isAuthenticatedAtom, needsLoginAtom, needsSetupAtom } from "./store" +export type { AuthStatus, LoginRequest, LoginResponse, SetupRequest, SetupResponse, ChangePasswordRequest } from "./types" +export { useAuth } from "./hooks" diff --git a/web/frontend/src/features/auth/store.ts b/web/frontend/src/features/auth/store.ts new file mode 100644 index 000000000..a6123ba84 --- /dev/null +++ b/web/frontend/src/features/auth/store.ts @@ -0,0 +1,25 @@ +import { atom } from "jotai" + +import type { AuthStatus } from "./types" + +export const authStatusAtom = atom({ + enabled: false, + configured: false, + logged_in: false, +}) + +export const isAuthenticatedAtom = atom((get) => { + const status = get(authStatusAtom) + if (!status.enabled) return true + return status.logged_in +}) + +export const needsSetupAtom = atom((get) => { + const status = get(authStatusAtom) + return status.enabled && !status.configured +}) + +export const needsLoginAtom = atom((get) => { + const status = get(authStatusAtom) + return status.enabled && status.configured && !status.logged_in +}) diff --git a/web/frontend/src/features/auth/types.ts b/web/frontend/src/features/auth/types.ts new file mode 100644 index 000000000..1cac95073 --- /dev/null +++ b/web/frontend/src/features/auth/types.ts @@ -0,0 +1,30 @@ +export interface AuthStatus { + enabled: boolean + configured: boolean + logged_in: boolean +} + +export interface LoginRequest { + username: string + password: string +} + +export interface LoginResponse { + success: boolean + message?: string +} + +export interface SetupRequest { + username: string + password: string +} + +export interface SetupResponse { + success: boolean + message?: string +} + +export interface ChangePasswordRequest { + current_password: string + new_password: string +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 0b0afa39d..3e580a9cf 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -472,6 +472,8 @@ "allowed_cidrs": "Allowed Network CIDRs", "allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", + "auth_enabled": "Enable Authentication", + "auth_enabled_hint": "Require login to access the web console. After enabling, you will need to create an admin account on first access.", "sections": { "agent": "Agent", "runtime": "Runtime", @@ -499,5 +501,37 @@ "clear": "Clear logs", "empty": "Waiting for logs..." } + }, + "auth": { + "username": "Username", + "password": "Password", + "confirmPassword": "Confirm Password", + "login": "Login", + "logout": "Logout", + "loggingIn": "Logging in...", + "loginFailed": "Invalid username or password", + "loginDescription": "Enter your credentials to access the console", + "setup": "Create Account", + "settingUp": "Setting up...", + "setupFailed": "Setup failed", + "setupDescription": "Create an admin account to secure your instance", + "passwordMismatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters", + "usernameTooShort": "Username must be at least 2 characters", + "networkError": "Network error", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "passwordChanged": "Password changed successfully", + "passwordChangeFailed": "Failed to change password", + "usernamePlaceholder": "Enter your username", + "passwordPlaceholder": "Enter your password", + "confirmPasswordPlaceholder": "Confirm your password", + "usernameRequirement": "At least 2 characters", + "passwordRequirement": "At least 6 characters", + "passwordValid": "Password is valid", + "passwordsMatch": "Passwords match", + "passwordsDoNotMatch": "Passwords do not match", + "insecureConnection": "Warning: Connection is not secure. Passwords will be sent in plain text." } } diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e85e4dd44..348db5cf5 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -472,6 +472,8 @@ "allowed_cidrs": "允许访问网段", "allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", + "auth_enabled": "启用登录认证", + "auth_enabled_hint": "要求登录后才能访问 Web 控制台。启用后,首次访问时需要创建管理员账户。", "sections": { "agent": "智能体", "runtime": "运行时", @@ -499,5 +501,37 @@ "clear": "清空日志", "empty": "等待日志中..." } + }, + "auth": { + "username": "用户名", + "password": "密码", + "confirmPassword": "确认密码", + "login": "登录", + "logout": "退出登录", + "loggingIn": "登录中...", + "loginFailed": "用户名或密码错误", + "loginDescription": "请输入凭据以访问控制台", + "setup": "创建账户", + "settingUp": "设置中...", + "setupFailed": "设置失败", + "setupDescription": "创建管理员账户以保护您的实例", + "passwordMismatch": "两次密码不一致", + "passwordTooShort": "密码至少需要6个字符", + "usernameTooShort": "用户名至少需要2个字符", + "networkError": "网络错误", + "changePassword": "修改密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "passwordChanged": "密码修改成功", + "passwordChangeFailed": "密码修改失败", + "usernamePlaceholder": "请输入用户名", + "passwordPlaceholder": "请输入密码", + "confirmPasswordPlaceholder": "请再次输入密码", + "usernameRequirement": "至少2个字符", + "passwordRequirement": "至少6个字符", + "passwordValid": "密码有效", + "passwordsMatch": "密码匹配", + "passwordsDoNotMatch": "密码不匹配", + "insecureConnection": "警告:连接不安全。密码将以明文形式传输。" } } diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 60f19ab53..c82c33fe5 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ModelsRouteImport } from './routes/models' import { Route as LogsRouteImport } from './routes/logs' +import { Route as LoginRouteImport } from './routes/login' import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as ConfigRouteImport } from './routes/config' import { Route as AgentRouteImport } from './routes/agent' @@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({ path: '/logs', getParentRoute: () => rootRouteImport, } as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const CredentialsRoute = CredentialsRouteImport.update({ id: '/credentials', path: '/credentials', @@ -83,6 +89,7 @@ export interface FileRoutesByFullPath { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/login': typeof LoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -96,6 +103,7 @@ export interface FileRoutesByTo { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/login': typeof LoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -110,6 +118,7 @@ export interface FileRoutesById { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/login': typeof LoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -125,6 +134,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/login' | '/logs' | '/models' | '/agent/skills' @@ -138,6 +148,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/login' | '/logs' | '/models' | '/agent/skills' @@ -151,6 +162,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/login' | '/logs' | '/models' | '/agent/skills' @@ -165,6 +177,7 @@ export interface RootRouteChildren { AgentRoute: typeof AgentRouteWithChildren ConfigRoute: typeof ConfigRouteWithChildren CredentialsRoute: typeof CredentialsRoute + LoginRoute: typeof LoginRoute LogsRoute: typeof LogsRoute ModelsRoute: typeof ModelsRoute } @@ -185,6 +198,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LogsRouteImport parentRoute: typeof rootRouteImport } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/credentials': { id: '/credentials' path: '/credentials' @@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = { AgentRoute: AgentRouteWithChildren, ConfigRoute: ConfigRouteWithChildren, CredentialsRoute: CredentialsRoute, + LoginRoute: LoginRoute, LogsRoute: LogsRoute, ModelsRoute: ModelsRoute, } diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index 31fdb7804..bb538e8d6 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -1,15 +1,30 @@ -import { Outlet, createRootRoute } from "@tanstack/react-router" +import { Outlet, createRootRoute, useNavigate, useRouterState } from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { useEffect } from "react" import { AppLayout } from "@/components/app-layout" import { initializeChatStore } from "@/features/chat/controller" +import { useAuth } from "@/features/auth" const RootLayout = () => { + const navigate = useNavigate() + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { needsSetup, needsLogin, isAuthenticated } = useAuth() + useEffect(() => { initializeChatStore() }, []) + useEffect(() => { + if ((needsSetup || needsLogin) && pathname !== "/login") { + navigate({ to: "/login" }) + } + }, [needsSetup, needsLogin, pathname, navigate]) + + if (!isAuthenticated && pathname !== "/login") { + return null + } + return ( diff --git a/web/frontend/src/routes/login.tsx b/web/frontend/src/routes/login.tsx new file mode 100644 index 000000000..5f9c6e861 --- /dev/null +++ b/web/frontend/src/routes/login.tsx @@ -0,0 +1,49 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" +import { useEffect } from "react" + +import { useAuth } from "@/features/auth" +import { LoginForm, SetupForm } from "@/components/auth" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" + +function LoginPage() { + const { t } = useTranslation() + const { needsSetup, needsLogin, isAuthenticated } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + if (isAuthenticated) { + navigate({ to: "/" }) + } + }, [isAuthenticated, navigate]) + + useEffect(() => { + if (!needsSetup && !needsLogin) { + navigate({ to: "/" }) + } + }, [needsSetup, needsLogin, navigate]) + + if (isAuthenticated || (!needsSetup && !needsLogin)) { + return null + } + + return ( +
+ + + PicoClaw + + {needsSetup + ? t("auth.setupDescription", "Create an admin account to secure your instance") + : t("auth.loginDescription", "Enter your credentials to access the console")} + + + {needsSetup ? : } + +
+ ) +} + +export const Route = createFileRoute("/login")({ + component: LoginPage, +})