summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-20 11:34:33 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-20 11:34:33 -1000
commit08bbcf18b1207153983261652b4a43a9b36f386c (patch)
treee6665608c7c8a87d6c789cf8b4c56d466df6bb8b
parent07ba815e8517ee2d3a5fa531361bbd09bdfcbaa7 (diff)
Add session-based authentication
Implement secure authentication using scs session manager with SQLite backing store and bcrypt password hashing. - Add users and sessions tables (migration 004) - Create internal/auth package with Service, Middleware, and Handlers - Protect all routes except /login, /logout, /static/* - Add login page template and logout button to dashboard - Default credentials: admin/changeme (configurable via env vars) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--AUDITOR_ROLE.md46
-rw-r--r--SESSION_STATE.md33
-rw-r--r--cmd/dashboard/main.go106
-rw-r--r--go.mod8
-rw-r--r--go.sum7
-rw-r--r--internal/auth/auth.go142
-rw-r--r--internal/auth/handlers.go111
-rw-r--r--internal/auth/middleware.go50
-rw-r--r--internal/store/sqlite.go5
-rw-r--r--migrations/004_add_auth.sql20
-rw-r--r--web/templates/index.html6
-rw-r--r--web/templates/login.html57
12 files changed, 546 insertions, 45 deletions
diff --git a/AUDITOR_ROLE.md b/AUDITOR_ROLE.md
new file mode 100644
index 0000000..1210a9e
--- /dev/null
+++ b/AUDITOR_ROLE.md
@@ -0,0 +1,46 @@
+# Senior Go Architect & Security Lead Persona
+
+**Role:** You are acting as a **Senior Go Architect and Security Lead**.
+**Project Context:** I am building a unified personal dashboard using Go 1.21, SQLite (caching layer), chi router, and HTMX.
+
+**Shared Standards (CLAUDE.md):**
+* **Efficiency:** Prioritize surgical edits over full-file rewrites.
+* **Tools:** Use terminal commands (`go test`, `go build`, `grep`) to verify state before planning.
+* **Architecture:** Handler -> Store (SQLite) -> API Clients.
+* **State:** Maintain `SESSION_STATE.md` as the source of truth for handoffs.
+
+**Gemini Architect Persona:**
+* You are the **Lead Architect**.
+* **Constraint:** You **DO NOT** write or edit Project Source Code (e.g., `.go`, `.html`, `.js`).
+* **Responsibility:** You **DO** write and update documentation and instruction files (e.g., `SESSION_STATE.md`, `instructions.md`, `issues/*.md`). Your job is to prepare surgical plans for the implementation agent (Claude Code) to execute.
+* **Constraint:** If the user rejects a proposed change, do NOT try again - IMMEDIATELY stop and ask for clarification from the user.
+* **Known issue:** You cannot access the project's `cmd/dashboard/main.go` entrypoint for an unknown reason. However, the implementation agent CAN. You may give it generic directions (like "remove XXXX dependency from main.go") instead of precise instructions, for this file ONLY.
+
+**Workflow Instructions:**
+
+1. **Analyze:**
+ * When pointed to a task or file, use tools (`read_file`, `grep`, `ls`) to understand the current state.
+ * Identify specific lines needing fixes based on `SECURITY_CHECKLIST.md` or the current feature requirement.
+
+2. **Bug Handling Protocol:**
+ * **Create Issue:** When a bug is identified, create a file in `issues/` (e.g., `issues/bug_00X_description.md`).
+ * **Document:** Describe the bug, root cause, and a plan to fix it.
+ * **Reproduction:** ALWAYS include instructions for a reproduction test case (preferably an automated `_test.go` file) in the issue document.
+ * **State:** Update `SESSION_STATE.md` to track the issue.
+
+3. **Document:**
+ * Update `SESSION_STATE.md` with the "Next Steps" and current context.
+
+4. **Draft Instructions:**
+ * **DO NOT** output the prompt in the chat.
+ * **WRITE** the "Surgical Prompt" to a file named `instructions.md`.
+ * The prompt in `instructions.md` must be concise, include specific file paths, and define the exact logic changes needed for the implementation agent.
+ * **TDD:** For bugs, instructions must follow a Test-Driven Development approach: Write Test -> Verify Fail -> Fix Code -> Verify Pass.
+
+**Tool Usage Protocol:**
+* **Execution:** When you state you are creating or updating a file (e.g., `instructions.md`, `SESSION_STATE.md`), you **MUST** execute the `write_file` tool. Do not just describe the content; write it to the disk.
+
+**Self-Improvement:**
+* **Meta-Review:** Periodically (e.g., after completing a major phase or encountering friction), suggest refinements to this Role Definition (`ARCHITECT_ROLE.md`) to better align with the user's needs and project workflow.
+
+**Why we do this:** We are managing token usage and rate limits. By using you to plan and the implementation agent to execute, we ensure work is structured, documented, and smooth.
diff --git a/SESSION_STATE.md b/SESSION_STATE.md
index cff3103..8a2920b 100644
--- a/SESSION_STATE.md
+++ b/SESSION_STATE.md
@@ -1,19 +1,22 @@
# Session State
-**Current Phase:** Phase 3: Write Operations & UI Integration
-**Current Step:** Step 6: Trello Tasks Heuristic & Tasks Tab
+## Active Task
+None - Authentication implementation complete.
-**Recent Completed Steps:**
-* Phase 3 Step 1: Trello Write Ops (Backend)
-* Phase 3 Step 2: Trello Lists Support
-* Phase 3 Step 3: Trello UI (Boards & Add Card)
-* Phase 3 Step 4: Todoist Write Ops
-* Phase 3 Step 5: Fix Tasks Tab (Identified as needing Heuristic)
+## Recent Changes
+* **Task 002:** Implemented session-based authentication.
+ * Added `scs` session manager with SQLite store.
+ * Added `bcrypt` password hashing via `golang.org/x/crypto`.
+ * Created `internal/auth` package with `Service`, `Middleware`, and `Handlers`.
+ * Created migration `004_add_auth.sql` (users + sessions tables).
+ * Created `login.html` template.
+ * Added logout button to dashboard header.
+ * Protected all routes except `/login`, `/logout`, and `/static/*`.
+ * Default user: `admin` / `changeme` (configurable via `DEFAULT_USER`/`DEFAULT_PASS` env vars).
+* **Task 001:** Removed Obsidian functionality.
-**Current Focus:**
-Implementing heuristics to extract actionable tasks from Trello boards and displaying them in a unified list on the Tasks tab.
-
-**Next Steps:**
-1. Implement Heuristics in Handlers.
-2. Create Trello Tasks Partial.
-3. Update Tasks Tab Template.
+## Next Steps
+1. **Commit** the authentication changes.
+2. **Test manually** by running the server and logging in.
+3. **Optional:** Add auth tests to `internal/auth`.
+4. **Optional:** Add password change functionality.
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 6e013e8..14664fc 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -2,18 +2,23 @@ package main
import (
"context"
+ "html/template"
"log"
"net/http"
"os"
"os/signal"
+ "path/filepath"
"syscall"
"time"
+ "github.com/alexedwards/scs/sqlite3store"
+ "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"task-dashboard/internal/api"
+ "task-dashboard/internal/auth"
"task-dashboard/internal/config"
"task-dashboard/internal/handlers"
"task-dashboard/internal/store"
@@ -36,6 +41,38 @@ func main() {
}
defer db.Close()
+ // Initialize session manager
+ sessionManager := scs.New()
+ sessionManager.Store = sqlite3store.New(db.DB())
+ sessionManager.Lifetime = 24 * time.Hour
+ sessionManager.Cookie.Secure = false // Set to true in production with HTTPS
+ sessionManager.Cookie.SameSite = http.SameSiteLaxMode
+
+ // Initialize auth service
+ authService := auth.NewService(db.DB())
+
+ // Ensure default admin user exists (use env vars for credentials)
+ defaultUser := os.Getenv("DEFAULT_USER")
+ defaultPass := os.Getenv("DEFAULT_PASS")
+ if defaultUser == "" {
+ defaultUser = "admin"
+ }
+ if defaultPass == "" {
+ defaultPass = "changeme"
+ }
+ if err := authService.EnsureDefaultUser(defaultUser, defaultPass); err != nil {
+ log.Printf("Warning: failed to ensure default user: %v", err)
+ }
+
+ // Parse templates for auth handlers
+ authTemplates, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html"))
+ if err != nil {
+ log.Printf("Warning: failed to parse auth templates: %v", err)
+ }
+
+ // Initialize auth handlers
+ authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates)
+
// Initialize API clients
todoistClient := api.NewTodoistClient(cfg.TodoistAPIKey)
trelloClient := api.NewTrelloClient(cfg.TrelloAPIKey, cfg.TrelloToken)
@@ -52,43 +89,54 @@ func main() {
// Set up router
r := chi.NewRouter()
- // Middleware
+ // Global middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
+ r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
- // Routes
- r.Get("/", h.HandleDashboard)
- r.Post("/api/refresh", h.HandleRefresh)
- r.Get("/api/tasks", h.HandleGetTasks)
- r.Get("/api/meals", h.HandleGetMeals)
- r.Get("/api/boards", h.HandleGetBoards)
-
- // Tab routes for HTMX (using new TabsHandler)
- r.Get("/tabs/tasks", tabsHandler.HandleTasks)
- r.Get("/tabs/planning", tabsHandler.HandlePlanning)
- r.Get("/tabs/meals", tabsHandler.HandleMeals)
- r.Post("/tabs/refresh", h.HandleRefreshTab)
-
- // Trello card operations
- r.Post("/cards", h.HandleCreateCard)
- r.Post("/cards/complete", h.HandleCompleteCard)
-
- // Todoist task operations
- r.Post("/tasks", h.HandleCreateTask)
- r.Post("/tasks/complete", h.HandleCompleteTask)
+ // Public routes (no auth required)
+ r.Get("/login", authHandlers.HandleLoginPage)
+ r.Post("/login", authHandlers.HandleLogin)
+ r.Post("/logout", authHandlers.HandleLogout)
- // Unified task completion (for Tasks tab Atoms)
- r.Post("/complete-atom", h.HandleCompleteAtom)
-
- // Unified Quick Add (for Tasks tab)
- r.Post("/unified-add", h.HandleUnifiedAdd)
- r.Get("/partials/lists", h.HandleGetListsOptions)
-
- // Serve static files
+ // Serve static files (public)
fileServer := http.FileServer(http.Dir("web/static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
+ // Protected routes (auth required)
+ r.Group(func(r chi.Router) {
+ r.Use(authHandlers.Middleware().RequireAuth)
+
+ // Dashboard
+ r.Get("/", h.HandleDashboard)
+ r.Post("/api/refresh", h.HandleRefresh)
+ r.Get("/api/tasks", h.HandleGetTasks)
+ r.Get("/api/meals", h.HandleGetMeals)
+ r.Get("/api/boards", h.HandleGetBoards)
+
+ // Tab routes for HTMX
+ r.Get("/tabs/tasks", tabsHandler.HandleTasks)
+ r.Get("/tabs/planning", tabsHandler.HandlePlanning)
+ r.Get("/tabs/meals", tabsHandler.HandleMeals)
+ r.Post("/tabs/refresh", h.HandleRefreshTab)
+
+ // Trello card operations
+ r.Post("/cards", h.HandleCreateCard)
+ r.Post("/cards/complete", h.HandleCompleteCard)
+
+ // Todoist task operations
+ r.Post("/tasks", h.HandleCreateTask)
+ r.Post("/tasks/complete", h.HandleCompleteTask)
+
+ // Unified task completion (for Tasks tab Atoms)
+ r.Post("/complete-atom", h.HandleCompleteAtom)
+
+ // Unified Quick Add (for Tasks tab)
+ r.Post("/unified-add", h.HandleUnifiedAdd)
+ r.Get("/partials/lists", h.HandleGetListsOptions)
+ })
+
// Start server
addr := ":" + cfg.Port
srv := &http.Server{
diff --git a/go.mod b/go.mod
index c3cda88..055dc8f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module task-dashboard
-go 1.21
+go 1.24.0
require (
github.com/go-chi/chi/v5 v5.2.3
@@ -8,3 +8,9 @@ require (
)
require github.com/joho/godotenv v1.5.1
+
+require (
+ github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
+ github.com/alexedwards/scs/v2 v2.9.0 // indirect
+ golang.org/x/crypto v0.47.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 8796d77..d1981e3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,13 @@
+github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
+github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
+github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
+github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..a602dad
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,142 @@
+package auth
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+var (
+ ErrInvalidCredentials = errors.New("invalid username or password")
+ ErrUserNotFound = errors.New("user not found")
+ ErrUserExists = errors.New("username already exists")
+)
+
+// User represents an authenticated user
+type User struct {
+ ID int64
+ Username string
+ PasswordHash string
+ CreatedAt time.Time
+}
+
+// Service handles authentication operations
+type Service struct {
+ db *sql.DB
+}
+
+// NewService creates a new auth service
+func NewService(db *sql.DB) *Service {
+ return &Service{db: db}
+}
+
+// Authenticate verifies username and password, returns user ID if valid
+func (s *Service) Authenticate(username, password string) (*User, error) {
+ user, err := s.GetUserByUsername(username)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ return nil, ErrInvalidCredentials
+ }
+ return nil, err
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
+ return nil, ErrInvalidCredentials
+ }
+
+ return user, nil
+}
+
+// GetUserByUsername retrieves a user by username
+func (s *Service) GetUserByUsername(username string) (*User, error) {
+ var user User
+ err := s.db.QueryRow(
+ `SELECT id, username, password_hash, created_at FROM users WHERE username = ?`,
+ username,
+ ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt)
+
+ if err == sql.ErrNoRows {
+ return nil, ErrUserNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+// GetUserByID retrieves a user by ID
+func (s *Service) GetUserByID(id int64) (*User, error) {
+ var user User
+ err := s.db.QueryRow(
+ `SELECT id, username, password_hash, created_at FROM users WHERE id = ?`,
+ id,
+ ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt)
+
+ if err == sql.ErrNoRows {
+ return nil, ErrUserNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+// CreateUser creates a new user with the given username and password
+func (s *Service) CreateUser(username, password string) (*User, error) {
+ // Check if user exists
+ _, err := s.GetUserByUsername(username)
+ if err == nil {
+ return nil, ErrUserExists
+ }
+ if !errors.Is(err, ErrUserNotFound) {
+ return nil, err
+ }
+
+ // Hash password
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, err
+ }
+
+ // Insert user
+ result, err := s.db.Exec(
+ `INSERT INTO users (username, password_hash) VALUES (?, ?)`,
+ username, string(hash),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+
+ return s.GetUserByID(id)
+}
+
+// UserCount returns the number of users in the database
+func (s *Service) UserCount() (int, error) {
+ var count int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
+ return count, err
+}
+
+// EnsureDefaultUser creates a default admin user if no users exist
+func (s *Service) EnsureDefaultUser(username, password string) error {
+ count, err := s.UserCount()
+ if err != nil {
+ return err
+ }
+
+ if count == 0 {
+ _, err = s.CreateUser(username, password)
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go
new file mode 100644
index 0000000..17bcabd
--- /dev/null
+++ b/internal/auth/handlers.go
@@ -0,0 +1,111 @@
+package auth
+
+import (
+ "html/template"
+ "log"
+ "net/http"
+
+ "github.com/alexedwards/scs/v2"
+)
+
+// Handlers provides HTTP handlers for authentication
+type Handlers struct {
+ service *Service
+ sessions *scs.SessionManager
+ middleware *Middleware
+ templates *template.Template
+}
+
+// NewHandlers creates new auth handlers
+func NewHandlers(service *Service, sessions *scs.SessionManager, templates *template.Template) *Handlers {
+ return &Handlers{
+ service: service,
+ sessions: sessions,
+ middleware: NewMiddleware(sessions),
+ templates: templates,
+ }
+}
+
+// Middleware returns the auth middleware for use in routes
+func (h *Handlers) Middleware() *Middleware {
+ return h.middleware
+}
+
+// HandleLoginPage renders the login form
+func (h *Handlers) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
+ // If already logged in, redirect to dashboard
+ if h.middleware.IsAuthenticated(r) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+
+ data := struct {
+ Error string
+ }{
+ Error: "",
+ }
+
+ if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering login template: %v", err)
+ }
+}
+
+// HandleLogin processes login form submission
+func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Failed to parse form", http.StatusBadRequest)
+ return
+ }
+
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ if username == "" || password == "" {
+ h.renderLoginError(w, "Username and password are required")
+ return
+ }
+
+ user, err := h.service.Authenticate(username, password)
+ if err != nil {
+ log.Printf("Login failed for user %s: %v", username, err)
+ h.renderLoginError(w, "Invalid username or password")
+ return
+ }
+
+ // Regenerate session token to prevent session fixation
+ if err := h.sessions.RenewToken(r.Context()); err != nil {
+ http.Error(w, "Failed to create session", http.StatusInternalServerError)
+ log.Printf("Failed to renew session token: %v", err)
+ return
+ }
+
+ // Set user ID in session
+ h.middleware.SetUserID(r, user.ID)
+
+ log.Printf("User %s logged in successfully", username)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// HandleLogout processes logout
+func (h *Handlers) HandleLogout(w http.ResponseWriter, r *http.Request) {
+ if err := h.middleware.ClearSession(r); err != nil {
+ log.Printf("Error clearing session: %v", err)
+ }
+
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
+
+func (h *Handlers) renderLoginError(w http.ResponseWriter, errorMsg string) {
+ data := struct {
+ Error string
+ }{
+ Error: errorMsg,
+ }
+
+ w.WriteHeader(http.StatusUnauthorized)
+ if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering login template: %v", err)
+ }
+}
diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go
new file mode 100644
index 0000000..7710328
--- /dev/null
+++ b/internal/auth/middleware.go
@@ -0,0 +1,50 @@
+package auth
+
+import (
+ "net/http"
+
+ "github.com/alexedwards/scs/v2"
+)
+
+const SessionKeyUserID = "user_id"
+
+// Middleware provides authentication middleware
+type Middleware struct {
+ sessions *scs.SessionManager
+}
+
+// NewMiddleware creates a new auth middleware
+func NewMiddleware(sessions *scs.SessionManager) *Middleware {
+ return &Middleware{sessions: sessions}
+}
+
+// RequireAuth redirects to login if not authenticated
+func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !m.IsAuthenticated(r) {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// IsAuthenticated checks if the current request has a valid session
+func (m *Middleware) IsAuthenticated(r *http.Request) bool {
+ return m.sessions.Exists(r.Context(), SessionKeyUserID)
+}
+
+// GetUserID returns the authenticated user's ID from the session
+func (m *Middleware) GetUserID(r *http.Request) int64 {
+ return m.sessions.GetInt64(r.Context(), SessionKeyUserID)
+}
+
+// SetUserID sets the user ID in the session (called after successful login)
+func (m *Middleware) SetUserID(r *http.Request, userID int64) {
+ m.sessions.Put(r.Context(), SessionKeyUserID, userID)
+}
+
+// ClearSession removes the user ID from the session (called on logout)
+func (m *Middleware) ClearSession(r *http.Request) error {
+ return m.sessions.Destroy(r.Context())
+}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index dac3321..7961f35 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -59,6 +59,11 @@ func (s *Store) Close() error {
return s.db.Close()
}
+// DB returns the underlying database connection
+func (s *Store) DB() *sql.DB {
+ return s.db
+}
+
// runMigrations executes all migration files in order
func (s *Store) runMigrations() error {
// Get migration files
diff --git a/migrations/004_add_auth.sql b/migrations/004_add_auth.sql
new file mode 100644
index 0000000..065b8e3
--- /dev/null
+++ b/migrations/004_add_auth.sql
@@ -0,0 +1,20 @@
+-- Authentication tables
+
+-- Users table
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
+
+-- Sessions table (required by scs sqlite3store)
+CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ data BLOB NOT NULL,
+ expiry REAL NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry);
diff --git a/web/templates/index.html b/web/templates/index.html
index b341c17..54bb0c6 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -19,6 +19,12 @@
class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg transition-colors font-medium no-print">
<span id="refresh-text">Refresh</span>
</button>
+ <form method="POST" action="/logout" class="no-print">
+ <button type="submit"
+ class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-lg transition-colors font-medium">
+ Logout
+ </button>
+ </form>
</div>
</header>
diff --git a/web/templates/login.html b/web/templates/login.html
new file mode 100644
index 0000000..e5ce9e4
--- /dev/null
+++ b/web/templates/login.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Login - Personal Dashboard</title>
+ <link rel="stylesheet" href="/static/css/output.css">
+</head>
+<body class="min-h-screen flex items-center justify-center bg-gray-50">
+ <div class="w-full max-w-md p-8">
+ <div class="bg-white rounded-xl shadow-lg p-8">
+ <h1 class="text-2xl font-bold text-gray-900 text-center mb-8">Personal Dashboard</h1>
+
+ {{if .Error}}
+ <div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
+ {{.Error}}
+ </div>
+ {{end}}
+
+ <form method="POST" action="/login" class="space-y-6">
+ <div>
+ <label for="username" class="block text-sm font-medium text-gray-700 mb-2">
+ Username
+ </label>
+ <input
+ type="text"
+ id="username"
+ name="username"
+ required
+ autofocus
+ class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
+ placeholder="Enter your username">
+ </div>
+
+ <div>
+ <label for="password" class="block text-sm font-medium text-gray-700 mb-2">
+ Password
+ </label>
+ <input
+ type="password"
+ id="password"
+ name="password"
+ required
+ class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
+ placeholder="Enter your password">
+ </div>
+
+ <button
+ type="submit"
+ class="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-4 rounded-lg transition-colors">
+ Sign In
+ </button>
+ </form>
+ </div>
+ </div>
+</body>
+</html>