initial import for Open Source 🎉

This commit is contained in:
Jimmy Zelinskie 2019-11-12 11:09:47 -05:00
parent 1898c361f3
commit 9c0dd3b722
2048 changed files with 218743 additions and 0 deletions

View file

302
endpoints/oauth/login.py Normal file
View file

@ -0,0 +1,302 @@
import logging
import time
import recaptcha2
from collections import namedtuple
from flask import request, redirect, url_for, Blueprint, abort, session
from peewee import IntegrityError
import features
from app import app, analytics, get_app_url, oauth_login, authentication, url_scheme_and_hostname
from auth.auth_context import get_authenticated_user
from auth.decorators import require_session_login
from data import model
from data.users.shared import can_create_user
from endpoints.common import common_login
from endpoints.web import index, render_page_template_with_routedata
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME, generate_csrf_token
from oauth.login import OAuthLoginException
from util.validation import generate_valid_usernames
from util.request import get_request_ip
logger = logging.getLogger(__name__)
client = app.config['HTTPCLIENT']
oauthlogin = Blueprint('oauthlogin', __name__)
oauthlogin_csrf_protect = csrf_protect(OAUTH_CSRF_TOKEN_NAME, 'state', all_methods=True,
check_header=False)
OAuthResult = namedtuple('oauthresult', ['user_obj', 'service_name', 'error_message',
'register_redirect', 'requires_verification'])
def _oauthresult(user_obj=None, service_name=None, error_message=None, register_redirect=False,
requires_verification=False):
return OAuthResult(user_obj, service_name, error_message, register_redirect,
requires_verification)
def _get_response(result):
if result.error_message is not None:
return _render_ologin_error(result.service_name, result.error_message, result.register_redirect)
return _perform_login(result.user_obj, result.service_name)
def _conduct_oauth_login(auth_system, login_service, lid, lusername, lemail, metadata=None,
captcha_verified=False):
""" Conducts login from the result of an OAuth service's login flow and returns
the status of the login, as well as the followup step. """
service_id = login_service.service_id()
service_name = login_service.service_name()
# Check for an existing account *bound to this service*. If found, conduct login of that account
# and redirect.
user_obj = model.user.verify_federated_login(service_id, lid)
if user_obj is not None:
return _oauthresult(user_obj=user_obj, service_name=service_name)
# If the login service has a bound field name, and we have a defined internal auth type that is
# not the database, then search for an existing account with that matching field. This allows
# users to setup SSO while also being backed by something like LDAP.
bound_field_name = login_service.login_binding_field()
if auth_system.federated_service is not None and bound_field_name is not None:
# Perform lookup.
logger.debug('Got oauth bind field name of "%s"', bound_field_name)
lookup_value = None
if bound_field_name == 'sub':
lookup_value = lid
elif bound_field_name == 'username':
lookup_value = lusername
elif bound_field_name == 'email':
lookup_value = lemail
if lookup_value is None:
logger.error('Missing lookup value for OAuth login')
return _oauthresult(service_name=service_name,
error_message='Configuration error in this provider')
(user_obj, err) = auth_system.link_user(lookup_value)
if err is not None:
logger.debug('%s %s not found: %s', bound_field_name, lookup_value, err)
msg = '%s %s not found in backing auth system' % (bound_field_name, lookup_value)
return _oauthresult(service_name=service_name, error_message=msg)
# Found an existing user. Bind their internal auth account to this service as well.
result = _attach_service(login_service, user_obj, lid, lusername)
if result.error_message is not None:
return result
return _oauthresult(user_obj=user_obj, service_name=service_name)
# Otherwise, we need to create a new user account.
blacklisted_domains = app.config.get('BLACKLISTED_EMAIL_DOMAINS', [])
if not can_create_user(lemail, blacklisted_domains=blacklisted_domains):
error_message = 'User creation is disabled. Please contact your administrator'
return _oauthresult(service_name=service_name, error_message=error_message)
if features.RECAPTCHA and not captcha_verified:
return _oauthresult(service_name=service_name, requires_verification=True)
# Try to create the user
try:
# Generate a valid username.
new_username = None
for valid in generate_valid_usernames(lusername):
if model.user.get_user_or_org(valid):
continue
new_username = valid
break
requires_password = auth_system.requires_distinct_cli_password
prompts = model.user.get_default_user_prompts(features)
user_obj = model.user.create_federated_user(new_username, lemail, service_id, lid,
set_password_notification=requires_password,
metadata=metadata or {},
confirm_username=features.USERNAME_CONFIRMATION,
prompts=prompts,
email_required=features.MAILING)
# Success, tell analytics
analytics.track(user_obj.username, 'register', {'service': service_name.lower()})
return _oauthresult(user_obj=user_obj, service_name=service_name)
except model.InvalidEmailAddressException:
message = ("The e-mail address {0} is already associated "
"with an existing {1} account. \n"
"Please log in with your username and password and "
"associate your {2} account to use it in the future.")
message = message.format(lemail, app.config['REGISTRY_TITLE_SHORT'], service_name)
return _oauthresult(service_name=service_name, error_message=message,
register_redirect=True)
except model.DataModelException as ex:
return _oauthresult(service_name=service_name, error_message=str(ex))
def _render_ologin_error(service_name, error_message=None, register_redirect=False):
""" Returns a Flask response indicating an OAuth error. """
user_creation = bool(features.USER_CREATION and features.DIRECT_LOGIN and
not features.INVITE_ONLY_USER_CREATION)
error_info = {
'reason': 'ologinerror',
'service_name': service_name,
'error_message': error_message or 'Could not load user data. The token may have expired',
'service_url': get_app_url(),
'user_creation': user_creation,
'register_redirect': register_redirect,
}
resp = index('', error_info=error_info)
resp.status_code = 400
return resp
def _perform_login(user_obj, service_name):
""" Attempts to login the given user, returning the Flask result of whether the login succeeded.
"""
success, _ = common_login(user_obj.uuid)
if success:
if model.user.has_user_prompts(user_obj):
return redirect(url_for('web.updateuser'))
else:
return redirect(url_for('web.index'))
else:
return _render_ologin_error(service_name, 'Could not login. Account may be disabled')
def _attach_service(login_service, user_obj, lid, lusername):
""" Attaches the given user account to the given service, with the given service user ID and
service username.
"""
metadata = {
'service_username': lusername,
}
try:
model.user.attach_federated_login(user_obj, login_service.service_id(), lid,
metadata=metadata)
return _oauthresult(user_obj=user_obj)
except IntegrityError:
err = '%s account %s is already attached to a %s account' % (
login_service.service_name(), lusername, app.config['REGISTRY_TITLE_SHORT'])
return _oauthresult(service_name=login_service.service_name(), error_message=err)
def _register_service(login_service):
""" Registers the given login service, adding its callback and attach routes to the blueprint. """
@oauthlogin_csrf_protect
def callback_func():
# Check for a callback error.
error = request.values.get('error', None)
if error:
return _render_ologin_error(login_service.service_name(), error)
# Exchange the OAuth code for login information.
code = request.values.get('code')
try:
lid, lusername, lemail = login_service.exchange_code_for_login(app.config, client, code, '')
except OAuthLoginException as ole:
logger.exception('Got login exception')
return _render_ologin_error(login_service.service_name(), str(ole))
# Conduct login.
metadata = {
'service_username': lusername,
}
# Conduct OAuth login.
captcha_verified = (int(time.time()) - session.get('captcha_verified', 0)) <= 600
session['captcha_verified'] = 0
result = _conduct_oauth_login(authentication, login_service, lid, lusername, lemail,
metadata=metadata, captcha_verified=captcha_verified)
if result.requires_verification:
return render_page_template_with_routedata('oauthcaptcha.html',
recaptcha_site_key=app.config['RECAPTCHA_SITE_KEY'],
callback_url=request.base_url)
return _get_response(result)
@require_session_login
@oauthlogin_csrf_protect
def attach_func():
# Check for a callback error.
error = request.values.get('error', None)
if error:
return _render_ologin_error(login_service.service_name(), error)
# Exchange the OAuth code for login information.
code = request.values.get('code')
try:
lid, lusername, _ = login_service.exchange_code_for_login(app.config, client, code, '/attach')
except OAuthLoginException as ole:
return _render_ologin_error(login_service.service_name(), str(ole))
# Conduct attach.
user_obj = get_authenticated_user()
result = _attach_service(login_service, user_obj, lid, lusername)
if result.error_message is not None:
return _get_response(result)
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
def captcha_func():
recaptcha_response = request.values.get('recaptcha_response', '')
result = recaptcha2.verify(app.config['RECAPTCHA_SECRET_KEY'],
recaptcha_response,
get_request_ip())
if not result['success']:
abort(400)
# Save that the captcha was verified.
session['captcha_verified'] = int(time.time())
# Redirect to the normal OAuth flow again, so that the user can now create an account.
csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)
login_scopes = login_service.get_login_scopes()
auth_url = login_service.get_auth_url(url_scheme_and_hostname, '', csrf_token, login_scopes)
return redirect(auth_url)
@require_session_login
@oauthlogin_csrf_protect
def cli_token_func():
# Check for a callback error.
error = request.values.get('error', None)
if error:
return _render_ologin_error(login_service.service_name(), error)
# Exchange the OAuth code for the ID token.
code = request.values.get('code')
try:
idtoken, _ = login_service.exchange_code_for_tokens(app.config, client, code, '/cli')
except OAuthLoginException as ole:
return _render_ologin_error(login_service.service_name(), str(ole))
user_obj = get_authenticated_user()
return redirect(url_for('web.user_view', path=user_obj.username, tab='settings',
idtoken=idtoken))
oauthlogin.add_url_rule('/%s/callback/captcha' % login_service.service_id(),
'%s_oauth_captcha' % login_service.service_id(),
captcha_func,
methods=['POST'])
oauthlogin.add_url_rule('/%s/callback' % login_service.service_id(),
'%s_oauth_callback' % login_service.service_id(),
callback_func,
methods=['GET', 'POST'])
oauthlogin.add_url_rule('/%s/callback/attach' % login_service.service_id(),
'%s_oauth_attach' % login_service.service_id(),
attach_func,
methods=['GET', 'POST'])
oauthlogin.add_url_rule('/%s/callback/cli' % login_service.service_id(),
'%s_oauth_cli' % login_service.service_id(),
cli_token_func,
methods=['GET', 'POST'])
# Register the routes for each of the login services.
for current_service in oauth_login.services:
_register_service(current_service)

View file

@ -0,0 +1,222 @@
import pytest
from mock import patch
from data import model, database
from data.users import get_users_handler, DatabaseUsers
from endpoints.oauth.login import _conduct_oauth_login
from oauth.services.github import GithubOAuthService
from test.test_ldap import mock_ldap
from test.fixtures import *
@pytest.fixture(params=[None, 'username', 'email'])
def login_service(request, app):
config = {'GITHUB': {}}
if request is not None:
config['GITHUB']['LOGIN_BINDING_FIELD'] = request.param
return GithubOAuthService(config, 'GITHUB')
@pytest.fixture(params=['Database', 'LDAP'])
def auth_system(request):
return _get_users_handler(request.param)
def _get_users_handler(auth_type):
config = {}
config['AUTHENTICATION_TYPE'] = auth_type
config['LDAP_BASE_DN'] = ['dc=quay', 'dc=io']
config['LDAP_ADMIN_DN'] = 'uid=testy,ou=employees,dc=quay,dc=io'
config['LDAP_ADMIN_PASSWD'] = 'password'
config['LDAP_USER_RDN'] = ['ou=employees']
return get_users_handler(config, None, None)
def test_existing_account(auth_system, login_service):
login_service_lid = 'someexternaluser'
# Create an existing bound federated user.
created_user = model.user.create_federated_user('someuser', 'example@example.com',
login_service.service_id(),
login_service_lid, False)
existing_user_count = database.User.select().count()
with mock_ldap():
result = _conduct_oauth_login(auth_system, login_service,
login_service_lid, login_service_lid,
'example@example.com')
assert result.user_obj == created_user
# Ensure that no addtional users were created.
current_user_count = database.User.select().count()
assert current_user_count == existing_user_count
def test_new_account_via_database(login_service):
existing_user_count = database.User.select().count()
login_service_lid = 'someexternaluser'
internal_auth = DatabaseUsers()
# Conduct login. Since the external user doesn't (yet) bind to a user in the database,
# a new user should be created and bound to the external service.
result = _conduct_oauth_login(internal_auth, login_service, login_service_lid, login_service_lid,
'example@example.com')
assert result.user_obj is not None
current_user_count = database.User.select().count()
assert current_user_count == existing_user_count + 1
# Find the user and ensure it is bound.
new_user = model.user.get_user(login_service_lid)
federated_login = model.user.lookup_federated_login(new_user, login_service.service_id())
assert federated_login is not None
# Ensure that a notification was created.
assert list(model.notification.list_notifications(result.user_obj,
kind_name='password_required'))
@pytest.mark.parametrize('open_creation, invite_only, has_invite, expect_success', [
# Open creation -> Success!
(True, False, False, True),
# Open creation + invite only + no invite -> Failure!
(True, True, False, False),
# Open creation + invite only + invite -> Success!
(True, True, True, True),
# Close creation -> Failure!
(False, False, False, False),
])
def test_flagged_user_creation(open_creation, invite_only, has_invite, expect_success, login_service):
login_service_lid = 'someexternaluser'
email = 'some@example.com'
if has_invite:
inviter = model.user.get_user('devtable')
team = model.team.get_organization_team('buynlarge', 'owners')
model.team.add_or_invite_to_team(inviter, team, email=email)
internal_auth = DatabaseUsers()
with patch('features.USER_CREATION', open_creation):
with patch('features.INVITE_ONLY_USER_CREATION', invite_only):
# Conduct login.
result = _conduct_oauth_login(internal_auth, login_service, login_service_lid, login_service_lid,
email)
assert (result.user_obj is not None) == expect_success
assert (result.error_message is None) == expect_success
@pytest.mark.parametrize('binding_field, lid, lusername, lemail, expected_error', [
# No binding field + newly seen user -> New unlinked user
(None, 'someid', 'someunknownuser', 'someemail@example.com', None),
# sub binding field + unknown sub -> Error.
('sub', 'someid', 'someuser', 'foo@bar.com',
'sub someid not found in backing auth system'),
# username binding field + unknown username -> Error.
('username', 'someid', 'someunknownuser', 'foo@bar.com',
'username someunknownuser not found in backing auth system'),
# email binding field + unknown email address -> Error.
('email', 'someid', 'someuser', 'someemail@example.com',
'email someemail@example.com not found in backing auth system'),
# No binding field + newly seen user -> New unlinked user.
(None, 'someid', 'someuser', 'foo@bar.com', None),
# username binding field + valid username -> fully bound user.
('username', 'someid', 'someuser', 'foo@bar.com', None),
# sub binding field + valid sub -> fully bound user.
('sub', 'someuser', 'someusername', 'foo@bar.com', None),
# email binding field + valid email -> fully bound user.
('email', 'someid', 'someuser', 'foo@bar.com', None),
# username binding field + valid username + invalid email -> fully bound user.
('username', 'someid', 'someuser', 'another@email.com', None),
# email binding field + valid email + invalid username -> fully bound user.
('email', 'someid', 'someotherusername', 'foo@bar.com', None),
])
def test_new_account_via_ldap(binding_field, lid, lusername, lemail, expected_error, app):
existing_user_count = database.User.select().count()
config = {'GITHUB': {}}
if binding_field is not None:
config['GITHUB']['LOGIN_BINDING_FIELD'] = binding_field
external_auth = GithubOAuthService(config, 'GITHUB')
internal_auth = _get_users_handler('LDAP')
with mock_ldap():
# Conduct OAuth login.
result = _conduct_oauth_login(internal_auth, external_auth, lid, lusername, lemail)
assert result.error_message == expected_error
current_user_count = database.User.select().count()
if expected_error is None:
# Ensure that the new user was created and that it is bound to both the
# external login service and to LDAP (if a binding_field was given).
assert current_user_count == existing_user_count + 1
assert result.user_obj is not None
# Check the service bindings.
external_login = model.user.lookup_federated_login(result.user_obj,
external_auth.service_id())
assert external_login is not None
internal_login = model.user.lookup_federated_login(result.user_obj,
internal_auth.federated_service)
if binding_field is not None:
assert internal_login is not None
else:
assert internal_login is None
# Ensure that no notification was created.
assert not list(model.notification.list_notifications(result.user_obj,
kind_name='password_required'))
else:
# Ensure that no addtional users were created.
assert current_user_count == existing_user_count
def test_existing_account_in_ldap(app):
config = {'GITHUB': {'LOGIN_BINDING_FIELD': 'username'}}
external_auth = GithubOAuthService(config, 'GITHUB')
internal_auth = _get_users_handler('LDAP')
# Add an existing federated user bound to the LDAP account associated with `someuser`.
bound_user = model.user.create_federated_user('someuser', 'foo@bar.com',
internal_auth.federated_service, 'someuser', False)
existing_user_count = database.User.select().count()
with mock_ldap():
# Conduct OAuth login with the same lid and bound field. This should find the existing LDAP
# user (via the `username` binding), and then bind Github to it as well.
result = _conduct_oauth_login(internal_auth, external_auth, bound_user.username,
bound_user.username, bound_user.email)
assert result.error_message is None
# Ensure that the same user was returned, and that it is now bound to the Github account
# as well.
assert result.user_obj.id == bound_user.id
# Ensure that no additional users were created.
current_user_count = database.User.select().count()
assert current_user_count == existing_user_count
# Check the service bindings.
external_login = model.user.lookup_federated_login(result.user_obj,
external_auth.service_id())
assert external_login is not None
internal_login = model.user.lookup_federated_login(result.user_obj,
internal_auth.federated_service)
assert internal_login is not None