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[:])
}