AnalogJ / lexicon

Manipulate DNS records on various DNS providers in a standardized way.
MIT License
1.48k stars 306 forks source link

Hover usage broken by mandatory 2FA #1602

Closed gitbls closed 1 year ago

gitbls commented 1 year ago

Hover has forced mandatory 2FA on all accounts, with no notification, breaking usage of lexicon-dns with Hover.

I spoke with Hover Support about this yesterday. They said that they are not hearing from enough people needing API interfaces to Hover for them to invest in making it work in this new security regime. I mentioned that both Microsoft and Google have app passwords which they could implement as well, but no positive response on this from them yet.

If this is important to you, PLEASE contact Hover support and let them know that you use the API, and that them disabling access to it is a serious problem for you.

Emporioo commented 1 year ago

Same problem encountered here.

I thought toggling it off in account settings would do the trick but it just remains on, reading further into it I noticed they state it's now mandatory. Frustrating!

I've contacted Hover, if no change (which I highly doubt unfortunatly...) I'll change provider.

Emporioo commented 1 year ago

Just to update... I moved my domain to Google and now using a service called ddclient.

Not sure what OPs use case is but for me I simply needed a way to update my domain to reference my external dynamic IP.

ddclient does the job beautifully and seems to be very popular and well maintained/documented. I wish I had discovered it sooner. It took me a long time to get the (now broken) Hover script working in this way.

bkanuka commented 1 year ago

Hey - I'm the original contributer of the Hover provider in lexicon. I got a nice surprise when my certbot and wildcard certificates failed!

I'm also going to contact Hover support, but I suspect this will also drive me to change providers :disappointed:

bkanuka commented 1 year ago

I contacted Hover support, and they were not immediately helpful. I switched to using Google Cloud for DNS becuause that's what I use in my day job. It costs me $0.01/day or $0.30/month so its almost free. I'm sure there are other free DNS providers to switch to.

bigfootjon commented 1 year ago

It is actually possible to login with 2fa using dns-lexicon

I've monkey-patched my local implementation to do this:

# The provided _authenticate function does not provide authentication
# for 2fa-enabled accounts. This shim does that
def _new_authenticate(self: hover.Provider) -> None:
    # Getting required cookies "hover_session" and "hoverauth"
    response = requests.get("https://www.hover.com/signin")
    self.cookies["hover_session"] = response.cookies["hover_session"]

    # Part one, login credentials
    payload = {
        "username": self._get_provider_option("auth_username"),
        "token": None,
        "password": self._get_provider_option("auth_password"),
    }
    response = requests.post(
        "https://www.hover.com/signin/auth.json", json=payload, cookies=self.cookies
    )
    response.raise_for_status()

    # Part two, 2fa
    payload = {
        "code": self._get_provider_option("auth_token"),
    }
    response = requests.post(
        "https://www.hover.com/signin/auth2.json", json=payload, cookies=self.cookies
    )
    response.raise_for_status()

    if "hoverauth" not in response.cookies:
        raise Exception("Unexpected auth response")
    self.cookies["hoverauth"] = response.cookies["hoverauth"]

    # Make sure domain exists
    # domain is stored in self.domain from BaseProvider

    domains = self._list_domains()
    for domain in domains:
        if domain["name"] == self.domain:
            self.domain_id = domain["id"]
            break
    else:
        raise AuthenticationError(f"Domain {self.domain} not found")

hover.Provider._authenticate = _new_authenticate

If this looks like a reasonable implementation I'd be happy to open a PR

Sudrien commented 1 year ago

Confirmed @bigfootjon 's code works. Less monkey patch, more direct hacking...

my certbot.hover.sh is looking like

TOKEN=$(oathtool -b --totp 'MY_TOTP_SECRET') PROVIDER_CREDENTIALS=("--auth-username=MY_USERNAME" "--auth-password=MY_PASSWORD" "--auth-token=${TOKEN}")

I think a pyotp integration might be appropriate? --auth-totp-secret instead of --auth-token

539 #716 #771 #884 seem to be covered by this as well

adferrand commented 1 year ago

Ok guys, let's tackle this.

So basically it seems that we have two approaches: 1) either exposing an OTP generated outside of Lexicon through a new flag like --auth-token 2) or generate the OTP directly in Lexicon using a provided secret through a new flag like --auth-totp-secret and the integration of a Python OTP library.

The issues review from Sudrien seems to indicate that OTP could be used in other providers, in place of dedicated static application credentials. Also pyotp seems a very light library with no dependency and large Python compatibility and activate maintenance. I am usually reluctant to add a dependency either globally to avoid big dependency graphs, or for a specific provider given the added complexity to handle this user-side. But here it seems to be a reasonable move.

So I will create a PR for hover with the approach proposed by @bigfootjon + the flag and pyotp integration proposed by @Sudrien.

Then, since I have no active account for Hover, I would like that one of you test the updated provider and generate the new set of cassettes for the integration tests. Sounds good to you ?

bigfootjon commented 1 year ago

Unfortunately I’m traveling for the next few weeks so I’ll be unable to test until then, but the code looks conceptually correct to me

Sudrien commented 1 year ago

Sorry, more of a rubygems person usually

pip install git+https://github.com/AnalogJ/lexicon.git@hover-otp

somthing worked

lexicon hover -h

New option shows

PROVIDER_CREDENTIALS=("--auth-username=MY_USERNAME" "--auth-password=MY_PASSWORD" "--auth-totp-secret=MY_TOTP_SECRET")

Note: MY_TOTP_SECRET should have no whitespace, according to quick pyotp test - what breaks when I have it in? Test this later

trying this with my test call for last night...

manual-auth-hook command "/root/certbot.hover.sh auth" returned error code 1
Error output from manual-auth-hook command certbot.hover.sh:
Traceback (most recent call last):
  File "/usr/local/bin/lexicon", line 8, in <module>
    sys.exit(main())
  File "/usr/local/lib/python3.9/dist-packages/lexicon/_private/cli.py", line 135, in main
    results = client.execute()
  File "/usr/local/lib/python3.9/dist-packages/lexicon/client.py", line 194, in execute
    executor = self.__enter__()
  File "/usr/local/lib/python3.9/dist-packages/lexicon/client.py", line 151, in __enter__
    raise e
  File "/usr/local/lib/python3.9/dist-packages/lexicon/client.py", line 143, in __enter__
    provider = self.provider_class(self.config)
TypeError: Can't instantiate abstract class Provider with abstract method authenticate

....

Which seems to be about

def _authenticate(self) -> None:

in hover.py but switching it back gets rid of the error with no actual dns change, and thus challenge failure.

Sudrien commented 1 year ago

Wait, switched to

def authenticate(self):

from

def _authenticate(self) -> None:

and removed

"token": None,

in the first call from hover.py and I'm getting success.

Doing the MY_TOTP_SECRET with whitspace test mentioned above...

manual-cleanup-hook command "/root/certbot.hover.sh cleanup" returned error code 1
Error output from manual-cleanup-hook command certbot.hover.sh:
Traceback (most recent call last):
  File "/usr/local/bin/lexicon", line 8, in <module>
    sys.exit(main())
  File "/usr/local/lib/python3.9/dist-packages/lexicon/_private/cli.py", line 135, in main
    results = client.execute()
  File "/usr/local/lib/python3.9/dist-packages/lexicon/client.py", line 194, in execute
    executor = self.__enter__()
  File "/usr/local/lib/python3.9/dist-packages/lexicon/client.py", line 151, in __enter__
    raise e
  File "/usr/local/lib/python3.9/dist-packages/lexicon/client.py", line 144, in __enter__
    provider.authenticate()
  File "/usr/local/lib/python3.9/dist-packages/lexicon/_private/providers/hover.py", line 59, in authenticate
    payload = {"code": self.totp.now()}
  File "/usr/local/lib/python3.9/dist-packages/pyotp/totp.py", line 64, in now
    return self.generate_otp(self.timecode(datetime.datetime.now()))
  File "/usr/local/lib/python3.9/dist-packages/pyotp/otp.py", line 35, in generate_otp
    hasher = hmac.new(self.byte_secret(), self.int_to_bytestring(input), self.digest)
  File "/usr/local/lib/python3.9/dist-packages/pyotp/otp.py", line 52, in byte_secret
    return base64.b32decode(secret, casefold=True)
  File "/usr/lib/python3.9/base64.py", line 231, in b32decode
    raise binascii.Error('Non-base32 digit found') from None
binascii.Error: Non-base32 digit found

So I'd suggest stripping all whitespace when feeding it into pyotp - as whitespace is often introduced to make human handling easier.

adferrand commented 1 year ago

Hello @Sudrien, I fixed the issues that have been caught, you can test again.

Sudrien commented 1 year ago

My tests are all working as expected now, thank you.

adferrand commented 1 year ago

Hello @Sudrien, are you willing to go ahead and update the cassettes stored in the repository for Hover provider, that allows offline integration tests? It is basically about:

If not, my alternative is that you provide me some temporary credentials and I do it directly.

Sudrien commented 1 year ago

I can handle one dump, unfortunately what I'm seeing is a bit beyond my python knowledge at the moment.

@adferrand, Assuming your commit email is current, I've sent login information for my second account.

bigfootjon commented 1 year ago

Yeah I can confirm that this PR works for me

simonevetere commented 1 year ago

hi, i install via pip install dns-lexicon how to update to have this modify ? like pip install will update the existing one with the fix for that ?

simonevetere commented 1 year ago

It is actually possible to login with 2fa using dns-lexicon

I've monkey-patched my local implementation to do this:

# The provided _authenticate function does not provide authentication
# for 2fa-enabled accounts. This shim does that
def _new_authenticate(self: hover.Provider) -> None:
    # Getting required cookies "hover_session" and "hoverauth"
    response = requests.get("https://www.hover.com/signin")
    self.cookies["hover_session"] = response.cookies["hover_session"]

    # Part one, login credentials
    payload = {
        "username": self._get_provider_option("auth_username"),
        "token": None,
        "password": self._get_provider_option("auth_password"),
    }
    response = requests.post(
        "https://www.hover.com/signin/auth.json", json=payload, cookies=self.cookies
    )
    response.raise_for_status()

    # Part two, 2fa
    payload = {
        "code": self._get_provider_option("auth_token"),
    }
    response = requests.post(
        "https://www.hover.com/signin/auth2.json", json=payload, cookies=self.cookies
    )
    response.raise_for_status()

    if "hoverauth" not in response.cookies:
        raise Exception("Unexpected auth response")
    self.cookies["hoverauth"] = response.cookies["hoverauth"]

    # Make sure domain exists
    # domain is stored in self.domain from BaseProvider

    domains = self._list_domains()
    for domain in domains:
        if domain["name"] == self.domain:
            self.domain_id = domain["id"]
            break
    else:
        raise AuthenticationError(f"Domain {self.domain} not found")

hover.Provider._authenticate = _new_authenticate

If this looks like a reasonable implementation I'd be happy to open a PR

can i see all your code please ? i don't have "hover."

bigfootjon commented 1 year ago

Hover is just the provider provided by lexicon. Its import path has moved around over time.

I deleted this code since a new release was made with similar code

simonevetere commented 1 year ago

Hover is just the provider provided by lexicon. Its import path has moved around over time.

I deleted this code since a new release was made with similar code

so why my [dns-lexicon] hover api isn't working ?

from flask import  jsonify
from lexicon.config import ConfigResolver
from lexicon.client import Client

def dohoverapi(nome,ip):

    lexicon_config = {
        "provider_name" : "hover", # lexicon shortname for provider, see providers directory for available proviers
        "action": "create", # create, list, update, delete
        "domain": "x", # domain name
        "name" : nome,
        "content" : ip,
        "type": "A", # specify a type for record filtering, case sensitive in some cases.
        "hover": {
            "auth_username": "y",
            "auth_password": "z"
        }
    }

    config = ConfigResolver()
    config.with_env().with_dict(dict_object=lexicon_config)
    client = Client(config)
    results = client.execute()

dohoverapi("test","x.y.z.k")

response : 401 Client Error: Unauthorized

bigfootjon commented 1 year ago

Please look at this PR: https://github.com/AnalogJ/lexicon/pull/1718

or this documentation: https://dns-lexicon.readthedocs.io/en/latest/configuration_reference.html#hover

you need to pass a new option

simonevetere commented 1 year ago

Please look at this PR: #1718

or this documentation: https://dns-lexicon.readthedocs.io/en/latest/configuration_reference.html#hover

you need to pass a new option

thanks for quick reply : 1) i don't have "auth_totp_secret" in my code i install via pip install maybe isn't officially relesed ? 2) where i can take this param ?

bigfootjon commented 1 year ago
  1. Yes it is officially released, please refer to the release notes: https://github.com/AnalogJ/lexicon/releases/tag/v3.15.0
  2. Not sure I understand this question
simonevetere commented 1 year ago

auth_totp_secret <- what i have to put here ?

simonevetere commented 1 year ago

idk guys i have to do in a different way : with auth_totp_secret => xyz=== 422 (Unprocessable Entity) i do :

in hover.py : in __init ()

        otp_uri = 'otpauth://totp/Hover:name?secret=secret&issuer=Hover'
        self.totp = pyotp.parse_uri( otp_uri )

in authenticate():

payload = {"code": self.totp.now()}

now it work but everytime hover.com send me a mail new access

bigfootjon commented 1 year ago

Your auth_totp_secret is the value of “secret=“ in your URL