Skip to content

Commit

Permalink
Enhance the login task. (pantsbuild#6586)
Browse files Browse the repository at this point in the history
- If the attempt to auth with .netrc creds fails with
  a basic auth challenge, respond to that challenge by
  prompting the user for creds.

- Check that basic auth is only attempted over secure urls.

- Some documentation and message fixes.
  • Loading branch information
benjyw authored Oct 3, 2018
1 parent d86c3cc commit b2fbf43
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 19 deletions.
1 change: 1 addition & 0 deletions 3rdparty/python/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ six>=1.9.0,<2
subprocess32==3.2.7 ; python_version<'3'
thrift>=0.9.1
wheel==0.29.0
www-authenticate==0.9.2
1 change: 1 addition & 0 deletions src/docs/docsite.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"jvm_tests": "dist/markdown/html/src/docs/common_tasks/jvm_tests.html",
"list_goals": "dist/markdown/html/src/docs/common_tasks/list_goals.html",
"list_targets": "dist/markdown/html/src/docs/common_tasks/list_targets.html",
"login": "dist/markdown/html/src/docs/common_tasks/login.html",
"notes-1.0.x": "dist/markdown/html/src/python/pants/notes/1.0.x.html",
"notes-1.0.x": "dist/markdown/html/src/python/pants/notes/1.0.x.html",
"notes-1.1.x": "dist/markdown/html/src/python/pants/notes/1.1.x.html",
Expand Down
1 change: 1 addition & 0 deletions src/python/pants/auth/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
python_library(
dependencies = [
'3rdparty/python:requests',
'3rdparty/python:www-authenticate',
'src/python/pants/subsystem',
'src/python/pants/process',
'src/python/pants/util:dirutil',
Expand Down
33 changes: 27 additions & 6 deletions src/python/pants/auth/basic_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections import namedtuple

import requests
import www_authenticate

from pants.auth.cookies import Cookies
from pants.subsystem.subsystem import Subsystem
Expand All @@ -16,18 +17,33 @@ class BasicAuthException(Exception):
pass


class BasicAuthAttemptFailed(BasicAuthException):
def __init__(self, url, status_code, reason):
msg = 'Failed to auth against {}. Status code: {}. Reason: {}.'.format(url, status_code, reason)
super(BasicAuthAttemptFailed, self).__init__(msg)
self.url = url


class Challenged(BasicAuthAttemptFailed):
def __init__(self, url, status_code, reason, realm):
super(Challenged, self).__init__(url, status_code, reason)
self.realm = realm


BasicAuthCreds = namedtuple('BasicAuthCreds', ['username', 'password'])


class BasicAuth(Subsystem):
options_scope = 'basic_auth'
options_scope = 'basicauth'

@classmethod
def register_options(cls, register):
super(BasicAuth, cls).register_options(register)
register('--providers', advanced=True, type=dict,
help='Map from provider name to config dict. This dict contains the following items: '
'{url: <url of endpoint that accepts basic auth and sets a session cookie>}')
register('--allow-insecure-urls', advanced=True, type=bool, default=False,
help='Allow auth against non-HTTPS urls. Must only be set when testing!')

@classmethod
def subsystem_dependencies(cls):
Expand Down Expand Up @@ -55,16 +71,21 @@ def authenticate(self, provider, creds=None, cookies=None):

url = provider_config.get('url')
if not url:
raise BasicAuthException('No url found in config for provider {}.'.format(provider_config))
# TODO: Require url to be https, except when testing. See
# https://github.com/pantsbuild/pants/issues/6496.
raise BasicAuthException('No url found in config for provider {}.'.format(provider))
if not self.get_options().allow_insecure_urls and not url.startswith('https://'):
raise BasicAuthException('Auth url for provider {} is not secure: {}.'.format(provider, url))

if creds:
auth = requests.auth.HTTPBasicAuth(creds.username, creds.password)
else:
auth = None # requests will use the netrc creds.
response = requests.get(url, auth=auth)

if response.status_code != requests.codes.ok:
raise BasicAuthException('Failed to auth against {}. Status code {}.'.format(
response, response.status_code))
if response.status_code == requests.codes.unauthorized:
parsed = www_authenticate.parse(response.headers.get('WWW-Authenticate', ''))
if 'Basic' in parsed:
raise Challenged(url, response.status_code, response.reason, parsed['Basic']['realm'])
raise BasicAuthException(url, response.status_code, response.reason)

cookies.update(response.cookies)
37 changes: 31 additions & 6 deletions src/python/pants/core_tasks/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@

from __future__ import absolute_import, division, print_function, unicode_literals

from pants.auth.basic_auth import BasicAuth
import getpass
from builtins import input

from colors import cyan, green, red

from pants.auth.basic_auth import BasicAuth, BasicAuthCreds, Challenged
from pants.base.exceptions import TaskError
from pants.task.task import Task
from pants.task.console_task import ConsoleTask


class Login(Task):
class Login(ConsoleTask):
"""Task to auth against some identity provider.
:API: public
Expand All @@ -29,10 +34,30 @@ def register_options(cls, register):
register('--to', fingerprint=True,
help='Log in to this provider. Can also be specified as a passthru arg.')

def execute(self):
def console_output(self, targets):
if targets:
raise TaskError('The login task does not take any target arguments.')

# TODO: When we have other auth methods (e.g., OAuth2), select one by provider name.
requested_providers = list(filter(None, [self.get_options().to] + self.get_passthru_args()))
if len(requested_providers) != 1:
raise TaskError('Must specify exactly one provider.')
# TODO: An interactive mode where we prompt for creds. Currently we assume they are in netrc.
BasicAuth.global_instance().authenticate(requested_providers[0])
provider = requested_providers[0]
try:
BasicAuth.global_instance().authenticate(provider)
return ['', 'Logged in successfully using .netrc credentials.']
except Challenged as e:
creds = self._ask_for_creds(provider, e.url, e.realm)
BasicAuth.global_instance().authenticate(provider, creds=creds)
return ['', 'Logged in successfully.']

@staticmethod
def _ask_for_creds(provider, url, realm):
print(green('\nEnter credentials for:\n'))
print('{} {}'.format(green('Provider:'), cyan(provider)))
print('{} {}'.format(green('Realm: '), cyan(realm)))
print('{} {}'.format(green('URL: '), cyan(url)))
print(red('\nONLY ENTER YOUR CREDENTIALS IF YOU TRUST THIS SITE!\n'))
username = input(green('Username: '))
password = getpass.getpass(green('Password: '))
return BasicAuthCreds(username, password)
31 changes: 24 additions & 7 deletions tests/python/pants_test/auth/test_basic_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import base64
import os
import threading
from contextlib import contextmanager

from future.moves.http.server import BaseHTTPRequestHandler, HTTPServer

from pants.auth.basic_auth import BasicAuth, BasicAuthCreds
from pants.auth.basic_auth import BasicAuth, BasicAuthCreds, BasicAuthException
from pants.auth.cookies import Cookies
from pants.util.contextutil import environment_as, temporary_dir
from pants_test.test_base import TestBase
Expand All @@ -24,10 +25,13 @@ def do_GET(self):
assert token_type == 'Basic'
username, password = base64.b64decode(credentials).decode('utf8').split(':')
assert username == 'test_user'
assert password == 'test_password'
self.send_response(200)
self.send_header('Set-Cookie', 'test_auth_key=test_auth_value; Max-Age=3600')
self.end_headers()
if password == 'test_password':
self.send_response(200)
self.send_header('Set-Cookie', 'test_auth_key=test_auth_value; Max-Age=3600')
self.end_headers()
else:
self.send_response(401)
self.end_headers()


def _run_test_server():
Expand All @@ -44,21 +48,26 @@ def setUp(self):
self.port, shutdown_func = _run_test_server()
self.addCleanup(shutdown_func)

def _do_test_basic_auth(self, creds):
@contextmanager
def _test_options(self):
with temporary_dir() as tmpcookiedir:
cookie_file = os.path.join(tmpcookiedir, 'pants.test.cookies')

self.context(for_subsystems=[BasicAuth, Cookies], options={
BasicAuth.options_scope: {
'providers': {
'foobar': { 'url': 'http://localhost:{}'.format(self.port) }
}
},
'allow_insecure_urls': True
},
Cookies.options_scope: {
'path': cookie_file
}
})
yield

def _do_test_basic_auth(self, creds):
with self._test_options():
basic_auth = BasicAuth.global_instance()
cookies = Cookies.global_instance()

Expand All @@ -79,3 +88,11 @@ def test_basic_auth_from_netrc(self):
fp.write('machine localhost\nlogin test_user\npassword test_password'.encode('ascii'))
with environment_as(HOME=tmphomedir):
self._do_test_basic_auth(creds=None)

def test_basic_auth_with_bad_creds(self):
self._do_test_basic_auth(creds=BasicAuthCreds('test_user', 'test_password'))
basic_auth = BasicAuth.global_instance()
cookies = Cookies.global_instance()
bad_creds = BasicAuthCreds('test_user', 'bad_password')
self.assertRaises(BasicAuthException,
lambda: basic_auth.authenticate(provider='foobar', creds=bad_creds, cookies=cookies))

0 comments on commit b2fbf43

Please sign in to comment.