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">✉</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
}