readysite / hosting / controllers / auth.go
6.7 KB
auth.go
package controllers

import (
	"fmt"
	"html"
	"log"
	"net/http"
	"net/mail"
	"net/url"
	"strings"
	"time"

	"github.com/readysite/readysite/hosting/internal/access"
	"github.com/readysite/readysite/hosting/models"
	"github.com/readysite/readysite/pkg/application"
)

// 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 /signup", app.Serve("auth/signup.html", nil))
	http.Handle("POST /signup", app.Method(c, "SendMagicLink", nil))
	http.Handle("GET /signin", app.Serve("auth/signin.html", nil))
	http.Handle("POST /signin", app.Method(c, "SendMagicLink", nil))
	http.Handle("GET /auth/verify", app.Method(c, "Verify", nil))
	http.Handle("POST /auth/signout", app.Method(c, "SignOut", nil))

	// Periodically clean up used/expired tokens
	go func() {
		for {
			time.Sleep(1 * time.Hour)
			models.CleanupExpiredTokens()
		}
	}()
}

// Handle returns a request-scoped controller instance.
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.
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
}

// SendMagicLink handles both signup and signin — sends a magic link email.
func (c *AuthController) SendMagicLink(w http.ResponseWriter, r *http.Request) {
	// Rate limit by IP (use RemoteAddr only — X-Forwarded-For is client-controlled)
	if !access.AuthLimiter.Allow(r.RemoteAddr) {
		c.renderError(w, "Too many requests. Please wait a moment.")
		return
	}

	email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
	if email == "" {
		c.renderError(w, "Email is required")
		return
	}

	// Validate email format
	if _, err := mail.ParseAddress(email); err != nil {
		c.renderError(w, "Please enter a valid email address")
		return
	}

	// Generate token
	rawToken, hashedToken := access.GenerateToken()
	if rawToken == "" {
		c.renderError(w, "Something went wrong. Please try again.")
		return
	}

	// Store token
	models.AuthTokens.Insert(&models.AuthToken{
		TokenHash: hashedToken,
		Email:     email,
		ExpiresAt: time.Now().Add(10 * time.Minute),
		Used:      false,
	})

	// Build verify URL
	scheme := "http"
	if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
		scheme = "https"
	}
	link := fmt.Sprintf("%s://%s/auth/verify?token=%s", scheme, r.Host, url.QueryEscape(rawToken))

	// Send email
	if err := c.App.Emailer.Send(email, "Sign in to ReadySite Hosting", "magic-link.html", map[string]any{
		"link": link,
	}); err != nil {
		log.Printf("[auth] Failed to send magic link to %s: %v", email, err)
		c.renderError(w, "Failed to send email. Please try again.")
		return
	}

	// Return compact success message, retargeting the card container
	w.Header().Set("HX-Retarget", "#auth-card")
	w.Header().Set("HX-Reswap", "innerHTML")
	w.Header().Set("Content-Type", "text/html")
	w.Write([]byte(`<div class="text-center py-2"><div class="text-violet-400 text-2xl mb-3">&#9993;</div><p class="text-white font-medium mb-1">Check your inbox</p><p class="text-[#888] text-sm">We sent a magic link to your email. It expires in 10 minutes.</p></div>`))
}

// Verify handles the magic link callback.
func (c *AuthController) Verify(w http.ResponseWriter, r *http.Request) {
	token := r.URL.Query().Get("token")
	if token == "" {
		http.Redirect(w, r, "/signin", http.StatusSeeOther)
		return
	}

	hashed := access.HashToken(token)

	// Atomically claim the token (prevents TOCTOU race on double-click)
	result, err := models.DB.Exec(
		"UPDATE AuthToken SET Used = 1, UpdatedAt = ? WHERE TokenHash = ? AND Used = 0 AND ExpiresAt > ?",
		time.Now(), hashed, time.Now(),
	)
	if err != nil {
		http.Redirect(w, r, "/signin", http.StatusSeeOther)
		return
	}
	rows, _ := result.RowsAffected()
	if rows == 0 {
		http.Redirect(w, r, "/signin", http.StatusSeeOther)
		return
	}

	// Token claimed — fetch its data
	authToken, err := models.AuthTokens.First("WHERE TokenHash = ?", hashed)
	if err != nil {
		http.Redirect(w, r, "/signin", http.StatusSeeOther)
		return
	}

	// Find or create user
	email := strings.ToLower(authToken.Email)
	user, _ := models.Users.First("WHERE Email = ?", email)
	if user == nil {
		user = &models.User{
			Email:    email,
			Verified: true,
		}
		if _, err := models.Users.Insert(user); err != nil {
			log.Printf("[auth] Failed to create user %s: %v", email, err)
			http.Redirect(w, r, "/signin", http.StatusSeeOther)
			return
		}
	} else if !user.Verified {
		user.Verified = true
		if err := models.Users.Update(user); err != nil {
			log.Printf("[auth] Failed to verify user %s: %v", email, err)
		}
	}

	// Set session
	if err := access.SetSessionCookie(w, r, user.ID); err != nil {
		log.Printf("[auth] Failed to set session cookie: %v", err)
	}

	// Redirect to dashboard
	http.Redirect(w, r, "/sites", http.StatusSeeOther)
}

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

// renderError writes an HTML error alert for HTMX.
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>`))
}

// RequireAuth is a bouncer that requires authentication.
func RequireAuth(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	user := access.GetUserFromJWT(r)
	if user == nil {
		next := r.URL.Path
		if r.URL.RawQuery != "" {
			next += "?" + r.URL.RawQuery
		}
		http.Redirect(w, r, "/signin?next="+url.QueryEscape(next), http.StatusSeeOther)
		return false
	}
	return true
}

// RequireAuthAPI is a bouncer for API endpoints that returns JSON 401.
// Also enforces rate limiting (60 req/min per IP).
func RequireAuthAPI(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	if !access.APILimiter.Allow(r.RemoteAddr) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusTooManyRequests)
		w.Write([]byte(`{"error":"Too many requests"}`))
		return false
	}
	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
}
← Back