Skip to content

Commit

Permalink
Merge pull request #373 from drdoctr/github-auth
Browse files Browse the repository at this point in the history
Update doctr configure to use the device authorization flow
  • Loading branch information
asmeurer authored Apr 8, 2021
2 parents 1e5be24 + 48db077 commit 7e75711
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 57 deletions.
28 changes: 19 additions & 9 deletions doctr/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
get_current_repo, sync_from_log, find_sphinx_build_dir, run,
get_travis_branch, copy_to_tmp, checkout_deploy_branch)

from .common import (red, green, blue, bold_black, BOLD_BLACK, BOLD_MAGENTA,
RESET, input)
from .common import (red, green, blue, bold_black, bold_magenta, BOLD_BLACK,
BOLD_MAGENTA, RESET, input)

from . import __version__

# See https://github.com/organizations/drdoctr/settings/applications/1418010
DOCTR_CLIENT_ID = "dcd97ff81716d4498a7d"

def make_parser_with_config_adder(parser, config):
"""factory function for a smarter parser:
Expand Down Expand Up @@ -200,7 +202,7 @@ def get_parser(config=None):
unless you are using a separate GitHub user for deploying.""")
configure_parser.add_argument("--no-upload-key", action="store_false", default=True,
dest="upload_key", help="""Don't automatically upload the deploy key to GitHub. To prevent doctr
configure from asking for your GitHub credentials, use
configure from requiring you to login to GitHub, use
--no-authenticate.""")
configure_parser.add_argument("--no-authenticate", action="store_false",
default=True, dest="authenticate", help="""Don't authenticate with GitHub. This option implies --no-upload-key. This
Expand Down Expand Up @@ -402,13 +404,14 @@ def configure(args, parser):
login_kwargs = {}

if args.authenticate:
while not login_kwargs:
try:
login_kwargs = GitHub_login()
except AuthenticationFailed as e:
print(red(e))
try:
print(bold_magenta("We must first authenticate with GitHub. This authorization is only needed for the initial configuration, and may be revoked after this command exits. The 'repo' scope is used so that I can upload the deploy key to the repo for you. You may also use 'doctr configure --no-authenticate' if you want to configure doctr without authenticating with GitHub (this will require pasting the deploy key into the GitHub form manually).\n"))
access_token = GitHub_login(client_id=DOCTR_CLIENT_ID)
login_kwargs = {'headers': {'Authorization': "token {}".format(access_token)}}
except AuthenticationFailed as e:
sys.exit(red(e))
else:
login_kwargs = {'auth': None, 'headers': None}
login_kwargs = {'headers': None}

GitHub_token = None
get_build_repo = False
Expand Down Expand Up @@ -565,6 +568,13 @@ def configure(args, parser):
The docs should now build automatically on Travis.
""".format(N=N, BOLD_MAGENTA=BOLD_MAGENTA, RESET=RESET)))

if args.authenticate:
app_url = "https://github.com/settings/connections/applications/" + DOCTR_CLIENT_ID
print(dedent("""\
{N}. {BOLD_MAGENTA}Finally, if you like, you may go to {app_url} and revoke access to the doctr application (it is not needed for doctr to work past this point).{RESET}
""".format(N=N, BOLD_MAGENTA=BOLD_MAGENTA,
app_url=app_url, RESET=RESET)))

print("See the documentation at https://drdoctr.github.io/ for more information.")

def main():
Expand Down
3 changes: 3 additions & 0 deletions doctr/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def bold_black(text):
def bold_magenta(text):
return "\033[1;35m%s\033[0m" % text

def bold(text):
return "\033[1m%s\033[0m" % text

# Use these when coloring individual parts of a larger string, e.g.,
# "{BOLD_MAGENTA}Bright text{RESET} normal text".format(BOLD_MAGENTA=BOLD_MAGENTA, RESET=RESET)
BOLD_BLACK = "\033[1;30m"
Expand Down
126 changes: 78 additions & 48 deletions doctr/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,20 @@
import base64
import subprocess
import re
from getpass import getpass
import urllib
import datetime
import time
import webbrowser

import requests
from requests.auth import HTTPBasicAuth

from cryptography.fernet import Fernet

from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

from .common import red, blue, green, input
from .common import red, blue, green, bold, input

Travis_APIv2 = {'Accept': 'application/vnd.travis-ci.2.1+json'}
Travis_APIv3 = {"Travis-API-Version": "3"}
Expand Down Expand Up @@ -111,51 +111,81 @@ def encrypt_to_file(contents, filename):
class AuthenticationFailed(Exception):
pass

def GitHub_login(*, username=None, password=None, OTP=None, headers=None):
def GitHub_login(client_id, *, headers=None, scope='repo'):
"""
Login to GitHub.
If no username, password, or OTP (2-factor authentication code) are
provided, they will be requested from the command line.
This uses the device authorization flow. client_id should be the client id
for your GitHub application. See
https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps#device-flow.
Returns a dict of kwargs that can be passed to functions that require
authenticated connections to GitHub.
"""
if not username:
username = input("What is your GitHub username? ")

if not password:
password = getpass("Enter the GitHub password for {username}: ".format(username=username))
'scope' should be the scope for the access token ('repo' by default). See https://docs.github.com/en/free-pro-team@latest/developers/apps/scopes-for-oauth-apps#available-scopes.
headers = headers or {}
Returns an access token.
if OTP:
headers['X-GitHub-OTP'] = OTP

auth = HTTPBasicAuth(username, password)

r = requests.get('https://api.github.com/', auth=auth, headers=headers)
if r.status_code == 401:
two_factor = r.headers.get('X-GitHub-OTP')
if two_factor:
if OTP:
print(red("Invalid authentication code"))
# For SMS, we have to make a fake request (that will fail without
# the OTP) to get GitHub to send it. See https://github.com/drdoctr/doctr/pull/203
auth_header = base64.urlsafe_b64encode(bytes(username + ':' + password, 'utf8')).decode()
login_kwargs = {'auth': None, 'headers': {'Authorization': 'Basic {}'.format(auth_header)}}
try:
generate_GitHub_token(**login_kwargs)
except (requests.exceptions.HTTPError, GitHubError):
pass
print("A two-factor authentication code is required:", two_factor.split(';')[1].strip())
OTP = input("Authentication code: ")
return GitHub_login(username=username, password=password, OTP=OTP, headers=headers)

raise AuthenticationFailed("invalid username or password")
"""
_headers = headers or {}
headers = {"accept": "application/json", **_headers}

r = requests.post("https://github.com/login/device/code",
{"client_id": client_id, "scope": scope},
headers=headers)
GitHub_raise_for_status(r)
return {'auth': auth, 'headers': headers}
result = r.json()
device_code = result['device_code']
user_code = result['user_code']
verification_uri = result['verification_uri']
expires_in = result['expires_in']
interval = result['interval']
request_time = time.time()

print("Go to", verification_uri, "and enter this code:")
print()
print(bold(user_code))
print()
input("Press Enter to open a webbrowser to " + verification_uri)
webbrowser.open(verification_uri)
while True:
time.sleep(interval)
now = time.time()
if now - request_time > expires_in:
print("Did not receive a response in time. Please try again.")
return GitHub_login(client_id=client_id, headers=headers, scope=scope)
# Try once before opening in case the user already did it
r = requests.post("https://github.com/login/oauth/access_token",
{"client_id": client_id,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"},
headers=headers)
GitHub_raise_for_status(r)
result = r.json()
if "error" in result:
# https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow
error = result['error']
if error == "authorization_pending":
if 0:
print("No response from GitHub yet: trying again")
continue
elif error == "slow_down":
# We are polling too fast somehow. This adds 5 seconds to the
# poll interval, which we increase by 6 just to be sure it
# doesn't happen again.
interval += 6
continue
elif error == "expired_token":
print("GitHub token expired. Trying again...")
return GitHub_login(client_id=client_id, headers=headers, scope=scope)
elif error == "access_denied":
raise AuthenticationFailed("User canceled authorization")
else:
# The remaining errors, "unsupported_grant_type",
# "incorrect_client_credentials", and "incorrect_device_code"
# mean the above request was incorrect somehow, which
# indicates a bug. Or GitHub added a new error type, in which
# case this code needs to be updated.
raise AuthenticationFailed("Unexpected error when authorizing with GitHub:", error)
else:
return result['access_token']


class GitHubError(RuntimeError):
Expand Down Expand Up @@ -212,14 +242,14 @@ def plural(n):
r.raise_for_status()


def GitHub_post(data, url, *, auth, headers):
def GitHub_post(data, url, *, headers):
"""
POST the data ``data`` to GitHub.
Returns the json response from the server, or raises on error status.
"""
r = requests.post(url, auth=auth, headers=headers, data=json.dumps(data))
r = requests.post(url, headers=headers, data=json.dumps(data))
GitHub_raise_for_status(r)
return r.json()

Expand Down Expand Up @@ -288,9 +318,9 @@ def generate_GitHub_token(*, note="Doctr token for pushing to gh-pages from Trav
return GitHub_post(data, AUTH_URL, **login_kwargs)


def delete_GitHub_token(token_id, *, auth, headers):
def delete_GitHub_token(token_id, *, headers):
"""Delete a temporary GitHub token"""
r = requests.delete('https://api.github.com/authorizations/{id}'.format(id=token_id), auth=auth, headers=headers)
r = requests.delete('https://api.github.com/authorizations/{id}'.format(id=token_id), headers=headers)
GitHub_raise_for_status(r)


Expand Down Expand Up @@ -334,8 +364,8 @@ def generate_ssh_key():

return private_key, public_key

def check_repo_exists(deploy_repo, service='github', *, auth=None,
headers=None, ask=False):
def check_repo_exists(deploy_repo, service='github', *, headers=None,
ask=False):
"""
Checks that the repository exists on GitHub.
Expand Down Expand Up @@ -380,7 +410,7 @@ def check_repo_exists(deploy_repo, service='github', *, auth=None,
repo = repo[:-5]

def _try(url):
r = requests.get(url, auth=auth, headers=headers)
r = requests.get(url, headers=headers)

if r.status_code in [requests.codes.not_found, requests.codes.forbidden]:
return False
Expand Down Expand Up @@ -428,7 +458,7 @@ def _try(url):
service = 'travis-ci.com'

if not r_active:
msg = '' if auth else '. If the repo is private, then you need to authenticate.'
msg = '' if 'Authorization' in headers else '. If the repo is private, then you need to authenticate.'
raise RuntimeError('"{user}/{repo}" not found on {service}{msg}'.format(user=user,
repo=repo,
service=service,
Expand Down

0 comments on commit 7e75711

Please sign in to comment.