readysite / website / controllers / auth.go
7.2 KB
auth.go
package controllers

import (
	"fmt"
	"html"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"

	"github.com/golang-jwt/jwt/v5"
	"github.com/readysite/readysite/pkg/application"
	"github.com/readysite/readysite/website/internal/access"
	"github.com/readysite/readysite/website/models"
	"golang.org/x/crypto/bcrypt"
)

// Auth returns the auth controller.
func Auth() (string, *AuthController) {
	return "auth", &AuthController{}
}

// AuthController handles authentication.
type AuthController struct {
	application.BaseController
	currentUser *models.User
}

// Setup registers routes.
func (c *AuthController) Setup(app *application.App) {
	c.BaseController.Setup(app)

	http.Handle("GET /auth/signin", app.Serve("auth/signin.html", redirectOrSetup))
	http.Handle("POST /auth/signin", app.Method(c, "SignIn", nil))
	http.Handle("POST /auth/signout", app.Method(c, "SignOut", nil))
	http.Handle("GET /auth/token", app.Method(c, "TokenSignIn", nil))
}

// redirectOrSetup redirects to setup if no users exist, or to the hosting
// dashboard if HOSTING_URL is set (hosted sites don't have password signin).
func redirectOrSetup(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	if models.Users.Count("") == 0 {
		http.Redirect(w, r, "/setup", http.StatusSeeOther)
		return false
	}
	if hostingURL := os.Getenv("HOSTING_URL"); hostingURL != "" {
		http.Redirect(w, r, hostingURL, http.StatusSeeOther)
		return false
	}
	return true
}

// Handle implements Controller interface with value receiver for request isolation.
func (c AuthController) Handle(r *http.Request) application.Controller {
	c.Request = r
	c.currentUser = access.GetUserFromJWT(r)
	return &c
}

// CurrentUser returns the authenticated user, or nil if not authenticated.
func (c *AuthController) CurrentUser() *models.User {
	return c.currentUser
}

// IsAuthenticated returns true if the user is authenticated.
func (c *AuthController) IsAuthenticated() bool {
	return c.currentUser != nil
}

// Next returns the URL-encoded next parameter for redirect after signin.
func (c *AuthController) Next() string {
	if c.Request == nil {
		return ""
	}
	next := c.Request.URL.Query().Get("next")
	// Validate it's a safe relative URL
	if next != "" && strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
		return url.QueryEscape(next)
	}
	return ""
}

// SignIn authenticates a user and sets a JWT cookie.
func (c *AuthController) SignIn(w http.ResponseWriter, r *http.Request) {
	// Rate limit by IP
	key := r.RemoteAddr
	if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
		key = ip
	}
	if !access.AuthLimiter.Allow(key) {
		c.renderError(w, "Too many login attempts. Please wait a moment.")
		return
	}

	email := r.FormValue("email")
	password := r.FormValue("password")

	if email == "" || password == "" {
		c.renderError(w, "Email and password are required")
		return
	}

	user, err := models.Users.First("WHERE Email = ?", email)
	if err != nil || user == nil {
		c.renderError(w, "Invalid email or password")
		return
	}

	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
		c.renderError(w, "Invalid email or password")
		return
	}

	// Create session
	if err := access.SetSessionCookie(w, r, user.ID); err != nil {
		c.renderError(w, "Failed to create session")
		return
	}

	// Check for next parameter to redirect back to original page
	redirectTo := "/admin"
	if next := r.URL.Query().Get("next"); next != "" {
		// Validate that next is a relative URL (security: prevent open redirect)
		if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
			redirectTo = next
		}
	}

	// Use HX-Redirect for HTMX to do a full page redirect
	w.Header().Set("HX-Redirect", redirectTo)
}

// renderError writes an HTML error alert for HTMX to swap into the error div.
func (c *AuthController) renderError(w http.ResponseWriter, message string) {
	w.Header().Set("Content-Type", "text/html")
	w.Write([]byte(`<div class="alert alert-error mb-4">` + html.EscapeString(message) + `</div>`))
}

// SignOut clears the session cookie.
func (c *AuthController) SignOut(w http.ResponseWriter, r *http.Request) {
	access.ClearSessionCookie(w, r)
	c.Redirect(w, r, "/auth/signin")
}

// TokenSignIn validates a JWT from the hosting platform and creates a session.
// The token contains an email claim and is signed with the shared AUTH_SECRET.
func (c *AuthController) TokenSignIn(w http.ResponseWriter, r *http.Request) {
	tokenString := r.URL.Query().Get("token")
	if tokenString == "" {
		http.Redirect(w, r, "/auth/signin", http.StatusSeeOther)
		return
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(os.Getenv("AUTH_SECRET")), nil
	})
	if err != nil || !token.Valid {
		log.Printf("[auth] TokenSignIn: invalid token: %v", err)
		http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
		return
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		http.Error(w, "Invalid token claims", http.StatusUnauthorized)
		return
	}

	email, ok := claims["email"].(string)
	if !ok || email == "" {
		http.Error(w, "Invalid token claims", http.StatusUnauthorized)
		return
	}

	user, err := models.Users.First("WHERE Email = ?", email)
	if err != nil || user == nil {
		log.Printf("[auth] TokenSignIn: user not found for email %s", email)
		http.Error(w, "User not found", http.StatusUnauthorized)
		return
	}

	if err := access.SetSessionCookie(w, r, user.ID); err != nil {
		log.Printf("[auth] TokenSignIn: failed to set session: %v", err)
		http.Error(w, "Failed to create session", http.StatusInternalServerError)
		return
	}

	http.Redirect(w, r, "/admin", http.StatusSeeOther)
}

// RequireAuth is a bouncer that requires authentication.
func RequireAuth(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	// Redirect to setup if no users exist
	if models.Users.Count("") == 0 {
		http.Redirect(w, r, "/setup", http.StatusSeeOther)
		return false
	}
	user := access.GetUserFromJWT(r)
	if user == nil {
		// Include next parameter to redirect back after signin
		next := r.URL.Path
		if r.URL.RawQuery != "" {
			next += "?" + r.URL.RawQuery
		}
		http.Redirect(w, r, "/auth/signin?next="+url.QueryEscape(next), http.StatusSeeOther)
		return false
	}
	return true
}

// RequireAuthAPI is a bouncer for API endpoints that returns JSON 401 instead of redirecting.
func RequireAuthAPI(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	user := access.GetUserFromJWT(r)
	if user == nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte(`{"error":"Unauthorized"}`))
		return false
	}
	return true
}

// RequireAdmin is a bouncer that requires admin role.
func RequireAdmin(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	user := access.GetUserFromJWT(r)
	if user == nil {
		// Include next parameter to redirect back after signin
		next := r.URL.Path
		if r.URL.RawQuery != "" {
			next += "?" + r.URL.RawQuery
		}
		http.Redirect(w, r, "/auth/signin?next="+url.QueryEscape(next), http.StatusSeeOther)
		return false
	}
	if user.Role != "admin" {
		http.Error(w, "Admin access required", http.StatusForbidden)
		return false
	}
	return true
}
← Back