Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 47 additions & 100 deletions internal/hover/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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-<name>.
Expand Down Expand Up @@ -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/<name> (CSRF for the API write)
// - PUT /api/control_panel/domains/domain-<name>
//
Expand Down
106 changes: 33 additions & 73 deletions internal/hover/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<form><input type="hidden" name="_token" value="t0kEnVaLuE"></form>`

func newStubClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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("<html>no token here — already logged in</html>"))
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)
Expand All @@ -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)
Expand All @@ -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("<html>no token</html>"))
return
}
case "/signin/auth.json":
_ = json.NewEncoder(w).Encode(map[string]any{"status": "completed"})
default:
w.WriteHeader(http.StatusOK)
}
Expand All @@ -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("<html>no token here</html>"))
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)
}
}
Expand All @@ -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("<html>logged in</html>"))
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.
Expand Down
7 changes: 3 additions & 4 deletions internal/hover/totp.go
Original file line number Diff line number Diff line change
@@ -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).
Expand Down