readysite / hosting / internal / access / auth.go
3.0 KB
auth.go
package access

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/readysite/readysite/hosting/models"
)

// jwtSecret is the signing key for JWT tokens.
var jwtSecret []byte

func init() {
	secret := os.Getenv("AUTH_SECRET")
	if secret == "" || secret == "change-me-in-production" {
		if os.Getenv("ENV") == "production" {
			log.Fatal("FATAL: AUTH_SECRET must be set in production. Sessions will not persist across restarts without a stable secret.")
		}
		secret = "dev-only-not-for-production"
	}
	jwtSecret = []byte(secret)
}

// JWTExpiration is the duration before JWT tokens expire.
const JWTExpiration = 30 * 24 * time.Hour

// isSecure returns true if the request is over HTTPS.
func isSecure(r *http.Request) bool {
	if r.TLS != nil {
		return true
	}
	if r.Header.Get("X-Forwarded-Proto") == "https" {
		return true
	}
	return false
}

// SetSessionCookie creates a signed JWT and sets it as a session cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, userID string) error {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id": userID,
		"exp":     time.Now().Add(JWTExpiration).Unix(),
	})
	tokenString, err := token.SignedString(jwtSecret)
	if err != nil {
		return err
	}
	http.SetCookie(w, &http.Cookie{
		Name:     "session",
		Value:    tokenString,
		Path:     "/",
		HttpOnly: true,
		Secure:   isSecure(r),
		SameSite: http.SameSiteLaxMode,
		MaxAge:   int(JWTExpiration.Seconds()),
	})
	return nil
}

// ClearSessionCookie removes the session cookie.
func ClearSessionCookie(w http.ResponseWriter, r *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name:     "session",
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		Secure:   isSecure(r),
		SameSite: http.SameSiteLaxMode,
		MaxAge:   -1,
	})
}

// GetUserFromJWT extracts the user from the JWT cookie.
func GetUserFromJWT(r *http.Request) *models.User {
	cookie, err := r.Cookie("session")
	if err != nil {
		return nil
	}

	token, err := jwt.Parse(cookie.Value, 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 jwtSecret, nil
	})
	if err != nil || !token.Valid {
		return nil
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return nil
	}

	userID, ok := claims["user_id"].(string)
	if !ok {
		return nil
	}

	user, err := models.Users.Get(userID)
	if err != nil {
		return nil
	}

	return user
}

// GenerateToken creates a new random token for magic link authentication.
// Returns (raw token for URL, SHA-256 hash for storage).
func GenerateToken() (string, string) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", ""
	}
	raw := base64.URLEncoding.EncodeToString(b)
	return raw, HashToken(raw)
}

// HashToken returns the SHA-256 hex digest of a token.
func HashToken(token string) string {
	h := sha256.Sum256([]byte(token))
	return hex.EncodeToString(h[:])
}
← Back