From 5df4769bbb040d8c72cc55b035dce31dfa6cb34e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 21 May 2026 08:36:35 -0400 Subject: [PATCH] fix: use Hover JSON signin endpoints --- internal/hover/client.go | 147 +++++++++++----------------------- internal/hover/client_test.go | 106 ++++++++---------------- internal/hover/totp.go | 7 +- 3 files changed, 83 insertions(+), 177 deletions(-) diff --git a/internal/hover/client.go b/internal/hover/client.go index 61b713f..56e37d9 100644 --- a/internal/hover/client.go +++ b/internal/hover/client.go @@ -67,13 +67,11 @@ func NewClient(creds Credentials, httpClient *http.Client) (*Client, error) { // when the session is older than sessionStaleAfter (1 hour). Safe for // concurrent use; the internal mutex serialises calls. // -// The underlying auth flow (derived from pjslauta/hover-dyn-dns): -// 1. GET https://www.hover.com/signin → extract CSRF _token -// 2. POST https://www.hover.com/signin (username + password + _token) -// 3. Probe https://www.hover.com/signin/totp for a TOTP form. -// If a _token is present → account has MFA enabled → submit TOTP code. -// If the CSRF token is absent → MFA is not enabled → skip. -// 4. Session cookies are stored in the jar for subsequent API calls. +// The underlying auth flow follows Hover's current React signin UI: +// 1. POST https://www.hover.com/signin/auth.json with username + password. +// 2. If the response status is "need_2fa", POST /signin/auth2.json with the +// current TOTP code. +// 3. Session cookies are stored in the jar for subsequent API calls. func (c *Client) Login(ctx context.Context) error { return c.ensureLogin(ctx) } @@ -98,44 +96,22 @@ func (c *Client) ensureLoginLocked(ctx context.Context) error { return nil } - csrf, err := c.fetchSignInCSRF(ctx) + auth, err := c.postLoginJSON(ctx, hoverHost+"/signin/auth.json", map[string]any{ + "username": c.creds.Username, + "password": c.creds.Password, + "remember": false, + }) if err != nil { - return err - } - - // Step 1 — submit credentials. - form := url.Values{ - "username": {c.creds.Username}, - "password": {c.creds.Password}, - "_token": {csrf}, - } - if err := c.postForm(ctx, hoverHost+"/signin", form); err != nil { return fmt.Errorf("hover signin step 1: %w", err) } - - // Step 2 — TOTP (conditional). Probe the TOTP page for a _token. - // If a token is found the account has MFA enabled and we must submit - // a 6-digit code. If the page contains no _token the account has MFA - // disabled and we skip this step — no TOTP submission required. - // - // This matches the pjslauta/hover-dyn-dns behaviour: it checks the - // response `status == 'need_2fa'` before posting to auth2.json. - // The form-based portal equivalent is the presence of _token on the - // TOTP page. - csrf2, totpEnabled, err := c.probeTOTPPage(ctx) - if err != nil { - return err - } - if totpEnabled { + if auth.Status == "need_2fa" { if c.creds.TOTPSecret.key == nil { return fmt.Errorf("hover: account has MFA enabled but no totp_secret was provided") } - code := c.creds.TOTPSecret.Code() - form = url.Values{ - "code": {code}, - "_token": {csrf2}, - } - if err := c.postForm(ctx, hoverHost+"/signin/totp", form); err != nil { + if _, err := c.postLoginJSON(ctx, hoverHost+"/signin/auth2.json", map[string]any{ + "code": c.creds.TOTPSecret.Code(), + "remember": false, + }); err != nil { return fmt.Errorf("hover signin step 2 (totp): %w", err) } } @@ -199,76 +175,49 @@ func (c *Client) fetchControlPanelCSRFLocked(ctx context.Context, domainName str return token, nil } -func (c *Client) fetchSignInCSRF(ctx context.Context) (string, error) { - return c.fetchCSRF(ctx, hoverHost+"/signin") +type signinResponse struct { + Succeeded bool `json:"succeeded"` + Status string `json:"status"` + Error string `json:"error"` } -// probeTOTPPage fetches /signin/totp and returns: -// - (token, true, nil) — page contains a _token → MFA is enabled. -// - ("", false, nil) — page loaded but no _token → MFA is not enabled. -// - ("", false, err) — network error, non-2xx status, or body read failure. -// -// Treating a non-200 response (redirect, login failure, Cloudflare gate) -// as "MFA not enabled" would silently misclassify these errors and let -// login appear to succeed before failing on the first API call. Status -// + body errors are now surfaced rather than swallowed. -func (c *Client) probeTOTPPage(ctx context.Context) (string, bool, error) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, hoverHost+"/signin/totp", nil) - req.Header.Set("User-Agent", c.UserAgent) - resp, err := c.http.Do(req) - if err != nil { - return "", false, fmt.Errorf("hover: probe TOTP page: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", false, fmt.Errorf("hover: probe TOTP page: unexpected HTTP %d", resp.StatusCode) - } - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if readErr != nil { - return "", false, fmt.Errorf("hover: probe TOTP page: read body: %w", readErr) +func (c *Client) postLoginJSON(ctx context.Context, urlStr string, payload map[string]any) (signinResponse, error) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + return signinResponse{}, err } - m := csrfRe.FindSubmatch(body) - if len(m) < 2 { - // Page loaded successfully but no CSRF token present — - // account does not have MFA enabled. - return "", false, nil + req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, &buf) + if err != nil { + return signinResponse{}, err } - return string(m[1]), true, nil -} - -func (c *Client) fetchCSRF(ctx context.Context, urlStr string) (string, error) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") req.Header.Set("User-Agent", c.UserAgent) resp, err := c.http.Do(req) if err != nil { - return "", err + return signinResponse{}, err } defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - m := csrfRe.FindSubmatch(body) - if len(m) < 2 { - return "", fmt.Errorf("hover: CSRF token not found at %s (login UI changed?)", urlStr) - } - return string(m[1]), nil -} - -func (c *Client) postForm(ctx context.Context, urlStr string, form url.Values) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, strings.NewReader(form.Encode())) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { - return err + return signinResponse{}, err } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", c.UserAgent) - resp, err := c.http.Do(req) - if err != nil { - return err + var parsed signinResponse + if len(strings.TrimSpace(string(body))) > 0 { + if err := json.Unmarshal(body, &parsed); err != nil { + return signinResponse{}, fmt.Errorf("HTTP %d: parse signin JSON: %w", resp.StatusCode, err) + } } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if parsed.Error != "" { + return signinResponse{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, parsed.Error) + } + return signinResponse{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - return nil + if parsed.Error != "" { + return signinResponse{}, errors.New(parsed.Error) + } + return parsed, nil } // DomainDelegation is the response shape of GET /api/control_panel/domains/domain-. @@ -352,10 +301,8 @@ func (c *Client) GetDomainDelegation(ctx context.Context, domainName string) (*D // Trade-off: any concurrent caller using the same *Client blocks for // the full duration of the held-lock sequence. Worst case (session is // stale and re-auth fires inside ensureLoginLocked): -// - GET /signin (CSRF for the form) -// - POST /signin (credentials) -// - GET /signin/totp (MFA probe) -// - POST /signin/totp (TOTP code, only if MFA enabled) +// - POST /signin/auth.json (credentials) +// - POST /signin/auth2.json (TOTP code, only if MFA enabled) // - GET /control_panel/domain/ (CSRF for the API write) // - PUT /api/control_panel/domains/domain- // diff --git a/internal/hover/client_test.go b/internal/hover/client_test.go index e838f44..50d397b 100644 --- a/internal/hover/client_test.go +++ b/internal/hover/client_test.go @@ -14,10 +14,6 @@ import ( "time" ) -// signinCSRFHTML is what we return on GET /signin + /signin/totp so -// the client's CSRF regex finds a token. -const signinCSRFHTML = `
` - func newStubClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { t.Helper() srv := httptest.NewServer(handler) @@ -49,26 +45,23 @@ func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { func TestClient_Login_TwoStep_WithMFA(t *testing.T) { var hits []string - var totpForm string + var totpBody map[string]any c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) { hits = append(hits, r.Method+" "+r.URL.Path) switch r.URL.Path { - case "/signin": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(signinCSRFHTML)) - return + case "/signin/auth.json": + if r.Method != http.MethodPost { + t.Errorf("auth method = %s, want POST", r.Method) } - // POST: just succeed. - w.WriteHeader(http.StatusOK) - case "/signin/totp": - if r.Method == http.MethodGet { - // Returning signinCSRFHTML signals that MFA is enabled. - _, _ = w.Write([]byte(signinCSRFHTML)) - return + _ = json.NewEncoder(w).Encode(map[string]any{"status": "need_2fa", "type": "app"}) + case "/signin/auth2.json": + if r.Method != http.MethodPost { + t.Errorf("auth2 method = %s, want POST", r.Method) } - _ = r.ParseForm() - totpForm = r.Form.Encode() - w.WriteHeader(http.StatusOK) + if err := json.NewDecoder(r.Body).Decode(&totpBody); err != nil { + t.Errorf("decode auth2 body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": "completed"}) default: t.Errorf("unexpected hit: %s %s", r.Method, r.URL.Path) } @@ -80,10 +73,8 @@ func TestClient_Login_TwoStep_WithMFA(t *testing.T) { } wantHits := []string{ - "GET /signin", - "POST /signin", - "GET /signin/totp", - "POST /signin/totp", + "POST /signin/auth.json", + "POST /signin/auth2.json", } if len(hits) != len(wantHits) { t.Fatalf("hits = %v; want %v", hits, wantHits) @@ -94,35 +85,25 @@ func TestClient_Login_TwoStep_WithMFA(t *testing.T) { } } - // TOTP form must include a 6-digit code + the CSRF token from the - // GET response. - if !strings.Contains(totpForm, "_token=t0kEnVaLuE") { - t.Errorf("TOTP POST missing CSRF: %q", totpForm) + if _, ok := totpBody["code"].(string); !ok { + t.Errorf("TOTP POST missing code: %#v", totpBody) } - if !strings.Contains(totpForm, "code=") { - t.Errorf("TOTP POST missing code: %q", totpForm) + if totpBody["remember"] != false { + t.Errorf("TOTP POST remember = %#v, want false", totpBody["remember"]) } } func TestClient_Login_NoMFA(t *testing.T) { - // Hover account with MFA disabled: /signin/totp GET returns a page - // without a _token, so the TOTP POST step is skipped. var hits []string c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) { hits = append(hits, r.Method+" "+r.URL.Path) switch r.URL.Path { - case "/signin": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(signinCSRFHTML)) - return - } - w.WriteHeader(http.StatusOK) - case "/signin/totp": - if r.Method == http.MethodGet { - // No _token → MFA not enabled on this account. - _, _ = w.Write([]byte("no token here — already logged in")) - return + case "/signin/auth.json": + if r.Method != http.MethodPost { + t.Errorf("auth method = %s, want POST", r.Method) } + _ = json.NewEncoder(w).Encode(map[string]any{"status": "completed"}) + case "/signin/auth2.json": t.Errorf("unexpected TOTP POST — account has no MFA") default: t.Errorf("unexpected hit: %s %s", r.Method, r.URL.Path) @@ -135,9 +116,7 @@ func TestClient_Login_NoMFA(t *testing.T) { } wantHits := []string{ - "GET /signin", - "POST /signin", - "GET /signin/totp", + "POST /signin/auth.json", } if len(hits) != len(wantHits) { t.Fatalf("hits = %v; want %v", hits, wantHits) @@ -149,18 +128,8 @@ func TestClient_Login_SkipsWhenFresh(t *testing.T) { c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) { hits++ switch r.URL.Path { - case "/signin": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(signinCSRFHTML)) - return - } - w.WriteHeader(http.StatusOK) - case "/signin/totp": - if r.Method == http.MethodGet { - // No MFA on this account. - _, _ = w.Write([]byte("no token")) - return - } + case "/signin/auth.json": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "completed"}) default: w.WriteHeader(http.StatusOK) } @@ -179,19 +148,18 @@ func TestClient_Login_SkipsWhenFresh(t *testing.T) { } } -func TestClient_CSRFParseFailure_RaisesClearError(t *testing.T) { +func TestClient_LoginFailure_RaisesClearError(t *testing.T) { c, srv := newStubClient(t, func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("no token here")) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": false, "error": "Invalid username or password."}) }) defer srv.Close() - // The /signin GET will return no CSRF token — Login must surface a - // clear error rather than silently failing. err := c.Login(context.Background()) if err == nil { - t.Fatal("expected CSRF parse error") + t.Fatal("expected login error") } - if !strings.Contains(err.Error(), "CSRF token not found") { + if !strings.Contains(err.Error(), "Invalid username or password") { t.Errorf("wrong error: %v", err) } } @@ -215,16 +183,8 @@ func newRecordStub(t *testing.T, apiHandler http.HandlerFunc) (*Client, *httptes mux := http.NewServeMux() // Login flow - mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(signinCSRFHTML)) - return - } - w.WriteHeader(http.StatusOK) - }) - mux.HandleFunc("/signin/totp", func(w http.ResponseWriter, r *http.Request) { - // No MFA token → skip TOTP. - _, _ = w.Write([]byte("logged in")) + mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"status": "completed"}) }) // API endpoints — delegate to caller. diff --git a/internal/hover/totp.go b/internal/hover/totp.go index 64fd9bb..0eb2f90 100644 --- a/internal/hover/totp.go +++ b/internal/hover/totp.go @@ -1,11 +1,10 @@ // Package hover implements the Hover DNS provider client. // // Hover ships no official API. This package mimics the browser-side -// authentication flow exercised by pjslauta/hover-dyn-dns: +// authentication flow exposed by Hover's signin UI: // -// 1. POST https://www.hover.com/signin (username, password) with -// CSRF `_token` parsed from the signin page. -// 2. POST https://www.hover.com/signin/totp (code, _token). +// 1. POST https://www.hover.com/signin/auth.json (username, password). +// 2. POST https://www.hover.com/signin/auth2.json (code) when MFA is required. // 3. Subsequent requests carry the session cookie jar. // // TOTP codes are RFC 6238 (HMAC-SHA1, 30s window, 6 digits).