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
}