diff --git a/README.md b/README.md index c5ef1e0..7304917 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ See [docs/AGENT_BUILDER.md](docs/AGENT_BUILDER.md) for comprehensive documentati |------|-----------------|-------------| | File System | `fs.NewFsTool(root)` | File operations within root directory | | Git | `git.NewGitTool(repoRoot)` | Git operations (add, commit, push, etc.) | -| Web | `web.NewWebTool(workingDir)` | Web automation with headless browser | +| Web | `web.NewWebTool(workingDir)` | Web automation with browser sessions; headless by default | | Postgres | `postgres.NewPostgresTool(url, mode, tables, schemas)` | Database operations with whitelisting | | API | `api.NewApiTool(name, endpoints, authHook)` | HTTP API calls with auth support | | Vector | `vector.NewVectorTool(db, embeddings)` | Semantic search and indexing | diff --git a/VERSION b/VERSION index a918a2a..ee6cdce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/cmd/localforge/src/auth/config.go b/cmd/localforge/src/auth/config.go new file mode 100644 index 0000000..889d09e --- /dev/null +++ b/cmd/localforge/src/auth/config.go @@ -0,0 +1,71 @@ +package auth + +import ( + "net/http" + "os" + "strings" + "time" +) + +const ( + DefaultCookieName = "localforge_session" + DefaultSessionTTL = 24 * time.Hour +) + +type Config struct { + Enabled bool + Username string + PasswordHash string + CookieName string + SessionTTL time.Duration + SecureCookie bool +} + +func LoadConfigFromEnv() Config { + cfg := Config{ + Username: strings.TrimSpace(os.Getenv("AUTH_USERNAME")), + PasswordHash: strings.TrimSpace(os.Getenv("AUTH_PASSWORD_HASH")), + CookieName: DefaultCookieName, + SessionTTL: DefaultSessionTTL, + SecureCookie: parseBoolEnv("AUTH_COOKIE_SECURE"), + } + + if cookieName := strings.TrimSpace(os.Getenv("AUTH_COOKIE_NAME")); cookieName != "" { + cfg.CookieName = cookieName + } + + if ttlRaw := strings.TrimSpace(os.Getenv("AUTH_SESSION_TTL")); ttlRaw != "" { + if ttl, err := time.ParseDuration(ttlRaw); err == nil && ttl > 0 { + cfg.SessionTTL = ttl + } + } + + cfg.Enabled = cfg.Username != "" && cfg.PasswordHash != "" + return cfg +} + +func parseBoolEnv(key string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func (c Config) CookieMaxAgeSeconds() int { + return int(c.SessionTTL.Seconds()) +} + +func (c Config) ShouldUseSecureCookie(r *http.Request) bool { + if c.SecureCookie { + return true + } + if r == nil { + return false + } + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} diff --git a/cmd/localforge/src/auth/middleware.go b/cmd/localforge/src/auth/middleware.go new file mode 100644 index 0000000..f4de979 --- /dev/null +++ b/cmd/localforge/src/auth/middleware.go @@ -0,0 +1,77 @@ +package auth + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +const sessionContextKey = "localforge.auth.session" + +type UnauthorizedMode int + +const ( + UnauthorizedJSON UnauthorizedMode = iota + UnauthorizedRedirect +) + +func Middleware(cfg Config, store *SessionStore, mode UnauthorizedMode) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.Enabled { + c.Next() + return + } + + token, err := c.Cookie(cfg.CookieName) + if err == nil && token != "" { + session, ok := store.Get(token) + if ok { + c.Set(sessionContextKey, session) + c.Next() + return + } + + clearSessionCookie(c, cfg) + } + + switch mode { + case UnauthorizedRedirect: + next := SafeNextPath(c.Request.URL.RequestURI()) + c.Redirect(http.StatusFound, "/login?next="+url.QueryEscape(next)) + c.Abort() + default: + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + } + } +} + +func CurrentSession(c *gin.Context) (Session, bool) { + value, ok := c.Get(sessionContextKey) + if !ok { + return Session{}, false + } + + session, ok := value.(Session) + return session, ok +} + +func clearSessionCookie(c *gin.Context, cfg Config) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: cfg.CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: cfg.ShouldUseSecureCookie(c.Request), + }) +} + +func SafeNextPath(next string) string { + if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { + return "/" + } + return next +} diff --git a/cmd/localforge/src/auth/password.go b/cmd/localforge/src/auth/password.go new file mode 100644 index 0000000..97622ae --- /dev/null +++ b/cmd/localforge/src/auth/password.go @@ -0,0 +1,10 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +func VerifyPassword(password, hash string) bool { + if password == "" || hash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/cmd/localforge/src/auth/session_store.go b/cmd/localforge/src/auth/session_store.go new file mode 100644 index 0000000..013416e --- /dev/null +++ b/cmd/localforge/src/auth/session_store.go @@ -0,0 +1,106 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "sync" + "time" +) + +type Session struct { + Username string + TokenHash string + ExpiresAt time.Time +} + +type SessionStore struct { + mu sync.RWMutex + sessions map[string]Session +} + +func NewSessionStore() *SessionStore { + return &SessionStore{ + sessions: make(map[string]Session), + } +} + +func (s *SessionStore) Create(username string, ttl time.Duration) (string, Session, error) { + token, err := randomToken() + if err != nil { + return "", Session{}, err + } + + session := Session{ + Username: username, + TokenHash: hashToken(token), + ExpiresAt: time.Now().UTC().Add(ttl), + } + + s.mu.Lock() + s.sessions[session.TokenHash] = session + s.mu.Unlock() + + return token, session, nil +} + +func (s *SessionStore) Get(token string) (Session, bool) { + tokenHash := hashToken(token) + + s.mu.RLock() + session, ok := s.sessions[tokenHash] + s.mu.RUnlock() + if !ok { + return Session{}, false + } + if time.Now().UTC().After(session.ExpiresAt) { + s.Delete(token) + return Session{}, false + } + return session, true +} + +func (s *SessionStore) Delete(token string) { + s.mu.Lock() + delete(s.sessions, hashToken(token)) + s.mu.Unlock() +} + +func (s *SessionStore) CleanupExpired() { + now := time.Now().UTC() + + s.mu.Lock() + for key, session := range s.sessions { + if now.After(session.ExpiresAt) { + delete(s.sessions, key) + } + } + s.mu.Unlock() +} + +func (s *SessionStore) StartCleanup(interval time.Duration) { + if interval <= 0 { + interval = time.Hour + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + s.CleanupExpired() + } + }() +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func randomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/cmd/localforge/src/handlers_auth.go b/cmd/localforge/src/handlers_auth.go new file mode 100644 index 0000000..0c31b6e --- /dev/null +++ b/cmd/localforge/src/handlers_auth.go @@ -0,0 +1,115 @@ +package main + +import ( + "crypto/subtle" + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" + localauth "github.com/thinktwiceco/agent-forge/cmd/localforge/src/auth" +) + +func (s *Server) handleLoginPage(c *gin.Context) { + if !s.authConfig.Enabled { + c.Redirect(http.StatusFound, "/") + return + } + + if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" { + if _, ok := s.sessionStore.Get(token); ok { + c.Redirect(http.StatusFound, requestedNextPath(c)) + return + } + } + + data, err := fs.ReadFile(s.staticFS, "login.html") + if err != nil { + c.String(http.StatusNotFound, "login.html not found") + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", data) +} + +func (s *Server) handleAuthLogin(c *gin.Context) { + if !s.authConfig.Enabled { + c.JSON(http.StatusNotFound, gin.H{"error": "authentication is disabled"}) + return + } + + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if subtle.ConstantTimeCompare([]byte(req.Username), []byte(s.authConfig.Username)) != 1 || + !localauth.VerifyPassword(req.Password, s.authConfig.PasswordHash) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + + token, session, err := s.sessionStore.Create(s.authConfig.Username, s.authConfig.SessionTTL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) + return + } + + http.SetCookie(c.Writer, &http.Cookie{ + Name: s.authConfig.CookieName, + Value: token, + Path: "/", + MaxAge: s.authConfig.CookieMaxAgeSeconds(), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.authConfig.ShouldUseSecureCookie(c.Request), + }) + + c.JSON(http.StatusOK, AuthStatusResponse{ + Enabled: true, + Authenticated: true, + Username: session.Username, + Next: requestedNextPath(c), + }) +} + +func (s *Server) handleAuthLogout(c *gin.Context) { + if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" { + s.sessionStore.Delete(token) + } + + http.SetCookie(c.Writer, &http.Cookie{ + Name: s.authConfig.CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.authConfig.ShouldUseSecureCookie(c.Request), + }) + + c.Status(http.StatusNoContent) +} + +func (s *Server) handleAuthMe(c *gin.Context) { + resp := AuthStatusResponse{ + Enabled: s.authConfig.Enabled, + Next: requestedNextPath(c), + } + if !s.authConfig.Enabled { + c.JSON(http.StatusOK, resp) + return + } + + if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" { + if session, ok := s.sessionStore.Get(token); ok { + resp.Authenticated = true + resp.Username = session.Username + } + } + + c.JSON(http.StatusOK, resp) +} + +func requestedNextPath(c *gin.Context) string { + return localauth.SafeNextPath(c.Query("next")) +} diff --git a/cmd/localforge/src/server.go b/cmd/localforge/src/server.go index 08c5783..9d88956 100644 --- a/cmd/localforge/src/server.go +++ b/cmd/localforge/src/server.go @@ -9,9 +9,12 @@ import ( "net/http" "os" "path/filepath" + "strings" + "time" "github.com/gin-gonic/gin" _ "github.com/mattn/go-sqlite3" + localauth "github.com/thinktwiceco/agent-forge/cmd/localforge/src/auth" "github.com/thinktwiceco/agent-forge/cmd/localforge/src/providers" ) @@ -30,11 +33,15 @@ type Server struct { knowledgeDB *sql.DB // opened once at startup; nil if DB not yet available devMode bool appDir string + staticFS fs.FS + authConfig localauth.Config + sessionStore *localauth.SessionStore } func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoManager, devMode bool, appDir string) *Server { + authConfig := localauth.LoadConfigFromEnv() engine := gin.New() - engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware()) + engine.Use(gin.Logger(), gin.Recovery(), corsMiddleware(authConfig.Enabled)) // Initialize provider registry providerRegistry := NewProviderRegistry() @@ -59,6 +66,17 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa providerRegistry: providerRegistry, devMode: devMode, appDir: appDir, + authConfig: authConfig, + sessionStore: localauth.NewSessionStore(), + } + + staticFS, err := server.staticFileSystem() + if err != nil { + panic(err) + } + server.staticFS = staticFS + if authConfig.Enabled { + server.sessionStore.StartCleanup(time.Hour) } // Open the knowledge DB once so all handlers share a connection pool. @@ -101,14 +119,9 @@ func (s *Server) staticFileSystem() (fs.FS, error) { } func (s *Server) setupRoutes() { - staticFS, err := s.staticFileSystem() - if err != nil { - panic(err) - } - // Serve static files. In dev mode wrap the handler to disable browser caching // so file edits are visible immediately without a hard refresh. - staticHandler := http.StripPrefix("/static", http.FileServer(http.FS(staticFS))) + staticHandler := http.StripPrefix("/static", http.FileServer(http.FS(s.staticFS))) if s.devMode { original := staticHandler staticHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -122,35 +135,23 @@ func (s *Server) setupRoutes() { s.engine.HEAD("/static/*filepath", func(c *gin.Context) { staticHandler.ServeHTTP(c.Writer, c.Request) }) + s.engine.GET("/login", s.handleLoginPage) - s.engine.GET("/", func(c *gin.Context) { - data, err := fs.ReadFile(staticFS, "index.html") - if err != nil { - c.String(http.StatusNotFound, "index.html not found") - return - } - c.Data(http.StatusOK, "text/html; charset=utf-8", data) - }) - - s.engine.GET("/knowledge", func(c *gin.Context) { - data, err := fs.ReadFile(staticFS, "knowledge.html") - if err != nil { - c.String(http.StatusNotFound, "knowledge.html not found") - return - } - c.Data(http.StatusOK, "text/html; charset=utf-8", data) - }) + pageRoutes := s.engine.Group("") + pageRoutes.Use(localauth.Middleware(s.authConfig, s.sessionStore, localauth.UnauthorizedRedirect)) + pageRoutes.GET("/", s.serveStaticPage("index.html")) + pageRoutes.GET("/knowledge", s.serveStaticPage("knowledge.html")) + pageRoutes.GET("/settings", s.serveStaticPage("settings.html")) - s.engine.GET("/settings", func(c *gin.Context) { - data, err := fs.ReadFile(staticFS, "settings.html") - if err != nil { - c.String(http.StatusNotFound, "settings.html not found") - return - } - c.Data(http.StatusOK, "text/html; charset=utf-8", data) - }) + publicAPI := s.engine.Group("/api") + publicAPI.GET("/auth/me", s.handleAuthMe) + publicAPI.POST("/auth/login", s.handleAuthLogin) + publicAPI.POST("/auth/logout", s.handleAuthLogout) + publicAPI.POST("/webhooks/:provider", s.handleWebhook) + publicAPI.POST("/webhooks/:provider/sync", s.handleWebhookSync) api := s.engine.Group("/api") + api.Use(localauth.Middleware(s.authConfig, s.sessionStore, localauth.UnauthorizedJSON)) api.POST("/chat", s.handleChat) api.POST("/chat/stop", s.handleStopChat) api.POST("/upload", s.handleUpload) @@ -177,10 +178,6 @@ func (s *Server) setupRoutes() { api.GET("/knowledge/graph", s.handleGetKnowledgeGraph) api.GET("/knowledge/stats", s.handleGetKnowledgeStats) api.GET("/knowledge/node/:id", s.handleGetKnowledgeNode) - - // Webhook endpoints - api.POST("/webhooks/:provider", s.handleWebhook) - api.POST("/webhooks/:provider/sync", s.handleWebhookSync) } func (s *Server) Run(port string) error { @@ -204,11 +201,29 @@ func (s *Server) Shutdown(ctx context.Context) error { return s.httpSrv.Shutdown(ctx) } -func corsMiddleware() gin.HandlerFunc { +func (s *Server) serveStaticPage(name string) gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + data, err := fs.ReadFile(s.staticFS, name) + if err != nil { + c.String(http.StatusNotFound, "%s not found", name) + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", data) + } +} + +func corsMiddleware(authEnabled bool) gin.HandlerFunc { + return func(c *gin.Context) { + if !authEnabled { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + } else if origin := c.Request.Header.Get("Origin"); origin != "" && sameOrigin(origin, c.Request.Host) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + } if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return @@ -216,3 +231,11 @@ func corsMiddleware() gin.HandlerFunc { c.Next() } } + +func sameOrigin(origin string, host string) bool { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + return false + } + return strings.HasSuffix(trimmed, "://"+host) +} diff --git a/cmd/localforge/src/static/index.html b/cmd/localforge/src/static/index.html index 61f0a71..7286652 100644 --- a/cmd/localforge/src/static/index.html +++ b/cmd/localforge/src/static/index.html @@ -16,7 +16,10 @@ ThinkTwice - +
+ + +
- ← Back to Chat +
+ ← Back to Chat + +
diff --git a/cmd/localforge/src/static/login.html b/cmd/localforge/src/static/login.html new file mode 100644 index 0000000..c46c557 --- /dev/null +++ b/cmd/localforge/src/static/login.html @@ -0,0 +1,109 @@ + + + + + + + Login + + + + + +
+ +
+ + + + + diff --git a/cmd/localforge/src/static/settings.html b/cmd/localforge/src/static/settings.html index 9a11b8d..671774a 100644 --- a/cmd/localforge/src/static/settings.html +++ b/cmd/localforge/src/static/settings.html @@ -25,6 +25,7 @@ .settings-nav-header { display: flex; align-items: center; + justify-content: space-between; gap: 10px; padding-bottom: 12px; margin-bottom: 16px; @@ -41,6 +42,13 @@ color: var(--text-primary); } + .settings-nav-header-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + .settings-nav-title { font-size: 15px; font-weight: 600; @@ -350,8 +358,11 @@