494 lines
13 KiB
Go
494 lines
13 KiB
Go
package cookie
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
CookieRefreshInterval = 4 * time.Hour
|
|
CookieRetryDelay = 5 * time.Second
|
|
MaxCookieRetryDelay = 60 * time.Second
|
|
)
|
|
|
|
type CookieRefreshService struct {
|
|
username string
|
|
password string
|
|
domain string
|
|
client *http.Client
|
|
jar http.CookieJar
|
|
currentCookie string
|
|
|
|
debug bool
|
|
|
|
mu sync.RWMutex
|
|
readyOnce sync.Once
|
|
readyCh chan struct{}
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func NewCookieRefreshService(username, password, domain string) (*CookieRefreshService, error) {
|
|
return NewCookieRefreshServiceWithDebug(username, password, domain, false)
|
|
}
|
|
|
|
func NewCookieRefreshServiceWithDebug(username, password, domain string, debug bool) (*CookieRefreshService, error) {
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tr := &http.Transport{}
|
|
client := &http.Client{
|
|
Jar: jar,
|
|
Transport: tr,
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
return &CookieRefreshService{
|
|
username: username,
|
|
password: password,
|
|
domain: domain,
|
|
client: client,
|
|
jar: jar,
|
|
debug: debug,
|
|
readyCh: make(chan struct{}),
|
|
stopCh: make(chan struct{}),
|
|
}, nil
|
|
}
|
|
|
|
func (s *CookieRefreshService) Start() {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
|
|
// Initial fetch
|
|
log.Println("⏳ Fetching initial cookie...")
|
|
c, err := s.FetchFreshCookie()
|
|
if err != nil {
|
|
log.Printf("❌ Failed to obtain initial cookie: %v", err)
|
|
s.readyOnce.Do(func() { close(s.readyCh) })
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.currentCookie = c
|
|
s.mu.Unlock()
|
|
s.readyOnce.Do(func() { close(s.readyCh) })
|
|
log.Println("✅ Initial cookie obtained")
|
|
|
|
// Continuous refresh loop
|
|
ticker := time.NewTicker(CookieRefreshInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
log.Println("🔄 Auto-refreshing cookie...")
|
|
newCookie, err := s.FetchFreshCookie()
|
|
if err != nil {
|
|
log.Printf("⚠️ Cookie auto-refresh failed: %v", err)
|
|
continue
|
|
}
|
|
s.mu.Lock()
|
|
s.currentCookie = newCookie
|
|
s.mu.Unlock()
|
|
log.Println("✅ Cookie auto-refresh successful")
|
|
case <-s.stopCh:
|
|
log.Println("Cookie refresh service stopping")
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *CookieRefreshService) WaitForCookie() {
|
|
<-s.readyCh
|
|
}
|
|
|
|
func (s *CookieRefreshService) Stop() {
|
|
close(s.stopCh)
|
|
s.wg.Wait()
|
|
}
|
|
|
|
func (s *CookieRefreshService) GetCurrentCookie() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.currentCookie
|
|
}
|
|
|
|
func (s *CookieRefreshService) FetchFreshCookie() (string, error) {
|
|
if s.debug {
|
|
log.Println("💡 Stage: Starting FetchFreshCookie")
|
|
}
|
|
|
|
attempt := 0
|
|
delay := CookieRetryDelay
|
|
|
|
for {
|
|
attempt++
|
|
c, err := s.attemptFetchCookie()
|
|
if err == nil {
|
|
if s.debug {
|
|
log.Printf("✅ Successfully fetched fresh cookie with xf_user (attempt %d)", attempt)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err)
|
|
// Exponential backoff, capped
|
|
time.Sleep(delay)
|
|
delay *= 2
|
|
if delay > MaxCookieRetryDelay {
|
|
delay = MaxCookieRetryDelay
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *CookieRefreshService) attemptFetchCookie() (string, error) {
|
|
base := fmt.Sprintf("https://%s/", s.domain)
|
|
loginPage := fmt.Sprintf("https://%s/login", s.domain)
|
|
loginPost := fmt.Sprintf("https://%s/login/login", s.domain)
|
|
accountURL := fmt.Sprintf("https://%s/account/", s.domain)
|
|
rootURL, _ := url.Parse(base)
|
|
|
|
// Reset redirect policy for manual control
|
|
s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
// don't auto-follow on login POST so we can inspect cookies first
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
// --- Step 1: KiwiFlare
|
|
if s.debug {
|
|
log.Println("Step 1: Checking for KiwiFlare challenge...")
|
|
}
|
|
if err := s.solveKiwiFlareIfPresent(base); err != nil {
|
|
return "", fmt.Errorf("KiwiFlare solve failed: %w", err)
|
|
}
|
|
if s.debug {
|
|
log.Println("✅ KiwiFlare challenge solved")
|
|
}
|
|
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// --- Step 2: GET /login ---
|
|
if s.debug {
|
|
log.Println("Step 2: Fetching login page...")
|
|
}
|
|
reqLogin, _ := http.NewRequest("GET", loginPage, nil)
|
|
reqLogin.Header.Set("Cache-Control", "no-cache")
|
|
reqLogin.Header.Set("Pragma", "no-cache")
|
|
reqLogin.Header.Set("User-Agent", "Mozilla/5.0")
|
|
respLogin, err := s.client.Do(reqLogin)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get login page: %w", err)
|
|
}
|
|
defer respLogin.Body.Close()
|
|
|
|
bodyLogin, _ := io.ReadAll(respLogin.Body)
|
|
if s.debug {
|
|
log.Printf("📄 Login page HTML (first 1024 bytes):\n%s", firstN(string(bodyLogin), 1024))
|
|
}
|
|
|
|
// --- Step 3: Extract CSRF---
|
|
if s.debug {
|
|
log.Println("Step 3: Extracting CSRF token...")
|
|
}
|
|
csrf := extractCSRF(string(bodyLogin))
|
|
if csrf == "" {
|
|
return "", fmt.Errorf("CSRF token not found in login page")
|
|
}
|
|
if s.debug {
|
|
log.Printf("✅ Found CSRF token: %s...", abbreviate(csrf, 10))
|
|
}
|
|
|
|
// Record if already have xf_user BEFORE login POST
|
|
preCookies := s.jar.Cookies(rootURL)
|
|
hadXfUserBefore := hasCookie(preCookies, "xf_user")
|
|
|
|
// --- Step 4: POST /login/login---
|
|
if s.debug {
|
|
log.Println("Step 4: Submitting login credentials...")
|
|
logCookies("Cookies before login POST", preCookies)
|
|
}
|
|
|
|
form := url.Values{
|
|
"_xfToken": {csrf},
|
|
"login": {s.username},
|
|
"password": {s.password},
|
|
"_xfRedirect": {base},
|
|
"remember": {"1"},
|
|
}
|
|
postReq, _ := http.NewRequest("POST", loginPost, strings.NewReader(form.Encode()))
|
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
postReq.Header.Set("User-Agent", "Mozilla/5.0")
|
|
postReq.Header.Set("Referer", loginPage)
|
|
postReq.Header.Set("Origin", fmt.Sprintf("https://%s", s.domain))
|
|
postResp, err := s.client.Do(postReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("login POST failed: %w", err)
|
|
}
|
|
defer postResp.Body.Close()
|
|
|
|
if s.debug {
|
|
log.Printf("Login response status: %d", postResp.StatusCode)
|
|
}
|
|
|
|
// XenForo often 303 when successful; 200 might still be fine (AJAX template), so we don't fail on 200 alone.
|
|
|
|
// small delay to let cookies propagate
|
|
if s.debug {
|
|
log.Println("⏳ Waiting 2 seconds for XenForo to issue cookies...")
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
|
|
postCookies := s.jar.Cookies(rootURL)
|
|
if s.debug {
|
|
for _, c := range postCookies {
|
|
log.Printf("Cookie after login: %s=%s...", c.Name, abbreviate(c.Value, 10))
|
|
}
|
|
}
|
|
|
|
// Check for xf_user after login
|
|
if hasCookie(postCookies, "xf_user") {
|
|
return buildCookieString(postCookies), nil
|
|
}
|
|
|
|
// ---- Success path: If we had xf_user before and still no new xf_user now,
|
|
// try validating the existing session on /account/ and succeed if logged in.
|
|
if hadXfUserBefore {
|
|
if s.debug {
|
|
log.Println("🔍 Missing xf_user after login POST but it existed before; validating current session via /account/ ...")
|
|
}
|
|
ok, cookieStr := s.validateSessionUsingAccount(accountURL, rootURL)
|
|
if ok {
|
|
if s.debug {
|
|
log.Println("✅ /account/ shows logged-in; retaining existing session cookie")
|
|
}
|
|
return cookieStr, nil
|
|
}
|
|
if s.debug {
|
|
log.Println("⚠️ /account/ did not confirm logged-in; proceeding with failure")
|
|
}
|
|
}
|
|
|
|
// If not successful yet, read body for context & fail
|
|
bodyBytes, _ := io.ReadAll(postResp.Body)
|
|
bodyText := string(bodyBytes)
|
|
if s.debug {
|
|
log.Printf("📄 Login HTML snippet (first 500 chars):\n%s", firstN(bodyText, 500))
|
|
}
|
|
return "", fmt.Errorf("retry still missing xf_user cookie")
|
|
}
|
|
|
|
// -------------------------------------------
|
|
// KiwiFlare handling
|
|
// -------------------------------------------
|
|
func (s *CookieRefreshService) solveKiwiFlareIfPresent(base string) error {
|
|
req, _ := http.NewRequest("GET", base, nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
html := string(body)
|
|
|
|
// Look for data-sssg-challenge and difficulty
|
|
re := regexp.MustCompile(`data-sssg-challenge=["']([0-9a-fA-F]+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`)
|
|
m := re.FindStringSubmatch(html)
|
|
if len(m) < 3 {
|
|
if s.debug {
|
|
log.Println("No KiwiFlare POW detected")
|
|
}
|
|
return nil
|
|
}
|
|
token := m[1]
|
|
diff, _ := strconv.Atoi(m[2])
|
|
|
|
if s.debug {
|
|
log.Printf("Solving KiwiFlare challenge (difficulty=%d, token=%s...)", diff, abbreviate(token, 10))
|
|
}
|
|
nonce, dur, err := s.solvePoW(token, diff)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.debug {
|
|
log.Printf("✅ KiwiFlare challenge solved in %v (nonce=%s)", dur, nonce)
|
|
}
|
|
|
|
// Submit solution
|
|
answerURL := fmt.Sprintf("https://%s/.sssg/api/answer", s.domain)
|
|
form := url.Values{"a": {token}, "b": {nonce}}
|
|
subReq, _ := http.NewRequest("POST", answerURL, strings.NewReader(form.Encode()))
|
|
subReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
subReq.Header.Set("User-Agent", "Mozilla/5.0")
|
|
subResp, err := s.client.Do(subReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer subResp.Body.Close()
|
|
|
|
if subResp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(subResp.Body)
|
|
return fmt.Errorf("challenge solve HTTP %d (%s)", subResp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
// Check jar for sssg_clearance
|
|
rootURL, _ := url.Parse(fmt.Sprintf("https://%s/", s.domain))
|
|
for _, c := range s.jar.Cookies(rootURL) {
|
|
if c.Name == "sssg_clearance" {
|
|
if s.debug {
|
|
log.Printf("✅ KiwiFlare clearance cookie confirmed: %s...", abbreviate(c.Value, 10))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
time.Sleep(2 * time.Second)
|
|
return nil
|
|
}
|
|
|
|
func (s *CookieRefreshService) solvePoW(token string, difficulty int) (string, time.Duration, error) {
|
|
start := time.Now()
|
|
nonce := rand.Int63()
|
|
requiredBytes := difficulty / 8
|
|
requiredBits := difficulty % 8
|
|
const maxAttempts = 10_000_000
|
|
|
|
for attempts := 0; attempts < maxAttempts; attempts++ {
|
|
nonce++
|
|
input := token + fmt.Sprintf("%d", nonce)
|
|
sum := sha256.Sum256([]byte(input))
|
|
|
|
// Check leading zero bits
|
|
ok := true
|
|
for i := 0; i < requiredBytes; i++ {
|
|
if sum[i] != 0 {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if ok && requiredBits > 0 && requiredBytes < len(sum) {
|
|
mask := byte(0xFF << (8 - requiredBits))
|
|
if sum[requiredBytes]&mask != 0 {
|
|
ok = false
|
|
}
|
|
}
|
|
if ok {
|
|
elapsed := time.Since(start)
|
|
// Stretch to >= ~1.7s to look human
|
|
if elapsed < 1700*time.Millisecond {
|
|
time.Sleep(1700*time.Millisecond - elapsed)
|
|
elapsed = 1700 * time.Millisecond
|
|
}
|
|
return fmt.Sprintf("%d", nonce), elapsed, nil
|
|
}
|
|
}
|
|
return "", 0, fmt.Errorf("failed to solve PoW within %d attempts", maxAttempts)
|
|
}
|
|
|
|
func extractCSRF(body string) string {
|
|
patterns := []*regexp.Regexp{
|
|
regexp.MustCompile(`data-csrf=["']([^"']+)["']`),
|
|
regexp.MustCompile(`"csrf":"([^"]+)"`),
|
|
regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`),
|
|
}
|
|
for _, re := range patterns {
|
|
if m := re.FindStringSubmatch(body); len(m) >= 2 {
|
|
return m[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hasCookie(cookies []*http.Cookie, name string) bool {
|
|
for _, c := range cookies {
|
|
if c.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func buildCookieString(cookies []*http.Cookie) string {
|
|
want := map[string]bool{
|
|
"sssg_clearance": true,
|
|
"xf_csrf": true,
|
|
"xf_session": true,
|
|
"xf_user": true,
|
|
"xf_toggle": true,
|
|
}
|
|
var parts []string
|
|
for _, c := range cookies {
|
|
if want[c.Name] {
|
|
parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value))
|
|
}
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
func (s *CookieRefreshService) validateSessionUsingAccount(accountURL string, rootURL *url.URL) (bool, string) {
|
|
if s.debug {
|
|
log.Println("🔍 Validating session via /account/ ...")
|
|
}
|
|
req, _ := http.NewRequest("GET", accountURL, nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
if s.debug {
|
|
log.Printf("⚠️ /account/ request error: %v", err)
|
|
}
|
|
return false, ""
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
snippet := firstN(string(body), 500)
|
|
|
|
if s.debug {
|
|
log.Printf("🔍 Accessed /account/ (%d)", resp.StatusCode)
|
|
log.Printf("📄 /account/ HTML snippet:\n%s", snippet)
|
|
for _, c := range s.jar.Cookies(rootURL) {
|
|
log.Printf("🍪 Cookie after /account/: %s=%s...", c.Name, abbreviate(c.Value, 10))
|
|
}
|
|
}
|
|
|
|
// Consider logged-in if data-logged-in="true" or the template isn't "login"
|
|
if strings.Contains(snippet, `data-logged-in="true"`) ||
|
|
(!strings.Contains(snippet, `data-template="login"`) && resp.StatusCode == 200) {
|
|
return true, buildCookieString(s.jar.Cookies(rootURL))
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
func logCookies(prefix string, cookies []*http.Cookie) {
|
|
log.Printf("%s (%d):", prefix, len(cookies))
|
|
for _, c := range cookies {
|
|
log.Printf(" - %s = %s...", c.Name, abbreviate(c.Value, 10))
|
|
}
|
|
}
|
|
|
|
func firstN(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n]
|
|
}
|
|
|
|
func abbreviate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n]
|
|
} |