package handler import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "net/http" "os" "strings" "time" "golang.org/x/crypto/bcrypt" ) const sessionCookie = "rs_session" const sessionDuration = 24 * time.Hour // sessionSecret returns the HMAC signing key from env, with a fallback warning. func sessionSecret() []byte { s := os.Getenv("SESSION_SECRET") if s == "" { // Insecure fallback for local dev only — set SESSION_SECRET in production return []byte("change-me-in-production") } return []byte(s) } // adminPasswordHash returns the bcrypt hash of the admin password from env. func adminPasswordHash() string { return os.Getenv("ADMIN_PASSWORD_HASH") } // checkPassword verifies a plaintext password against the stored bcrypt hash. func checkPassword(password string) bool { hash := adminPasswordHash() if hash == "" { return false } return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } // signValue creates an HMAC-signed value: base64(payload)|base64(sig). func signValue(payload string) string { mac := hmac.New(sha256.New, sessionSecret()) mac.Write([]byte(payload)) sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "|" + sig } // verifyValue checks the signature and returns the original payload. func verifyValue(signed string) (string, bool) { parts := strings.SplitN(signed, "|", 2) if len(parts) != 2 { return "", false } payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", false } payload := string(payloadBytes) expected := signValue(payload) if !hmac.Equal([]byte(signed), []byte(expected)) { return "", false } return payload, true } // setSession writes a signed session cookie. func setSession(w http.ResponseWriter) { expiry := time.Now().Add(sessionDuration).Format(time.RFC3339) value := signValue("admin|" + expiry) http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: value, Path: "/admin", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionDuration.Seconds()), }) } // clearSession deletes the session cookie. func clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/admin", MaxAge: -1, Expires: time.Unix(0, 0), }) } // isAuthenticated returns true if the request has a valid session cookie. func isAuthenticated(r *http.Request) bool { c, err := r.Cookie(sessionCookie) if err != nil { return false } payload, ok := verifyValue(c.Value) if !ok { return false } // payload = "admin|" parts := strings.SplitN(payload, "|", 2) if len(parts) != 2 || parts[0] != "admin" { return false } expiry, err := time.Parse(time.RFC3339, parts[1]) if err != nil { return false } return time.Now().Before(expiry) } // requireAuth is middleware that redirects to /admin/login if not authenticated. func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !isAuthenticated(r) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } next(w, r) } }