From ad96d44ab1f85e90a6f05f2414df3ea5f4514bad Mon Sep 17 00:00:00 2001 From: Robert Blenkinsopp Date: Sat, 22 Aug 2020 16:11:16 +0100 Subject: [PATCH] Add option to use `.hedns-session` file to store sessions between runs --- docs/_providers/hedns.md | 34 +++++++-- providers/hedns/hednsProvider.go | 120 ++++++++++++++++++++++++++----- 2 files changed, 132 insertions(+), 22 deletions(-) diff --git a/docs/_providers/hedns.md b/docs/_providers/hedns.md index 1bb60f091..87577f1dd 100644 --- a/docs/_providers/hedns.md +++ b/docs/_providers/hedns.md @@ -6,10 +6,7 @@ jsId: HEDNS --- # Hurricane Electric DNS Provider ## Configuration -In your `creds.json` file you must provide your `dns.he.net` account username and password, along wit - -In your `creds.json` file you must provide your INWX -username and password: +In your `creds.json` file you must provide your `dns.he.net` account username and password: {% highlight json %} { @@ -60,6 +57,35 @@ only available when first enabling two-factor authentication. } {% endhighlight %} +### Persistent Sessions + +Normally this provider will refresh authentication with each run of dnscontrol. This can lead to issues when using +two-factor authentication if two runs occur within the time period of a single TOTP token (30 seconds), as reusing the +same token is explicitly disallowed by RFC 6238 (TOTP). + +To work around this limitation, if multiple requests need to be made. the option `"session-file-path"` can be set in +`creds.json`, which is the location where a `.hedns-session` file will be created. This can be used to allow reuse of an +existing session, between runs, without full authentication. + +When this key is not present, this option is disabled by default. + +**Important Notes**: +* Anyone with access to this `.hedns-session` file will be able to use the existing session (until it expires) and have + *full* access to your Hurrican Electric account and will be able to modify and delete your DNS entries. +* It should be stored in a location only your user can access. + +{% highlight json %} +{ + "hedns":{ + "username": "yourUsername", + "password": "yourPassword", + "totp-key": "yourTOTPSharedSecret" + "session-file-path": "." + } +} +{% endhighlight %} + + ## Metadata This provider does not recognize any special metadata fields unique to Hurricane Electric DNS. diff --git a/providers/hedns/hednsProvider.go b/providers/hedns/hednsProvider.go index 6329c56b5..dcf5b4ec3 100644 --- a/providers/hedns/hednsProvider.go +++ b/providers/hedns/hednsProvider.go @@ -1,11 +1,15 @@ package hedns import ( + "crypto/sha1" "encoding/json" "fmt" + "io/ioutil" "net/http" "net/http/cookiejar" "net/url" + "os" + "path" "sort" "strconv" "strings" @@ -70,14 +74,16 @@ const ( ErrorTotpTokenRequired = "You must enter the token generated by your authenticator." ErrorTotpTokenReused = "This token has already been used. You may not reuse tokens." ErrorImproperDelegation = "This zone does not appear to be properly delegated to our nameservers." + + SessionFileName = ".hedns-session" ) type ApiClient struct { - Username string - Password string - - TfaSecret string - TfaValue string + Username string + Password string + TfaSecret string + TfaValue string + SessionFilePath string httpClient http.Client } @@ -92,6 +98,7 @@ type Record struct { func NewProvider(cfg map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { username, password := cfg["username"], cfg["password"] totpSecret, totpValue := cfg["totp-key"], cfg["totp"] + sessionFilePath := cfg["session-file-path"] if username == "" { return nil, fmt.Errorf("username must be provided") @@ -103,17 +110,27 @@ func NewProvider(cfg map[string]string, _ json.RawMessage) (providers.DNSService return nil, fmt.Errorf("totp and totp-key must not be specified at the same time") } + if totpValue == "" && totpSecret != "" { + var err error + totpValue, err = totp.GenerateCode(totpSecret, time.Now()) + if err != nil { + return nil, err + } + } + // Perform the initial login - client := NewApiClient(username, password, totpSecret) + client := NewApiClient(username, password, totpSecret, totpValue, sessionFilePath) err := client.authenticate() return client, err } -func NewApiClient(username, password, tfaSecret string) *ApiClient { +func NewApiClient(username, password, tfaSecret string, tfaValue string, sessionFilePath string) *ApiClient { client := ApiClient{ - Username: username, - Password: password, - TfaSecret: tfaSecret, + Username: username, + Password: password, + TfaSecret: tfaSecret, + TfaValue: tfaValue, + SessionFilePath: sessionFilePath, } // Create storage for the cookies @@ -377,14 +394,6 @@ func (c *ApiClient) authUsernameAndPassword() (authenticated bool, requiresTfa b } func (c *ApiClient) auth2FA() (authenticated bool, err error) { - if c.TfaValue == "" && c.TfaSecret != "" { - var err error - c.TfaValue, err = totp.GenerateCode(c.TfaSecret, time.Now()) - if err != nil { - return false, err - } - } - response, err := c.httpClient.PostForm(ApiUrl, url.Values{ "tfacode": {c.TfaValue}, "submit": {"Submit"}, @@ -410,6 +419,11 @@ func (c *ApiClient) auth2FA() (authenticated bool, err error) { } func (c *ApiClient) authenticate() error { + + if c.SessionFilePath != "" { + _ = c.loadSessionFile() + } + authenticated, requiresTfa, err := c.authResumeSession() if err != nil { return err @@ -435,6 +449,10 @@ func (c *ApiClient) authenticate() error { if !authenticated { err = fmt.Errorf("unknown authentication failure") + } else { + if c.SessionFilePath != "" { + err = c.saveSessionFile() + } } return err @@ -587,6 +605,72 @@ func (c *ApiClient) setZoneDynamicKey(rc *models.RecordConfig, key string) error return err } +func (c *ApiClient) generateCredentialHash() string { + hash := sha1.New() + hash.Write([]byte(c.Username)) + hash.Write([]byte(c.Password)) + //hash.Write([]byte(c.TfaValue)) + hash.Write([]byte(c.TfaSecret)) + return fmt.Sprintf("%x", hash.Sum(nil)) +} + +func (c *ApiClient) saveSessionFile() error { + cookieDomain, err := url.Parse(ApiUrl) + if err != nil { + return err + } + + // Put the credential hash on the first lines + entries := []string{ + c.generateCredentialHash(), + } + + for _, cookie := range c.httpClient.Jar.Cookies(cookieDomain) { + entries = append(entries, strings.Join([]string{cookie.Name, cookie.Value}, "=")) + } + + fileName := path.Join(c.SessionFilePath, SessionFileName) + err = ioutil.WriteFile(fileName, []byte(strings.Join(entries, "\n")), 0600) + return err +} + +func (c *ApiClient) loadSessionFile() error { + cookieDomain, err := url.Parse(ApiUrl) + if err != nil { + return err + } + + fileName := path.Join(c.SessionFilePath, SessionFileName) + bytes, err := ioutil.ReadFile(fileName) + if err != nil { + if os.IsNotExist(err) { + // Skip loading the session. + return nil + } + return err + } + + var cookies []*http.Cookie + for i, entry := range strings.Split(string(bytes), "\n") { + if i == 0 { + if entry != c.generateCredentialHash() { + return fmt.Errorf("invalid credential hash in session file") + } + } else { + kv := strings.Split(entry, "=") + if len(kv) == 2 { + cookies = append(cookies, &http.Cookie{ + Name: kv[0], + Value: kv[1], + }) + } + } + } + c.httpClient.Jar.SetCookies(cookieDomain, cookies) + + return err +} + func (c *ApiClient) parseResponseForDocumentAndErrors(response *http.Response) (document *goquery.Document, err error) { var ignoredErrorMessages = [...]string{ ErrorImproperDelegation,