summaryrefslogtreecommitdiff
path: root/internal/auth
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 /internal/auth
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>
Diffstat (limited to 'internal/auth')
-rw-r--r--internal/auth/auth.go142
-rw-r--r--internal/auth/handlers.go111
-rw-r--r--internal/auth/middleware.go50
3 files changed, 303 insertions, 0 deletions
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())
+}