From 08bbcf18b1207153983261652b4a43a9b36f386c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 20 Jan 2026 11:34:33 -1000 Subject: 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 --- internal/auth/auth.go | 142 ++++++++++++++++++++++++++++++++++++++++++++ internal/auth/handlers.go | 111 ++++++++++++++++++++++++++++++++++ internal/auth/middleware.go | 50 ++++++++++++++++ internal/store/sqlite.go | 5 ++ 4 files changed, 308 insertions(+) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/handlers.go create mode 100644 internal/auth/middleware.go (limited to 'internal') 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 -- cgit v1.2.3