Merge pull request #2300 from coreos-inc/openid-connect
OpenID Connect support and OAuth login refactoring
This commit is contained in:
commit
01ec22b362
36 changed files with 1623 additions and 983 deletions
|
@ -20,3 +20,4 @@ coverage
|
||||||
.cache
|
.cache
|
||||||
.npm-debug.log
|
.npm-debug.log
|
||||||
test/__pycache__
|
test/__pycache__
|
||||||
|
__pycache__
|
||||||
|
|
22
app.py
22
app.py
|
@ -25,6 +25,9 @@ from data.queue import WorkQueue, BuildMetricQueueReporter
|
||||||
from data.userevent import UserEventsBuilderModule
|
from data.userevent import UserEventsBuilderModule
|
||||||
from data.userfiles import Userfiles
|
from data.userfiles import Userfiles
|
||||||
from data.users import UserAuthentication
|
from data.users import UserAuthentication
|
||||||
|
from oauth.services.github import GithubOAuthService
|
||||||
|
from oauth.services.gitlab import GitLabOAuthService
|
||||||
|
from oauth.loginmanager import OAuthLoginManager
|
||||||
from storage import Storage
|
from storage import Storage
|
||||||
from util import get_app_url
|
from util import get_app_url
|
||||||
from util.saas.analytics import Analytics
|
from util.saas.analytics import Analytics
|
||||||
|
@ -32,18 +35,13 @@ from util.saas.useranalytics import UserAnalytics
|
||||||
from util.saas.exceptionlog import Sentry
|
from util.saas.exceptionlog import Sentry
|
||||||
from util.names import urn_generator
|
from util.names import urn_generator
|
||||||
from util.config.configutil import generate_secret_key
|
from util.config.configutil import generate_secret_key
|
||||||
from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig,
|
|
||||||
DexOAuthConfig)
|
|
||||||
from util.config.provider import get_config_provider
|
from util.config.provider import get_config_provider
|
||||||
from util.config.superusermanager import SuperUserManager
|
from util.config.superusermanager import SuperUserManager
|
||||||
from util.label_validator import LabelValidator
|
from util.label_validator import LabelValidator
|
||||||
from util.license import LicenseValidator, LICENSE_FILENAME
|
from util.license import LicenseValidator
|
||||||
from util.metrics.metricqueue import MetricQueue
|
from util.metrics.metricqueue import MetricQueue
|
||||||
from util.metrics.prometheus import PrometheusPlugin
|
from util.metrics.prometheus import PrometheusPlugin
|
||||||
from util.names import urn_generator
|
|
||||||
from util.saas.analytics import Analytics
|
|
||||||
from util.saas.cloudwatch import start_cloudwatch_sender
|
from util.saas.cloudwatch import start_cloudwatch_sender
|
||||||
from util.saas.exceptionlog import Sentry
|
|
||||||
from util.secscan.api import SecurityScannerAPI
|
from util.secscan.api import SecurityScannerAPI
|
||||||
from util.security.instancekeys import InstanceKeys
|
from util.security.instancekeys import InstanceKeys
|
||||||
from util.security.signing import Signer
|
from util.security.signing import Signer
|
||||||
|
@ -204,13 +202,11 @@ license_validator.start()
|
||||||
|
|
||||||
start_cloudwatch_sender(metric_queue, app)
|
start_cloudwatch_sender(metric_queue, app)
|
||||||
|
|
||||||
github_login = GithubOAuthConfig(app.config, 'GITHUB_LOGIN_CONFIG')
|
github_trigger = GithubOAuthService(app.config, 'GITHUB_TRIGGER_CONFIG')
|
||||||
github_trigger = GithubOAuthConfig(app.config, 'GITHUB_TRIGGER_CONFIG')
|
gitlab_trigger = GitLabOAuthService(app.config, 'GITLAB_TRIGGER_CONFIG')
|
||||||
gitlab_trigger = GitLabOAuthConfig(app.config, 'GITLAB_TRIGGER_CONFIG')
|
|
||||||
google_login = GoogleOAuthConfig(app.config, 'GOOGLE_LOGIN_CONFIG')
|
|
||||||
dex_login = DexOAuthConfig(app.config, 'DEX_LOGIN_CONFIG')
|
|
||||||
|
|
||||||
oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login, dex_login]
|
oauth_login = OAuthLoginManager(app.config)
|
||||||
|
oauth_apps = [github_trigger, gitlab_trigger]
|
||||||
|
|
||||||
image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf,
|
image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf,
|
||||||
has_namespace=False, metric_queue=metric_queue)
|
has_namespace=False, metric_queue=metric_queue)
|
||||||
|
@ -243,7 +239,7 @@ model.config.register_image_cleanup_callback(secscan_api.cleanup_layers)
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_uuid):
|
def load_user(user_uuid):
|
||||||
logger.debug('User loader loading deferred user with uuid: %s' % user_uuid)
|
logger.debug('User loader loading deferred user with uuid: %s', user_uuid)
|
||||||
return LoginWrappedDBUser(user_uuid)
|
return LoginWrappedDBUser(user_uuid)
|
||||||
|
|
||||||
class LoginWrappedDBUser(UserMixin):
|
class LoginWrappedDBUser(UserMixin):
|
||||||
|
|
|
@ -369,7 +369,7 @@ def update_user_metadata(user, given_name=None, family_name=None, company=None):
|
||||||
remove_user_prompt(user, UserPromptTypes.ENTER_COMPANY)
|
remove_user_prompt(user, UserPromptTypes.ENTER_COMPANY)
|
||||||
|
|
||||||
|
|
||||||
def create_federated_user(username, email, service_name, service_ident,
|
def create_federated_user(username, email, service_id, service_ident,
|
||||||
set_password_notification, metadata={},
|
set_password_notification, metadata={},
|
||||||
email_required=True, prompts=tuple()):
|
email_required=True, prompts=tuple()):
|
||||||
prompts = set(prompts)
|
prompts = set(prompts)
|
||||||
|
@ -379,7 +379,11 @@ def create_federated_user(username, email, service_name, service_ident,
|
||||||
new_user.verified = True
|
new_user.verified = True
|
||||||
new_user.save()
|
new_user.save()
|
||||||
|
|
||||||
service = LoginService.get(LoginService.name == service_name)
|
try:
|
||||||
|
service = LoginService.get(LoginService.name == service_id)
|
||||||
|
except LoginService.DoesNotExist:
|
||||||
|
service = LoginService.create(name=service_id)
|
||||||
|
|
||||||
FederatedLogin.create(user=new_user, service=service,
|
FederatedLogin.create(user=new_user, service=service,
|
||||||
service_ident=service_ident,
|
service_ident=service_ident,
|
||||||
metadata_json=json.dumps(metadata))
|
metadata_json=json.dumps(metadata))
|
||||||
|
@ -390,20 +394,20 @@ def create_federated_user(username, email, service_name, service_ident,
|
||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
def attach_federated_login(user, service_name, service_ident, metadata={}):
|
def attach_federated_login(user, service_id, service_ident, metadata={}):
|
||||||
service = LoginService.get(LoginService.name == service_name)
|
service = LoginService.get(LoginService.name == service_id)
|
||||||
FederatedLogin.create(user=user, service=service, service_ident=service_ident,
|
FederatedLogin.create(user=user, service=service, service_ident=service_ident,
|
||||||
metadata_json=json.dumps(metadata))
|
metadata_json=json.dumps(metadata))
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def verify_federated_login(service_name, service_ident):
|
def verify_federated_login(service_id, service_ident):
|
||||||
try:
|
try:
|
||||||
found = (FederatedLogin
|
found = (FederatedLogin
|
||||||
.select(FederatedLogin, User)
|
.select(FederatedLogin, User)
|
||||||
.join(LoginService)
|
.join(LoginService)
|
||||||
.switch(FederatedLogin).join(User)
|
.switch(FederatedLogin).join(User)
|
||||||
.where(FederatedLogin.service_ident == service_ident, LoginService.name == service_name)
|
.where(FederatedLogin.service_ident == service_ident, LoginService.name == service_id)
|
||||||
.get())
|
.get())
|
||||||
return found.user
|
return found.user
|
||||||
except FederatedLogin.DoesNotExist:
|
except FederatedLogin.DoesNotExist:
|
||||||
|
|
|
@ -11,7 +11,9 @@ from peewee import IntegrityError
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, billing as stripe, authentication, avatar, user_analytics, all_queues
|
from app import (app, billing as stripe, authentication, avatar, user_analytics, all_queues,
|
||||||
|
oauth_login)
|
||||||
|
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
||||||
|
@ -24,11 +26,12 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques
|
||||||
query_param, require_scope, format_date, show_if,
|
query_param, require_scope, format_date, show_if,
|
||||||
require_fresh_login, path_param, define_json_response,
|
require_fresh_login, path_param, define_json_response,
|
||||||
RepositoryParamResource, page_support)
|
RepositoryParamResource, page_support)
|
||||||
from endpoints.exception import NotFound, InvalidToken
|
from endpoints.exception import NotFound, InvalidToken, InvalidRequest, DownstreamIssue
|
||||||
from endpoints.api.subscribe import subscribe
|
from endpoints.api.subscribe import subscribe
|
||||||
from endpoints.common import common_login
|
from endpoints.common import common_login
|
||||||
from endpoints.csrf import generate_csrf_token, OAUTH_CSRF_TOKEN_NAME
|
from endpoints.csrf import generate_csrf_token, OAUTH_CSRF_TOKEN_NAME
|
||||||
from endpoints.decorators import anon_allowed
|
from endpoints.decorators import anon_allowed
|
||||||
|
from oauth.oidc import DiscoveryFailureException
|
||||||
from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email,
|
from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email,
|
||||||
send_password_changed, send_org_recovery_email)
|
send_password_changed, send_org_recovery_email)
|
||||||
from util.names import parse_single_urn
|
from util.names import parse_single_urn
|
||||||
|
@ -692,26 +695,62 @@ class Signout(ApiResource):
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/externaltoken')
|
@resource('/v1/externallogin/<service_id>')
|
||||||
@internal_only
|
@internal_only
|
||||||
class GenerateExternalToken(ApiResource):
|
class ExternalLoginInformation(ApiResource):
|
||||||
""" Resource for generating a token for external login. """
|
""" Resource for both setting a token for external login and returning its authorization
|
||||||
@nickname('generateExternalLoginToken')
|
url.
|
||||||
def post(self):
|
"""
|
||||||
""" Generates a CSRF token explicitly for OIDC/OAuth-associated login. """
|
schemas = {
|
||||||
return {'token': generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)}
|
'GetLogin': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Information required to an retrieve external login URL.',
|
||||||
|
'required': [
|
||||||
|
'kind',
|
||||||
|
],
|
||||||
|
'properties': {
|
||||||
|
'kind': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The kind of URL',
|
||||||
|
'enum': ['login', 'attach'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/detachexternal/<servicename>')
|
@nickname('retrieveExternalLoginAuthorizationUrl')
|
||||||
|
@anon_allowed
|
||||||
|
@validate_json_request('GetLogin')
|
||||||
|
def post(self, service_id):
|
||||||
|
""" Generates the auth URL and CSRF token explicitly for OIDC/OAuth-associated login. """
|
||||||
|
login_service = oauth_login.get_service(service_id)
|
||||||
|
if login_service is None:
|
||||||
|
raise InvalidRequest()
|
||||||
|
|
||||||
|
csrf_token = generate_csrf_token(OAUTH_CSRF_TOKEN_NAME)
|
||||||
|
kind = request.get_json()['kind']
|
||||||
|
redirect_suffix = '/attach' if kind == 'attach' else ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_scopes = login_service.get_login_scopes()
|
||||||
|
auth_url = login_service.get_auth_url(app.config, redirect_suffix, csrf_token, login_scopes)
|
||||||
|
return {'auth_url': auth_url}
|
||||||
|
except DiscoveryFailureException as dfe:
|
||||||
|
logger.exception('Could not discovery OAuth endpoint information')
|
||||||
|
raise DownstreamIssue(dfe.message)
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/detachexternal/<service_id>')
|
||||||
@show_if(features.DIRECT_LOGIN)
|
@show_if(features.DIRECT_LOGIN)
|
||||||
@internal_only
|
@internal_only
|
||||||
class DetachExternal(ApiResource):
|
class DetachExternal(ApiResource):
|
||||||
""" Resource for detaching an external login. """
|
""" Resource for detaching an external login. """
|
||||||
@require_user_admin
|
@require_user_admin
|
||||||
@nickname('detachExternalLogin')
|
@nickname('detachExternalLogin')
|
||||||
def post(self, servicename):
|
def post(self, service_id):
|
||||||
""" Request that the current user be detached from the external login service. """
|
""" Request that the current user be detached from the external login service. """
|
||||||
model.user.detach_external_login(get_authenticated_user(), servicename)
|
model.user.detach_external_login(get_authenticated_user(), service_id)
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ from flask_principal import identity_changed
|
||||||
import endpoints.decorated # Register the various exceptions via decorators.
|
import endpoints.decorated # Register the various exceptions via decorators.
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, oauth_apps, LoginWrappedDBUser, user_analytics, license_validator
|
from app import app, oauth_apps, oauth_login, LoginWrappedDBUser, user_analytics, license_validator
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.permissions import QuayDeferredPermissionUser
|
from auth.permissions import QuayDeferredPermissionUser
|
||||||
from config import frontend_visible_config
|
from config import frontend_visible_config
|
||||||
|
@ -189,6 +189,18 @@ def render_page_template(name, route_data=None, **kwargs):
|
||||||
cache_buster = cachebusters.get(filename, random_string()) if not debugging else 'debugging'
|
cache_buster = cachebusters.get(filename, random_string()) if not debugging else 'debugging'
|
||||||
yield (filename, cache_buster)
|
yield (filename, cache_buster)
|
||||||
|
|
||||||
|
def get_external_login_config():
|
||||||
|
login_config = []
|
||||||
|
for login_service in oauth_login.services:
|
||||||
|
login_config.append({
|
||||||
|
'id': login_service.service_id(),
|
||||||
|
'title': login_service.service_name(),
|
||||||
|
'config': login_service.get_public_config(),
|
||||||
|
'icon': login_service.get_icon(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return login_config
|
||||||
|
|
||||||
def get_oauth_config():
|
def get_oauth_config():
|
||||||
oauth_config = {}
|
oauth_config = {}
|
||||||
for oauth_app in oauth_apps:
|
for oauth_app in oauth_apps:
|
||||||
|
@ -215,6 +227,7 @@ def render_page_template(name, route_data=None, **kwargs):
|
||||||
feature_set=features.get_features(),
|
feature_set=features.get_features(),
|
||||||
config_set=frontend_visible_config(app.config),
|
config_set=frontend_visible_config(app.config),
|
||||||
oauth_set=get_oauth_config(),
|
oauth_set=get_oauth_config(),
|
||||||
|
external_login_set=get_external_login_config(),
|
||||||
scope_set=scopes.app_scopes(app.config),
|
scope_set=scopes.app_scopes(app.config),
|
||||||
vuln_priority_set=PRIORITY_LEVELS,
|
vuln_priority_set=PRIORITY_LEVELS,
|
||||||
enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''),
|
enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''),
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import uuid
|
||||||
|
|
||||||
from flask import request, redirect, url_for, Blueprint
|
from flask import request, redirect, url_for, Blueprint
|
||||||
from flask_login import current_user
|
|
||||||
from peewee import IntegrityError
|
from peewee import IntegrityError
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, analytics, get_app_url, github_login, google_login, dex_login
|
from app import app, analytics, get_app_url, oauth_login
|
||||||
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.process import require_session_login
|
from auth.process import require_session_login
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.common import common_login, route_show_if
|
from endpoints.common import common_login
|
||||||
from endpoints.web import index
|
from endpoints.web import index
|
||||||
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME
|
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME
|
||||||
from util.security.jwtutil import decode, InvalidTokenError
|
from oauth.login import OAuthLoginException
|
||||||
from util.validation import generate_valid_usernames
|
from util.validation import generate_valid_usernames
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -22,7 +22,9 @@ oauthlogin = Blueprint('oauthlogin', __name__)
|
||||||
|
|
||||||
oauthlogin_csrf_protect = csrf_protect(OAUTH_CSRF_TOKEN_NAME, 'state', all_methods=True)
|
oauthlogin_csrf_protect = csrf_protect(OAUTH_CSRF_TOKEN_NAME, 'state', all_methods=True)
|
||||||
|
|
||||||
def render_ologin_error(service_name, error_message=None, register_redirect=False):
|
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)
|
user_creation = bool(features.USER_CREATION and features.DIRECT_LOGIN)
|
||||||
error_info = {
|
error_info = {
|
||||||
'reason': 'ologinerror',
|
'reason': 'ologinerror',
|
||||||
|
@ -37,30 +39,19 @@ def render_ologin_error(service_name, error_message=None, register_redirect=Fals
|
||||||
resp.status_code = 400
|
resp.status_code = 400
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
def _conduct_oauth_login(service_id, service_name, user_id, username, email, metadata=None):
|
||||||
|
""" Conducts login from the result of an OAuth service's login flow. """
|
||||||
|
|
||||||
def get_user(service, token):
|
to_login = model.user.verify_federated_login(service_id, user_id)
|
||||||
token_param = {
|
|
||||||
'access_token': token,
|
|
||||||
'alt': 'json',
|
|
||||||
}
|
|
||||||
got_user = client.get(service.user_endpoint(), params=token_param)
|
|
||||||
if got_user.status_code != requests.codes.ok:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return got_user.json()
|
|
||||||
|
|
||||||
|
|
||||||
def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
|
||||||
service_name = service.service_name()
|
|
||||||
to_login = model.user.verify_federated_login(service_name.lower(), user_id)
|
|
||||||
if not to_login:
|
if not to_login:
|
||||||
# See if we can create a new user.
|
# See if we can create a new user.
|
||||||
if not features.USER_CREATION:
|
if not features.USER_CREATION:
|
||||||
error_message = 'User creation is disabled. Please contact your administrator'
|
error_message = 'User creation is disabled. Please contact your administrator'
|
||||||
return render_ologin_error(service_name, error_message)
|
return _render_ologin_error(service_name, error_message)
|
||||||
|
|
||||||
# Try to create the user
|
# Try to create the user
|
||||||
try:
|
try:
|
||||||
|
# Generate a valid username.
|
||||||
new_username = None
|
new_username = None
|
||||||
for valid in generate_valid_usernames(username):
|
for valid in generate_valid_usernames(username):
|
||||||
if model.user.get_user_or_org(valid):
|
if model.user.get_user_or_org(valid):
|
||||||
|
@ -69,8 +60,13 @@ def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
||||||
new_username = valid
|
new_username = valid
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Generate a valid email. If the email is None and the MAILING feature is turned
|
||||||
|
# off, simply place in a fake email address.
|
||||||
|
if email is None and not features.MAILING:
|
||||||
|
email = '%s@fake.example.com' % (str(uuid.uuid4()))
|
||||||
|
|
||||||
prompts = model.user.get_default_user_prompts(features)
|
prompts = model.user.get_default_user_prompts(features)
|
||||||
to_login = model.user.create_federated_user(new_username, email, service_name.lower(),
|
to_login = model.user.create_federated_user(new_username, email, service_id,
|
||||||
user_id, set_password_notification=True,
|
user_id, set_password_notification=True,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
prompts=prompts)
|
prompts=prompts)
|
||||||
|
@ -84,10 +80,10 @@ def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
||||||
message = message + "\nPlease log in with your username and password and "
|
message = message + "\nPlease log in with your username and password and "
|
||||||
message = message + "associate your %s account to use it in the future." % (service_name, )
|
message = message + "associate your %s account to use it in the future." % (service_name, )
|
||||||
|
|
||||||
return render_ologin_error(service_name, message, register_redirect=True)
|
return _render_ologin_error(service_name, message, register_redirect=True)
|
||||||
|
|
||||||
except model.DataModelException as ex:
|
except model.DataModelException as ex:
|
||||||
return render_ologin_error(service_name, ex.message)
|
return _render_ologin_error(service_name, ex.message)
|
||||||
|
|
||||||
if common_login(to_login):
|
if common_login(to_login):
|
||||||
if model.user.has_user_prompts(to_login):
|
if model.user.has_user_prompts(to_login):
|
||||||
|
@ -95,277 +91,79 @@ def conduct_oauth_login(service, user_id, username, email, metadata=None):
|
||||||
else:
|
else:
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
return render_ologin_error(service_name)
|
return _render_ologin_error(service_name)
|
||||||
|
|
||||||
|
|
||||||
def get_email_username(user_data):
|
def _register_service(login_service):
|
||||||
username = user_data['email']
|
""" Registers the given login service, adding its callback and attach routes to the blueprint. """
|
||||||
at = username.find('@')
|
|
||||||
if at > 0:
|
|
||||||
username = username[0:at]
|
|
||||||
|
|
||||||
return username
|
@oauthlogin_csrf_protect
|
||||||
|
def callback_func():
|
||||||
|
# Check for a callback error.
|
||||||
|
error = request.args.get('error', None)
|
||||||
|
if error:
|
||||||
|
return _render_ologin_error(login_service.service_name(), error)
|
||||||
|
|
||||||
|
# Exchange the OAuth code for login information.
|
||||||
|
code = request.args.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(), ole.message)
|
||||||
|
|
||||||
|
# Conduct login.
|
||||||
|
metadata = {
|
||||||
|
'service_username': lusername
|
||||||
|
}
|
||||||
|
|
||||||
|
return _conduct_oauth_login(login_service.service_id(), login_service.service_name(), lid,
|
||||||
|
lusername, lemail, metadata=metadata)
|
||||||
|
|
||||||
|
|
||||||
@oauthlogin.route('/google/callback', methods=['GET'])
|
@require_session_login
|
||||||
@route_show_if(features.GOOGLE_LOGIN)
|
@oauthlogin_csrf_protect
|
||||||
@oauthlogin_csrf_protect
|
def attach_func():
|
||||||
def google_oauth_callback():
|
# Check for a callback error.
|
||||||
error = request.args.get('error', None)
|
error = request.args.get('error', None)
|
||||||
if error:
|
if error:
|
||||||
return render_ologin_error('Google', error)
|
return _render_ologin_error(login_service.service_name(), error)
|
||||||
|
|
||||||
code = request.args.get('code')
|
# Exchange the OAuth code for login information.
|
||||||
token = google_login.exchange_code_for_token(app.config, client, code, form_encode=True)
|
code = request.args.get('code')
|
||||||
if token is None:
|
try:
|
||||||
return render_ologin_error('Google')
|
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(), ole.message)
|
||||||
|
|
||||||
user_data = get_user(google_login, token)
|
# Conduct attach.
|
||||||
if not user_data or not user_data.get('id', None) or not user_data.get('email', None):
|
metadata = {
|
||||||
return render_ologin_error('Google')
|
'service_username': lusername
|
||||||
|
}
|
||||||
|
|
||||||
if not user_data.get('verified_email', False):
|
user_obj = get_authenticated_user()
|
||||||
return render_ologin_error(
|
|
||||||
'Google',
|
|
||||||
'A verified e-mail address is required for login. Please verify your ' +
|
|
||||||
'e-mail address in Google and try again.',
|
|
||||||
)
|
|
||||||
|
|
||||||
username = get_email_username(user_data)
|
try:
|
||||||
metadata = {
|
model.user.attach_federated_login(user_obj, login_service.service_id(), lid,
|
||||||
'service_username': user_data['email']
|
metadata=metadata)
|
||||||
}
|
except IntegrityError:
|
||||||
|
err = '%s account %s is already attached to a %s account' % (
|
||||||
|
login_service.service_name(), lusername, app.config['REGISTRY_TITLE_SHORT'])
|
||||||
|
return _render_ologin_error(login_service.service_name(), err)
|
||||||
|
|
||||||
return conduct_oauth_login(google_login, user_data['id'], username, user_data['email'],
|
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
||||||
metadata=metadata)
|
|
||||||
|
|
||||||
|
|
||||||
@oauthlogin.route('/github/callback', methods=['GET'])
|
oauthlogin.add_url_rule('/%s/callback' % login_service.service_id(),
|
||||||
@route_show_if(features.GITHUB_LOGIN)
|
'%s_oauth_callback' % login_service.service_id(),
|
||||||
@oauthlogin_csrf_protect
|
callback_func,
|
||||||
def github_oauth_callback():
|
methods=['GET'])
|
||||||
error = request.args.get('error', None)
|
|
||||||
if error:
|
|
||||||
return render_ologin_error('GitHub', error)
|
|
||||||
|
|
||||||
# Exchange the OAuth code.
|
oauthlogin.add_url_rule('/%s/callback/attach' % login_service.service_id(),
|
||||||
code = request.args.get('code')
|
'%s_oauth_attach' % login_service.service_id(),
|
||||||
token = github_login.exchange_code_for_token(app.config, client, code)
|
attach_func,
|
||||||
if token is None:
|
methods=['GET'])
|
||||||
return render_ologin_error('GitHub')
|
|
||||||
|
|
||||||
# Retrieve the user's information.
|
# Register the routes for each of the login services.
|
||||||
user_data = get_user(github_login, token)
|
for current_service in oauth_login.services:
|
||||||
if not user_data or 'login' not in user_data:
|
_register_service(current_service)
|
||||||
return render_ologin_error('GitHub')
|
|
||||||
|
|
||||||
username = user_data['login']
|
|
||||||
github_id = user_data['id']
|
|
||||||
|
|
||||||
v3_media_type = {
|
|
||||||
'Accept': 'application/vnd.github.v3'
|
|
||||||
}
|
|
||||||
|
|
||||||
token_param = {
|
|
||||||
'access_token': token,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Retrieve the user's orgnizations (if organization filtering is turned on)
|
|
||||||
if github_login.allowed_organizations() is not None:
|
|
||||||
get_orgs = client.get(github_login.orgs_endpoint(), params=token_param,
|
|
||||||
headers={'Accept': 'application/vnd.github.moondragon+json'})
|
|
||||||
|
|
||||||
organizations = set([org.get('login').lower() for org in get_orgs.json()])
|
|
||||||
matching_organizations = organizations & set(github_login.allowed_organizations())
|
|
||||||
if not matching_organizations:
|
|
||||||
err = """You are not a member of an allowed GitHub organization.
|
|
||||||
Please contact your system administrator if you believe this is in error."""
|
|
||||||
return render_ologin_error('GitHub', err)
|
|
||||||
|
|
||||||
# Find the e-mail address for the user: we will accept any email, but we prefer the primary
|
|
||||||
get_email = client.get(github_login.email_endpoint(), params=token_param,
|
|
||||||
headers=v3_media_type)
|
|
||||||
if get_email.status_code / 100 != 2:
|
|
||||||
return render_ologin_error('GitHub')
|
|
||||||
|
|
||||||
found_email = None
|
|
||||||
for user_email in get_email.json():
|
|
||||||
if not github_login.is_enterprise() and not user_email['verified']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
found_email = user_email['email']
|
|
||||||
if user_email['primary']:
|
|
||||||
break
|
|
||||||
|
|
||||||
if found_email is None:
|
|
||||||
err = 'There is no verified e-mail address attached to the GitHub account.'
|
|
||||||
return render_ologin_error('GitHub', err)
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
'service_username': username
|
|
||||||
}
|
|
||||||
|
|
||||||
return conduct_oauth_login(github_login, github_id, username, found_email, metadata=metadata)
|
|
||||||
|
|
||||||
|
|
||||||
@oauthlogin.route('/google/callback/attach', methods=['GET'])
|
|
||||||
@route_show_if(features.GOOGLE_LOGIN)
|
|
||||||
@require_session_login
|
|
||||||
@oauthlogin_csrf_protect
|
|
||||||
def google_oauth_attach():
|
|
||||||
code = request.args.get('code')
|
|
||||||
token = google_login.exchange_code_for_token(app.config, client, code,
|
|
||||||
redirect_suffix='/attach', form_encode=True)
|
|
||||||
if token is None:
|
|
||||||
return render_ologin_error('Google')
|
|
||||||
|
|
||||||
user_data = get_user(google_login, token)
|
|
||||||
if not user_data or not user_data.get('id', None):
|
|
||||||
return render_ologin_error('Google')
|
|
||||||
|
|
||||||
if not user_data.get('verified_email', False):
|
|
||||||
return render_ologin_error(
|
|
||||||
'Google',
|
|
||||||
'A verified e-mail address is required for login. Please verify your ' +
|
|
||||||
'e-mail address in Google and try again.',
|
|
||||||
)
|
|
||||||
|
|
||||||
google_id = user_data['id']
|
|
||||||
user_obj = current_user.db_user()
|
|
||||||
|
|
||||||
username = get_email_username(user_data)
|
|
||||||
metadata = {
|
|
||||||
'service_username': user_data['email']
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
model.user.attach_federated_login(user_obj, 'google', google_id, metadata=metadata)
|
|
||||||
except IntegrityError:
|
|
||||||
err = 'Google account %s is already attached to a %s account' % (
|
|
||||||
username, app.config['REGISTRY_TITLE_SHORT'])
|
|
||||||
return render_ologin_error('Google', err)
|
|
||||||
|
|
||||||
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
|
||||||
|
|
||||||
|
|
||||||
@oauthlogin.route('/github/callback/attach', methods=['GET'])
|
|
||||||
@route_show_if(features.GITHUB_LOGIN)
|
|
||||||
@require_session_login
|
|
||||||
@oauthlogin_csrf_protect
|
|
||||||
def github_oauth_attach():
|
|
||||||
code = request.args.get('code')
|
|
||||||
token = github_login.exchange_code_for_token(app.config, client, code)
|
|
||||||
if token is None:
|
|
||||||
return render_ologin_error('GitHub')
|
|
||||||
|
|
||||||
user_data = get_user(github_login, token)
|
|
||||||
if not user_data:
|
|
||||||
return render_ologin_error('GitHub')
|
|
||||||
|
|
||||||
github_id = user_data['id']
|
|
||||||
user_obj = current_user.db_user()
|
|
||||||
|
|
||||||
username = user_data['login']
|
|
||||||
metadata = {
|
|
||||||
'service_username': username
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
model.user.attach_federated_login(user_obj, 'github', github_id, metadata=metadata)
|
|
||||||
except IntegrityError:
|
|
||||||
err = 'Github account %s is already attached to a %s account' % (
|
|
||||||
username, app.config['REGISTRY_TITLE_SHORT'])
|
|
||||||
|
|
||||||
return render_ologin_error('GitHub', err)
|
|
||||||
|
|
||||||
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
|
||||||
|
|
||||||
|
|
||||||
def decode_user_jwt(token, oidc_provider):
|
|
||||||
try:
|
|
||||||
return decode(token, oidc_provider.get_public_key(), algorithms=['RS256'],
|
|
||||||
audience=oidc_provider.client_id(),
|
|
||||||
issuer=oidc_provider.issuer)
|
|
||||||
except InvalidTokenError:
|
|
||||||
# Public key may have expired. Try to retrieve an updated public key and use it to decode.
|
|
||||||
return decode(token, oidc_provider.get_public_key(force_refresh=True), algorithms=['RS256'],
|
|
||||||
audience=oidc_provider.client_id(),
|
|
||||||
issuer=oidc_provider.issuer)
|
|
||||||
|
|
||||||
|
|
||||||
@oauthlogin.route('/dex/callback', methods=['GET', 'POST'])
|
|
||||||
@route_show_if(features.DEX_LOGIN)
|
|
||||||
@oauthlogin_csrf_protect
|
|
||||||
def dex_oauth_callback():
|
|
||||||
error = request.values.get('error', None)
|
|
||||||
if error:
|
|
||||||
return render_ologin_error(dex_login.public_title, error)
|
|
||||||
|
|
||||||
code = request.values.get('code')
|
|
||||||
if not code:
|
|
||||||
return render_ologin_error(dex_login.public_title, 'Missing OAuth code')
|
|
||||||
|
|
||||||
token = dex_login.exchange_code_for_token(app.config, client, code, client_auth=True,
|
|
||||||
form_encode=True)
|
|
||||||
if token is None:
|
|
||||||
return render_ologin_error(dex_login.public_title)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = decode_user_jwt(token, dex_login)
|
|
||||||
except InvalidTokenError:
|
|
||||||
logger.exception('Exception when decoding returned JWT')
|
|
||||||
return render_ologin_error(
|
|
||||||
dex_login.public_title,
|
|
||||||
'Could not decode response. Please contact your system administrator about this error.',
|
|
||||||
)
|
|
||||||
|
|
||||||
username = get_email_username(payload)
|
|
||||||
metadata = {}
|
|
||||||
|
|
||||||
dex_id = payload['sub']
|
|
||||||
email_address = payload['email']
|
|
||||||
|
|
||||||
if not payload.get('email_verified', False):
|
|
||||||
return render_ologin_error(
|
|
||||||
dex_login.public_title,
|
|
||||||
'A verified e-mail address is required for login. Please verify your ' +
|
|
||||||
'e-mail address in %s and try again.' % dex_login.public_title,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
return conduct_oauth_login(dex_login, dex_id, username, email_address,
|
|
||||||
metadata=metadata)
|
|
||||||
|
|
||||||
|
|
||||||
@oauthlogin.route('/dex/callback/attach', methods=['GET', 'POST'])
|
|
||||||
@route_show_if(features.DEX_LOGIN)
|
|
||||||
@require_session_login
|
|
||||||
@oauthlogin_csrf_protect
|
|
||||||
def dex_oauth_attach():
|
|
||||||
code = request.args.get('code')
|
|
||||||
token = dex_login.exchange_code_for_token(app.config, client, code, redirect_suffix='/attach',
|
|
||||||
client_auth=True, form_encode=True)
|
|
||||||
if token is None:
|
|
||||||
return render_ologin_error(dex_login.public_title)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = decode_user_jwt(token, dex_login)
|
|
||||||
except InvalidTokenError:
|
|
||||||
logger.exception('Exception when decoding returned JWT')
|
|
||||||
return render_ologin_error(
|
|
||||||
dex_login.public_title,
|
|
||||||
'Could not decode response. Please contact your system administrator about this error.',
|
|
||||||
)
|
|
||||||
|
|
||||||
user_obj = current_user.db_user()
|
|
||||||
dex_id = payload['sub']
|
|
||||||
metadata = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
model.user.attach_federated_login(user_obj, 'dex', dex_id, metadata=metadata)
|
|
||||||
except IntegrityError:
|
|
||||||
err = '%s account is already attached to a %s account' % (dex_login.public_title,
|
|
||||||
app.config['REGISTRY_TITLE_SHORT'])
|
|
||||||
return render_ologin_error(dex_login.public_title, err)
|
|
||||||
|
|
||||||
return redirect(url_for('web.user_view', path=user_obj.username, tab='external'))
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ EXTERNAL_JS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
EXTERNAL_CSS = [
|
EXTERNAL_CSS = [
|
||||||
'netdna.bootstrapcdn.com/font-awesome/4.6.0/css/font-awesome.css',
|
'netdna.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.css',
|
||||||
'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css',
|
'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css',
|
||||||
'fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700',
|
'fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700',
|
||||||
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css',
|
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css',
|
||||||
|
|
0
oauth/__init__.py
Normal file
0
oauth/__init__.py
Normal file
155
oauth/base.py
Normal file
155
oauth/base.py
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import logging
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from six import add_metaclass
|
||||||
|
|
||||||
|
from util import get_app_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class OAuthExchangeCodeException(Exception):
|
||||||
|
""" Exception raised if a code exchange fails. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OAuthGetUserInfoException(Exception):
|
||||||
|
""" Exception raised if a call to get user information fails. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@add_metaclass(ABCMeta)
|
||||||
|
class OAuthService(object):
|
||||||
|
""" A base class for defining an external service, exposed via OAuth. """
|
||||||
|
def __init__(self, config, key_name):
|
||||||
|
self.key_name = key_name
|
||||||
|
self.config = config.get(key_name) or {}
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def service_id(self):
|
||||||
|
""" The internal ID for this service. Must match the URL portion for the service, e.g. `github`
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def service_name(self):
|
||||||
|
""" The user-readable name for the service, e.g. `GitHub`"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def token_endpoint(self):
|
||||||
|
""" The endpoint at which the OAuth code can be exchanged for a token. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def user_endpoint(self):
|
||||||
|
""" The endpoint at which user information can be looked up. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def validate_client_id_and_secret(self, http_client, app_config):
|
||||||
|
""" Performs validation of the client ID and secret, raising an exception on failure. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def authorize_endpoint(self):
|
||||||
|
""" Endpoint for authorization. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
def requires_form_encoding(self):
|
||||||
|
""" Returns True if form encoding is necessary for the exchange_code_for_token call. """
|
||||||
|
return False
|
||||||
|
|
||||||
|
def client_id(self):
|
||||||
|
return self.config.get('CLIENT_ID')
|
||||||
|
|
||||||
|
def client_secret(self):
|
||||||
|
return self.config.get('CLIENT_SECRET')
|
||||||
|
|
||||||
|
def get_auth_url(self, app_config, redirect_suffix, csrf_token, scopes):
|
||||||
|
""" Retrieves the authorization URL for this login service. """
|
||||||
|
redirect_uri = '%s/oauth2/%s/callback%s' % (get_app_url(app_config), self.service_id(),
|
||||||
|
redirect_suffix)
|
||||||
|
params = {
|
||||||
|
'client_id': self.client_id(),
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'scope': ' '.join(scopes),
|
||||||
|
'state': csrf_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize_url = '%s%s' % (self.authorize_endpoint(), urllib.urlencode(params))
|
||||||
|
return authorize_url
|
||||||
|
|
||||||
|
def get_redirect_uri(self, app_config, redirect_suffix=''):
|
||||||
|
return '%s://%s/oauth2/%s/callback%s' % (app_config['PREFERRED_URL_SCHEME'],
|
||||||
|
app_config['SERVER_HOSTNAME'],
|
||||||
|
self.service_id(),
|
||||||
|
redirect_suffix)
|
||||||
|
|
||||||
|
def get_user_info(self, http_client, token):
|
||||||
|
token_param = {
|
||||||
|
'access_token': token,
|
||||||
|
'alt': 'json',
|
||||||
|
}
|
||||||
|
|
||||||
|
got_user = http_client.get(self.user_endpoint(), params=token_param)
|
||||||
|
if got_user.status_code // 100 != 2:
|
||||||
|
raise OAuthGetUserInfoException('Non-2XX response code for user_info call: %s' %
|
||||||
|
got_user.status_code)
|
||||||
|
|
||||||
|
user_info = got_user.json()
|
||||||
|
if user_info is None:
|
||||||
|
raise OAuthGetUserInfoException()
|
||||||
|
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
def exchange_code_for_token(self, app_config, http_client, code, form_encode=False,
|
||||||
|
redirect_suffix='', client_auth=False):
|
||||||
|
""" Exchanges an OAuth access code for the associated OAuth token. """
|
||||||
|
json_data = self.exchange_code(app_config, http_client, code, form_encode, redirect_suffix,
|
||||||
|
client_auth)
|
||||||
|
|
||||||
|
access_token = json_data.get('access_token', None)
|
||||||
|
if access_token is None:
|
||||||
|
logger.debug('Got successful get_access_token response with missing token: %s', json_data)
|
||||||
|
raise OAuthExchangeCodeException('Missing `access_token` in OAuth response')
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
def exchange_code(self, app_config, http_client, code, form_encode=False, redirect_suffix='',
|
||||||
|
client_auth=False):
|
||||||
|
""" Exchanges an OAuth access code for associated OAuth token and other data. """
|
||||||
|
payload = {
|
||||||
|
'code': code,
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'redirect_uri': self.get_redirect_uri(app_config, redirect_suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = None
|
||||||
|
if client_auth:
|
||||||
|
auth = (self.client_id(), self.client_secret())
|
||||||
|
else:
|
||||||
|
payload['client_id'] = self.client_id()
|
||||||
|
payload['client_secret'] = self.client_secret()
|
||||||
|
|
||||||
|
token_url = self.token_endpoint()
|
||||||
|
if form_encode:
|
||||||
|
get_access_token = http_client.post(token_url, data=payload, headers=headers, auth=auth)
|
||||||
|
else:
|
||||||
|
get_access_token = http_client.post(token_url, params=payload, headers=headers, auth=auth)
|
||||||
|
|
||||||
|
if get_access_token.status_code // 100 != 2:
|
||||||
|
logger.debug('Got get_access_token response %s', get_access_token.text)
|
||||||
|
raise OAuthExchangeCodeException('Got non-2XX response for code exchange: %s' %
|
||||||
|
get_access_token.status_code)
|
||||||
|
|
||||||
|
json_data = get_access_token.json()
|
||||||
|
if not json_data:
|
||||||
|
raise OAuthExchangeCodeException('Got non-JSON response for code exchange')
|
||||||
|
|
||||||
|
if 'error' in json_data:
|
||||||
|
raise OAuthExchangeCodeException(json_data.get('error_description', json_data['error']))
|
||||||
|
|
||||||
|
return json_data
|
96
oauth/login.py
Normal file
96
oauth/login.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from six import add_metaclass
|
||||||
|
|
||||||
|
import features
|
||||||
|
|
||||||
|
from oauth.base import OAuthService, OAuthExchangeCodeException, OAuthGetUserInfoException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class OAuthLoginException(Exception):
|
||||||
|
""" Exception raised if a login operation fails. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@add_metaclass(ABCMeta)
|
||||||
|
class OAuthLoginService(OAuthService):
|
||||||
|
""" A base class for defining an OAuth-compliant service that can be used for, amongst other
|
||||||
|
things, login and authentication. """
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def login_enabled(self):
|
||||||
|
""" Returns true if the login service is enabled. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_login_service_id(self, user_info):
|
||||||
|
""" Returns the internal ID for the given user under this login service. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_login_service_username(self, user_info):
|
||||||
|
""" Returns the username for the given user under this login service. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_verified_user_email(self, app_config, http_client, token, user_info):
|
||||||
|
""" Returns the verified email address for the given user, if any or None if none. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_icon(self):
|
||||||
|
""" Returns the icon to display for this login service. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_login_scopes(self):
|
||||||
|
""" Returns the list of scopes for login for this service. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
def service_verify_user_info_for_login(self, app_config, http_client, token, user_info):
|
||||||
|
""" Performs service-specific verification of user information for login. On failure, a service
|
||||||
|
should raise a OAuthLoginService.
|
||||||
|
"""
|
||||||
|
# By default, does nothing.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix):
|
||||||
|
""" Exchanges the given OAuth access code for user information on behalf of a user trying to
|
||||||
|
login or attach their account. Raises a OAuthLoginService exception on failure. Returns
|
||||||
|
a tuple consisting of (service_id, service_username, email)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Retrieve the token for the OAuth code.
|
||||||
|
try:
|
||||||
|
token = self.exchange_code_for_token(app_config, http_client, code,
|
||||||
|
redirect_suffix=redirect_suffix,
|
||||||
|
form_encode=self.requires_form_encoding())
|
||||||
|
except OAuthExchangeCodeException as oce:
|
||||||
|
raise OAuthLoginException(oce.message)
|
||||||
|
|
||||||
|
# Retrieve the user's information with the token.
|
||||||
|
try:
|
||||||
|
user_info = self.get_user_info(http_client, token)
|
||||||
|
except OAuthGetUserInfoException as oge:
|
||||||
|
raise OAuthLoginException(oge.message)
|
||||||
|
|
||||||
|
if user_info.get('id', None) is None:
|
||||||
|
logger.debug('Got user info response %s', user_info)
|
||||||
|
raise OAuthLoginException('Missing `id` column in returned user information')
|
||||||
|
|
||||||
|
# Perform any custom verification for this login service.
|
||||||
|
self.service_verify_user_info_for_login(app_config, http_client, token, user_info)
|
||||||
|
|
||||||
|
# Retrieve the user's email address (if necessary).
|
||||||
|
email_address = self.get_verified_user_email(app_config, http_client, token, user_info)
|
||||||
|
if features.MAILING and email_address is None:
|
||||||
|
raise OAuthLoginException('A verified email address is required to login with this service')
|
||||||
|
|
||||||
|
service_user_id = self.get_login_service_id(user_info)
|
||||||
|
service_username = self.get_login_service_username(user_info)
|
||||||
|
|
||||||
|
logger.debug('Completed successful exchange for service %s: %s, %s, %s',
|
||||||
|
self.service_id(), service_user_id, service_username, email_address)
|
||||||
|
return (service_user_id, service_username, email_address)
|
31
oauth/loginmanager.py
Normal file
31
oauth/loginmanager.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
from oauth.services.github import GithubOAuthService
|
||||||
|
from oauth.services.google import GoogleOAuthService
|
||||||
|
from oauth.oidc import OIDCLoginService
|
||||||
|
|
||||||
|
CUSTOM_LOGIN_SERVICES = {
|
||||||
|
'GITHUB_LOGIN_CONFIG': GithubOAuthService,
|
||||||
|
'GOOGLE_LOGIN_CONFIG': GoogleOAuthService,
|
||||||
|
}
|
||||||
|
|
||||||
|
class OAuthLoginManager(object):
|
||||||
|
""" Helper class which manages all registered OAuth login services. """
|
||||||
|
def __init__(self, config):
|
||||||
|
self.services = []
|
||||||
|
|
||||||
|
# Register the endpoints for each of the OAuth login services.
|
||||||
|
for key in config.keys():
|
||||||
|
# All keys which end in _LOGIN_CONFIG setup a login service.
|
||||||
|
if key.endswith('_LOGIN_CONFIG'):
|
||||||
|
if key in CUSTOM_LOGIN_SERVICES:
|
||||||
|
custom_service = CUSTOM_LOGIN_SERVICES[key](config, key)
|
||||||
|
if custom_service.login_enabled(config):
|
||||||
|
self.services.append(custom_service)
|
||||||
|
else:
|
||||||
|
self.services.append(OIDCLoginService(config, key))
|
||||||
|
|
||||||
|
def get_service(self, service_id):
|
||||||
|
for service in self.services:
|
||||||
|
if service.service_id() == service_id:
|
||||||
|
return service
|
||||||
|
|
||||||
|
return None
|
237
oauth/oidc.py
Normal file
237
oauth/oidc.py
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from cachetools import lru_cache
|
||||||
|
from cachetools.ttl import TTLCache
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
||||||
|
from jwkest.jwk import KEYS
|
||||||
|
|
||||||
|
from oauth.base import OAuthService, OAuthExchangeCodeException, OAuthGetUserInfoException
|
||||||
|
from oauth.login import OAuthLoginException
|
||||||
|
from util.security.jwtutil import decode, InvalidTokenError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
OIDC_WELLKNOWN = ".well-known/openid-configuration"
|
||||||
|
PUBLIC_KEY_CACHE_TTL = 3600 # 1 hour
|
||||||
|
ALLOWED_ALGORITHMS = ['RS256']
|
||||||
|
JWT_CLOCK_SKEW_SECONDS = 30
|
||||||
|
|
||||||
|
class DiscoveryFailureException(Exception):
|
||||||
|
""" Exception raised when OIDC discovery fails. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class PublicKeyLoadException(Exception):
|
||||||
|
""" Exception raised if loading the OIDC public key fails. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCLoginService(OAuthService):
|
||||||
|
""" Defines a generic service for all OpenID-connect compatible login services. """
|
||||||
|
def __init__(self, config, key_name):
|
||||||
|
super(OIDCLoginService, self).__init__(config, key_name)
|
||||||
|
|
||||||
|
self._public_key_cache = TTLCache(1, PUBLIC_KEY_CACHE_TTL, missing=self._load_public_key)
|
||||||
|
self._id = key_name[0:key_name.find('_')].lower()
|
||||||
|
self._http_client = config['HTTPCLIENT']
|
||||||
|
self._mailing = config.get('FEATURE_MAILING', False)
|
||||||
|
|
||||||
|
def service_id(self):
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def service_name(self):
|
||||||
|
return self.config.get('SERVICE_NAME', self.service_id())
|
||||||
|
|
||||||
|
def get_icon(self):
|
||||||
|
return self.config.get('SERVICE_ICON', 'fa-user-circle')
|
||||||
|
|
||||||
|
def get_login_scopes(self):
|
||||||
|
default_scopes = ['openid']
|
||||||
|
|
||||||
|
if self.user_endpoint() is not None:
|
||||||
|
default_scopes.append('profile')
|
||||||
|
|
||||||
|
if self._mailing:
|
||||||
|
default_scopes.append('email')
|
||||||
|
|
||||||
|
return self._oidc_config().get('scopes_supported', default_scopes)
|
||||||
|
|
||||||
|
def authorize_endpoint(self):
|
||||||
|
return self._oidc_config().get('authorization_endpoint', '') + '?response_type=code&'
|
||||||
|
|
||||||
|
def token_endpoint(self):
|
||||||
|
return self._oidc_config().get('token_endpoint')
|
||||||
|
|
||||||
|
def user_endpoint(self):
|
||||||
|
return self._oidc_config().get('userinfo_endpoint')
|
||||||
|
|
||||||
|
def validate_client_id_and_secret(self, http_client, app_config):
|
||||||
|
# TODO: find a way to verify client secret too.
|
||||||
|
check_auth_url = http_client.get(self.get_auth_url())
|
||||||
|
if check_auth_url.status_code // 100 != 2:
|
||||||
|
raise Exception('Got non-200 status code for authorization endpoint')
|
||||||
|
|
||||||
|
def requires_form_encoding(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_public_config(self):
|
||||||
|
return {
|
||||||
|
'CLIENT_ID': self.client_id(),
|
||||||
|
'OIDC': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix):
|
||||||
|
# Exchange the code for the access token and id_token
|
||||||
|
try:
|
||||||
|
json_data = self.exchange_code(app_config, http_client, code,
|
||||||
|
redirect_suffix=redirect_suffix,
|
||||||
|
form_encode=self.requires_form_encoding())
|
||||||
|
except OAuthExchangeCodeException as oce:
|
||||||
|
raise OAuthLoginException(oce.message)
|
||||||
|
|
||||||
|
# Make sure we received both.
|
||||||
|
access_token = json_data.get('access_token', None)
|
||||||
|
if access_token is None:
|
||||||
|
logger.debug('Missing access_token in response: %s', json_data)
|
||||||
|
raise OAuthLoginException('Missing `access_token` in OIDC response')
|
||||||
|
|
||||||
|
id_token = json_data.get('id_token', None)
|
||||||
|
if id_token is None:
|
||||||
|
logger.debug('Missing id_token in response: %s', json_data)
|
||||||
|
raise OAuthLoginException('Missing `id_token` in OIDC response')
|
||||||
|
|
||||||
|
# Decode the id_token.
|
||||||
|
try:
|
||||||
|
decoded_id_token = self._decode_user_jwt(id_token)
|
||||||
|
except InvalidTokenError as ite:
|
||||||
|
logger.exception('Got invalid token error on OIDC decode: %s', ite.message)
|
||||||
|
raise OAuthLoginException('Could not decode OIDC token')
|
||||||
|
except PublicKeyLoadException as pke:
|
||||||
|
logger.exception('Could not load public key during OIDC decode: %s', pke.message)
|
||||||
|
raise OAuthLoginException('Could find public OIDC key')
|
||||||
|
|
||||||
|
# Retrieve the user information.
|
||||||
|
try:
|
||||||
|
user_info = self.get_user_info(http_client, access_token)
|
||||||
|
except OAuthGetUserInfoException as oge:
|
||||||
|
raise OAuthLoginException(oge.message)
|
||||||
|
|
||||||
|
# Verify subs.
|
||||||
|
if user_info['sub'] != decoded_id_token['sub']:
|
||||||
|
raise OAuthLoginException('Mismatch in `sub` returned by OIDC user info endpoint')
|
||||||
|
|
||||||
|
# Check if we have a verified email address.
|
||||||
|
email_address = user_info.get('email') if user_info.get('email_verified') else None
|
||||||
|
if self._mailing:
|
||||||
|
if email_address is None:
|
||||||
|
raise OAuthLoginException('A verified email address is required to login with this service')
|
||||||
|
|
||||||
|
# Check for a preferred username.
|
||||||
|
lusername = user_info.get('preferred_username') or user_info.get('sub')
|
||||||
|
return decoded_id_token['sub'], lusername, email_address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _issuer(self):
|
||||||
|
return self.config.get('OIDC_ISSUER', self.config['OIDC_SERVER'])
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _oidc_config(self):
|
||||||
|
if self.config.get('OIDC_SERVER'):
|
||||||
|
return self._load_oidc_config_via_discovery(self.config.get('DEBUGGING', False))
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _load_oidc_config_via_discovery(self, is_debugging):
|
||||||
|
""" Attempts to load the OIDC config via the OIDC discovery mechanism. If is_debugging is True,
|
||||||
|
non-secure connections are alllowed. Raises an DiscoveryFailureException on failure.
|
||||||
|
"""
|
||||||
|
oidc_server = self.config['OIDC_SERVER']
|
||||||
|
if not oidc_server.startswith('https://') and not is_debugging:
|
||||||
|
raise DiscoveryFailureException('OIDC server must be accessed over SSL')
|
||||||
|
|
||||||
|
discovery_url = urlparse.urljoin(oidc_server, OIDC_WELLKNOWN)
|
||||||
|
discovery = self._http_client.get(discovery_url, timeout=5, verify=is_debugging is False)
|
||||||
|
if discovery.status_code // 100 != 2:
|
||||||
|
logger.debug('Got %s response for OIDC discovery: %s', discovery.status_code, discovery.text)
|
||||||
|
raise DiscoveryFailureException("Could not load OIDC discovery information")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(discovery.text)
|
||||||
|
except ValueError:
|
||||||
|
logger.exception('Could not parse OIDC discovery for url: %s', discovery_url)
|
||||||
|
raise DiscoveryFailureException("Could not parse OIDC discovery information")
|
||||||
|
|
||||||
|
def _decode_user_jwt(self, token):
|
||||||
|
""" Decodes the given JWT under the given provider and returns it. Raises an InvalidTokenError
|
||||||
|
exception on an invalid token or a PublicKeyLoadException if the public key could not be
|
||||||
|
loaded for decoding.
|
||||||
|
"""
|
||||||
|
# Find the key to use.
|
||||||
|
headers = jwt.get_unverified_header(token)
|
||||||
|
kid = headers.get('kid', None)
|
||||||
|
if kid is None:
|
||||||
|
raise InvalidTokenError('Missing `kid` header')
|
||||||
|
|
||||||
|
try:
|
||||||
|
return decode(token, self._get_public_key(kid), algorithms=ALLOWED_ALGORITHMS,
|
||||||
|
audience=self.client_id(),
|
||||||
|
issuer=self._issuer,
|
||||||
|
leeway=JWT_CLOCK_SKEW_SECONDS,
|
||||||
|
options=dict(require_nbf=False))
|
||||||
|
except InvalidTokenError:
|
||||||
|
# Public key may have expired. Try to retrieve an updated public key and use it to decode.
|
||||||
|
return decode(token, self._get_public_key(kid, force_refresh=True),
|
||||||
|
algorithms=ALLOWED_ALGORITHMS,
|
||||||
|
audience=self.client_id(),
|
||||||
|
issuer=self._issuer,
|
||||||
|
leeway=JWT_CLOCK_SKEW_SECONDS,
|
||||||
|
options=dict(require_nbf=False))
|
||||||
|
|
||||||
|
def _get_public_key(self, kid, force_refresh=False):
|
||||||
|
""" Retrieves the public key for this handler with the given kid. Raises a
|
||||||
|
PublicKeyLoadException on failure. """
|
||||||
|
|
||||||
|
# If force_refresh is true, we expire all the items in the cache by setting the time to
|
||||||
|
# the current time + the expiration TTL.
|
||||||
|
if force_refresh:
|
||||||
|
self._public_key_cache.expire(time=time.time() + PUBLIC_KEY_CACHE_TTL)
|
||||||
|
|
||||||
|
# Retrieve the public key from the cache. If the cache does not contain the public key,
|
||||||
|
# it will internally call _load_public_key to retrieve it and then save it.
|
||||||
|
return self._public_key_cache[kid]
|
||||||
|
|
||||||
|
def _load_public_key(self, kid):
|
||||||
|
""" Loads the public key for this handler from the OIDC service. Raises PublicKeyLoadException
|
||||||
|
on failure.
|
||||||
|
"""
|
||||||
|
keys_url = self._oidc_config()['jwks_uri']
|
||||||
|
|
||||||
|
# Load the keys.
|
||||||
|
try:
|
||||||
|
keys = KEYS()
|
||||||
|
keys.load_from_url(keys_url, verify=not self.config.get('DEBUGGING', False))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception('Exception loading public key')
|
||||||
|
raise PublicKeyLoadException(ex.message)
|
||||||
|
|
||||||
|
# Find the matching key.
|
||||||
|
keys_found = keys.by_kid(kid)
|
||||||
|
if len(keys_found) == 0:
|
||||||
|
raise PublicKeyLoadException('Public key %s not found' % kid)
|
||||||
|
|
||||||
|
rsa_keys = [key for key in keys_found if key.kty == 'RSA']
|
||||||
|
if len(rsa_keys) == 0:
|
||||||
|
raise PublicKeyLoadException('No RSA form of public key %s not found' % kid)
|
||||||
|
|
||||||
|
matching_key = rsa_keys[0]
|
||||||
|
matching_key.deserialize()
|
||||||
|
|
||||||
|
# Reload the key so that we can give a key *instance* to PyJWT to work around its weird parsing
|
||||||
|
# issues.
|
||||||
|
return load_der_public_key(matching_key.key.exportKey('DER'), backend=default_backend())
|
0
oauth/services/__init__.py
Normal file
0
oauth/services/__init__.py
Normal file
179
oauth/services/github.py
Normal file
179
oauth/services/github.py
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from oauth.login import OAuthLoginService, OAuthLoginException
|
||||||
|
from util import slash_join
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class GithubOAuthService(OAuthLoginService):
|
||||||
|
def __init__(self, config, key_name):
|
||||||
|
super(GithubOAuthService, self).__init__(config, key_name)
|
||||||
|
|
||||||
|
def login_enabled(self, config):
|
||||||
|
return config.get('FEATURE_GITHUB_LOGIN', False)
|
||||||
|
|
||||||
|
def service_id(self):
|
||||||
|
return 'github'
|
||||||
|
|
||||||
|
def service_name(self):
|
||||||
|
if self.is_enterprise():
|
||||||
|
return 'GitHub Enterprise'
|
||||||
|
|
||||||
|
return 'GitHub'
|
||||||
|
|
||||||
|
def get_icon(self):
|
||||||
|
return 'fa-github'
|
||||||
|
|
||||||
|
def get_login_scopes(self):
|
||||||
|
if self.config.get('ORG_RESTRICT'):
|
||||||
|
return ['user:email', 'read:org']
|
||||||
|
|
||||||
|
return ['user:email']
|
||||||
|
|
||||||
|
def allowed_organizations(self):
|
||||||
|
if not self.config.get('ORG_RESTRICT', False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
allowed = self.config.get('ALLOWED_ORGANIZATIONS', None)
|
||||||
|
if allowed is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return [org.lower() for org in allowed]
|
||||||
|
|
||||||
|
def get_public_url(self, suffix):
|
||||||
|
return slash_join(self._endpoint(), suffix)
|
||||||
|
|
||||||
|
def _endpoint(self):
|
||||||
|
return self.config.get('GITHUB_ENDPOINT', 'https://github.com')
|
||||||
|
|
||||||
|
def is_enterprise(self):
|
||||||
|
return self._api_endpoint().find('.github.com') < 0
|
||||||
|
|
||||||
|
def authorize_endpoint(self):
|
||||||
|
return slash_join(self._endpoint(), '/login/oauth/authorize') + '?'
|
||||||
|
|
||||||
|
def token_endpoint(self):
|
||||||
|
return slash_join(self._endpoint(), '/login/oauth/access_token')
|
||||||
|
|
||||||
|
def _api_endpoint(self):
|
||||||
|
return self.config.get('API_ENDPOINT', slash_join(self._endpoint(), '/api/v3/'))
|
||||||
|
|
||||||
|
def api_endpoint(self):
|
||||||
|
endpoint = self._api_endpoint()
|
||||||
|
if endpoint.endswith('/'):
|
||||||
|
return endpoint[0:-1]
|
||||||
|
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
def user_endpoint(self):
|
||||||
|
return slash_join(self._api_endpoint(), 'user')
|
||||||
|
|
||||||
|
def email_endpoint(self):
|
||||||
|
return slash_join(self._api_endpoint(), 'user/emails')
|
||||||
|
|
||||||
|
def orgs_endpoint(self):
|
||||||
|
return slash_join(self._api_endpoint(), 'user/orgs')
|
||||||
|
|
||||||
|
def validate_client_id_and_secret(self, http_client, app_config):
|
||||||
|
# First: Verify that the github endpoint is actually Github by checking for the
|
||||||
|
# X-GitHub-Request-Id here.
|
||||||
|
api_endpoint = self._api_endpoint()
|
||||||
|
result = http_client.get(api_endpoint, auth=(self.client_id(), self.client_secret()), timeout=5)
|
||||||
|
if not 'X-GitHub-Request-Id' in result.headers:
|
||||||
|
raise Exception('Endpoint is not a Github (Enterprise) installation')
|
||||||
|
|
||||||
|
# Next: Verify the client ID and secret.
|
||||||
|
# Note: The following code is a hack until such time as Github officially adds an API endpoint
|
||||||
|
# for verifying a {client_id, client_secret} pair. That being said, this hack was given to us
|
||||||
|
# *by a Github Engineer*, so I think it is okay for the time being :)
|
||||||
|
#
|
||||||
|
# TODO(jschorr): Replace with the real API call once added.
|
||||||
|
#
|
||||||
|
# Hitting the endpoint applications/{client_id}/tokens/foo will result in the following
|
||||||
|
# behavior IF the client_id is given as the HTTP username and the client_secret as the HTTP
|
||||||
|
# password:
|
||||||
|
# - If the {client_id, client_secret} pair is invalid in some way, we get a 401 error.
|
||||||
|
# - If the pair is valid, then we get a 404 because the 'foo' token does not exists.
|
||||||
|
validate_endpoint = slash_join(api_endpoint, 'applications/%s/tokens/foo' % self.client_id())
|
||||||
|
result = http_client.get(validate_endpoint, auth=(self.client_id(), self.client_secret()),
|
||||||
|
timeout=5)
|
||||||
|
return result.status_code == 404
|
||||||
|
|
||||||
|
def validate_organization(self, organization_id, http_client):
|
||||||
|
org_endpoint = slash_join(self._api_endpoint(), 'orgs/%s' % organization_id.lower())
|
||||||
|
|
||||||
|
result = http_client.get(org_endpoint,
|
||||||
|
headers={'Accept': 'application/vnd.github.moondragon+json'},
|
||||||
|
timeout=5)
|
||||||
|
|
||||||
|
return result.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_config(self):
|
||||||
|
return {
|
||||||
|
'CLIENT_ID': self.client_id(),
|
||||||
|
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
||||||
|
'GITHUB_ENDPOINT': self._endpoint(),
|
||||||
|
'ORG_RESTRICT': self.config.get('ORG_RESTRICT', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_login_service_id(self, user_info):
|
||||||
|
return user_info['id']
|
||||||
|
|
||||||
|
def get_login_service_username(self, user_info):
|
||||||
|
return user_info['login']
|
||||||
|
|
||||||
|
def get_verified_user_email(self, app_config, http_client, token, user_info):
|
||||||
|
v3_media_type = {
|
||||||
|
'Accept': 'application/vnd.github.v3'
|
||||||
|
}
|
||||||
|
|
||||||
|
token_param = {
|
||||||
|
'access_token': token,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the e-mail address for the user: we will accept any email, but we prefer the primary
|
||||||
|
get_email = http_client.get(self.email_endpoint(), params=token_param, headers=v3_media_type)
|
||||||
|
if get_email.status_code // 100 != 2:
|
||||||
|
raise OAuthLoginException('Got non-2XX status code for emails endpoint: %s' %
|
||||||
|
get_email.status_code)
|
||||||
|
|
||||||
|
verified_emails = [email for email in get_email.json() if email['verified']]
|
||||||
|
primary_emails = [email for email in get_email.json() if email['primary']]
|
||||||
|
|
||||||
|
# Special case: We don't care about whether an e-mail address is "verified" under GHE.
|
||||||
|
if self.is_enterprise() and not verified_emails:
|
||||||
|
verified_emails = primary_emails
|
||||||
|
|
||||||
|
allowed_emails = (primary_emails or verified_emails or [])
|
||||||
|
return allowed_emails[0]['email'] if len(allowed_emails) > 0 else None
|
||||||
|
|
||||||
|
def service_verify_user_info_for_login(self, app_config, http_client, token, user_info):
|
||||||
|
# Retrieve the user's orgnizations (if organization filtering is turned on)
|
||||||
|
if self.allowed_organizations() is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
moondragon_media_type = {
|
||||||
|
'Accept': 'application/vnd.github.moondragon+json'
|
||||||
|
}
|
||||||
|
|
||||||
|
token_param = {
|
||||||
|
'access_token': token,
|
||||||
|
}
|
||||||
|
|
||||||
|
get_orgs = http_client.get(self.orgs_endpoint(), params=token_param,
|
||||||
|
headers=moondragon_media_type)
|
||||||
|
|
||||||
|
if get_orgs.status_code // 100 != 2:
|
||||||
|
logger.debug('get_orgs response: %s', get_orgs.json())
|
||||||
|
raise OAuthLoginException('Got non-2XX response for org lookup: %s' %
|
||||||
|
get_orgs.status_code)
|
||||||
|
|
||||||
|
organizations = set([org.get('login').lower() for org in get_orgs.json()])
|
||||||
|
matching_organizations = organizations & set(self.allowed_organizations())
|
||||||
|
if not matching_organizations:
|
||||||
|
logger.debug('Found organizations %s, but expected one of %s', organizations,
|
||||||
|
self.allowed_organizations())
|
||||||
|
err = """You are not a member of an allowed GitHub organization.
|
||||||
|
Please contact your system administrator if you believe this is in error."""
|
||||||
|
raise OAuthLoginException(err)
|
60
oauth/services/gitlab.py
Normal file
60
oauth/services/gitlab.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
from oauth.base import OAuthService
|
||||||
|
from util import slash_join
|
||||||
|
|
||||||
|
class GitLabOAuthService(OAuthService):
|
||||||
|
def __init__(self, config, key_name):
|
||||||
|
super(GitLabOAuthService, self).__init__(config, key_name)
|
||||||
|
|
||||||
|
def service_id(self):
|
||||||
|
return 'gitlab'
|
||||||
|
|
||||||
|
def service_name(self):
|
||||||
|
return 'GitLab'
|
||||||
|
|
||||||
|
def _endpoint(self):
|
||||||
|
return self.config.get('GITLAB_ENDPOINT', 'https://gitlab.com')
|
||||||
|
|
||||||
|
def user_endpoint(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def api_endpoint(self):
|
||||||
|
return self._endpoint()
|
||||||
|
|
||||||
|
def get_public_url(self, suffix):
|
||||||
|
return slash_join(self._endpoint(), suffix)
|
||||||
|
|
||||||
|
def authorize_endpoint(self):
|
||||||
|
return slash_join(self._endpoint(), '/oauth/authorize')
|
||||||
|
|
||||||
|
def token_endpoint(self):
|
||||||
|
return slash_join(self._endpoint(), '/oauth/token')
|
||||||
|
|
||||||
|
def validate_client_id_and_secret(self, http_client, app_config):
|
||||||
|
# We validate the client ID and secret by hitting the OAuth token exchange endpoint with
|
||||||
|
# the real client ID and secret, but a fake auth code to exchange. Gitlab's implementation will
|
||||||
|
# return `invalid_client` as the `error` if the client ID or secret is invalid; otherwise, it
|
||||||
|
# will return another error.
|
||||||
|
url = self.token_endpoint()
|
||||||
|
redirect_uri = self.get_redirect_uri(app_config, redirect_suffix='trigger')
|
||||||
|
data = {
|
||||||
|
'code': 'fakecode',
|
||||||
|
'client_id': self.client_id(),
|
||||||
|
'client_secret': self.client_secret(),
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'redirect_uri': redirect_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
# We validate by checking the error code we receive from this call.
|
||||||
|
result = http_client.post(url, data=data, timeout=5)
|
||||||
|
value = result.json()
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return value.get('error', '') != 'invalid_client'
|
||||||
|
|
||||||
|
def get_public_config(self):
|
||||||
|
return {
|
||||||
|
'CLIENT_ID': self.client_id(),
|
||||||
|
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
||||||
|
'GITLAB_ENDPOINT': self._endpoint(),
|
||||||
|
}
|
79
oauth/services/google.py
Normal file
79
oauth/services/google.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
from oauth.login import OAuthLoginService
|
||||||
|
|
||||||
|
def _get_email_username(email_address):
|
||||||
|
username = email_address
|
||||||
|
at = username.find('@')
|
||||||
|
if at > 0:
|
||||||
|
username = username[0:at]
|
||||||
|
|
||||||
|
return username
|
||||||
|
|
||||||
|
class GoogleOAuthService(OAuthLoginService):
|
||||||
|
def __init__(self, config, key_name):
|
||||||
|
super(GoogleOAuthService, self).__init__(config, key_name)
|
||||||
|
|
||||||
|
def login_enabled(self, config):
|
||||||
|
return config.get('FEATURE_GOOGLE_LOGIN', False)
|
||||||
|
|
||||||
|
def service_id(self):
|
||||||
|
return 'google'
|
||||||
|
|
||||||
|
def service_name(self):
|
||||||
|
return 'Google'
|
||||||
|
|
||||||
|
def get_icon(self):
|
||||||
|
return 'fa-google'
|
||||||
|
|
||||||
|
def get_login_scopes(self):
|
||||||
|
return ['openid', 'email']
|
||||||
|
|
||||||
|
def authorize_endpoint(self):
|
||||||
|
return 'https://accounts.google.com/o/oauth2/auth?response_type=code&'
|
||||||
|
|
||||||
|
def token_endpoint(self):
|
||||||
|
return 'https://accounts.google.com/o/oauth2/token'
|
||||||
|
|
||||||
|
def user_endpoint(self):
|
||||||
|
return 'https://www.googleapis.com/oauth2/v1/userinfo'
|
||||||
|
|
||||||
|
def requires_form_encoding(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_client_id_and_secret(self, http_client, app_config):
|
||||||
|
# To verify the Google client ID and secret, we hit the
|
||||||
|
# https://www.googleapis.com/oauth2/v3/token endpoint with an invalid request. If the client
|
||||||
|
# ID or secret are invalid, we get returned a 403 Unauthorized. Otherwise, we get returned
|
||||||
|
# another response code.
|
||||||
|
url = 'https://www.googleapis.com/oauth2/v3/token'
|
||||||
|
data = {
|
||||||
|
'code': 'fakecode',
|
||||||
|
'client_id': self.client_id(),
|
||||||
|
'client_secret': self.client_secret(),
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'redirect_uri': 'http://example.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
result = http_client.post(url, data=data, timeout=5)
|
||||||
|
return result.status_code != 401
|
||||||
|
|
||||||
|
def get_public_config(self):
|
||||||
|
return {
|
||||||
|
'CLIENT_ID': self.client_id(),
|
||||||
|
'AUTHORIZE_ENDPOINT': self.authorize_endpoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_login_service_id(self, user_info):
|
||||||
|
return user_info['id']
|
||||||
|
|
||||||
|
def get_login_service_username(self, user_info):
|
||||||
|
return _get_email_username(user_info['email'])
|
||||||
|
|
||||||
|
def get_verified_user_email(self, app_config, http_client, token, user_info):
|
||||||
|
if not user_info.get('verified_email', False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user_info['email']
|
||||||
|
|
||||||
|
def service_verify_user_info_for_login(self, app_config, http_client, token, user_info):
|
||||||
|
# Nothing to do.
|
||||||
|
pass
|
62
oauth/test/test_loginmanager.py
Normal file
62
oauth/test/test_loginmanager.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
from oauth.loginmanager import OAuthLoginManager
|
||||||
|
from oauth.services.github import GithubOAuthService
|
||||||
|
from oauth.services.google import GoogleOAuthService
|
||||||
|
from oauth.oidc import OIDCLoginService
|
||||||
|
|
||||||
|
def test_login_manager_github():
|
||||||
|
config = {
|
||||||
|
'FEATURE_GITHUB_LOGIN': True,
|
||||||
|
'GITHUB_LOGIN_CONFIG': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
loginmanager = OAuthLoginManager(config)
|
||||||
|
assert len(loginmanager.services) == 1
|
||||||
|
assert isinstance(loginmanager.services[0], GithubOAuthService)
|
||||||
|
|
||||||
|
def test_github_disabled():
|
||||||
|
config = {
|
||||||
|
'GITHUB_LOGIN_CONFIG': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
loginmanager = OAuthLoginManager(config)
|
||||||
|
assert len(loginmanager.services) == 0
|
||||||
|
|
||||||
|
def test_login_manager_google():
|
||||||
|
config = {
|
||||||
|
'FEATURE_GOOGLE_LOGIN': True,
|
||||||
|
'GOOGLE_LOGIN_CONFIG': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
loginmanager = OAuthLoginManager(config)
|
||||||
|
assert len(loginmanager.services) == 1
|
||||||
|
assert isinstance(loginmanager.services[0], GoogleOAuthService)
|
||||||
|
|
||||||
|
def test_google_disabled():
|
||||||
|
config = {
|
||||||
|
'GOOGLE_LOGIN_CONFIG': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
loginmanager = OAuthLoginManager(config)
|
||||||
|
assert len(loginmanager.services) == 0
|
||||||
|
|
||||||
|
def test_oidc():
|
||||||
|
config = {
|
||||||
|
'SOMECOOL_LOGIN_CONFIG': {},
|
||||||
|
'HTTPCLIENT': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
loginmanager = OAuthLoginManager(config)
|
||||||
|
assert len(loginmanager.services) == 1
|
||||||
|
assert isinstance(loginmanager.services[0], OIDCLoginService)
|
||||||
|
|
||||||
|
def test_multiple_oidc():
|
||||||
|
config = {
|
||||||
|
'SOMECOOL_LOGIN_CONFIG': {},
|
||||||
|
'ANOTHER_LOGIN_CONFIG': {},
|
||||||
|
'HTTPCLIENT': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
loginmanager = OAuthLoginManager(config)
|
||||||
|
assert len(loginmanager.services) == 2
|
||||||
|
assert isinstance(loginmanager.services[0], OIDCLoginService)
|
||||||
|
assert isinstance(loginmanager.services[1], OIDCLoginService)
|
273
oauth/test/test_oidc.py
Normal file
273
oauth/test/test_oidc.py
Normal file
|
@ -0,0 +1,273 @@
|
||||||
|
# pylint: disable=redefined-outer-name, unused-argument, invalid-name, missing-docstring, too-many-arguments
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from httmock import urlmatch, HTTMock
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from jwkest.jwk import RSAKey
|
||||||
|
|
||||||
|
from oauth.oidc import OIDCLoginService, OAuthLoginException
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def http_client():
|
||||||
|
sess = requests.Session()
|
||||||
|
adapter = requests.adapters.HTTPAdapter(pool_connections=100,
|
||||||
|
pool_maxsize=100)
|
||||||
|
sess.mount('http://', adapter)
|
||||||
|
sess.mount('https://', adapter)
|
||||||
|
return sess
|
||||||
|
|
||||||
|
@pytest.fixture(params=[True, False])
|
||||||
|
def app_config(http_client, request):
|
||||||
|
return {
|
||||||
|
'PREFERRED_URL_SCHEME': 'http',
|
||||||
|
'SERVER_HOSTNAME': 'localhost',
|
||||||
|
'FEATURE_MAILING': request.param,
|
||||||
|
|
||||||
|
'SOMEOIDC_TEST_SERVICE': {
|
||||||
|
'CLIENT_ID': 'foo',
|
||||||
|
'CLIENT_SECRET': 'bar',
|
||||||
|
'SERVICE_NAME': 'Some Cool Service',
|
||||||
|
'SERVICE_ICON': 'http://some/icon',
|
||||||
|
'OIDC_SERVER': 'http://fakeoidc',
|
||||||
|
'DEBUGGING': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'HTTPCLIENT': http_client,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def oidc_service(app_config):
|
||||||
|
return OIDCLoginService(app_config, 'SOMEOIDC_TEST_SERVICE')
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def discovery_content():
|
||||||
|
return {
|
||||||
|
'scopes_supported': ['profile'],
|
||||||
|
'authorization_endpoint': 'http://fakeoidc/authorize',
|
||||||
|
'token_endpoint': 'http://fakeoidc/token',
|
||||||
|
'userinfo_endpoint': 'http://fakeoidc/userinfo',
|
||||||
|
'jwks_uri': 'http://fakeoidc/jwks',
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def discovery_handler(discovery_content):
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'.+openid.+')
|
||||||
|
def handler(_, __):
|
||||||
|
return json.dumps(discovery_content)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module") # Slow to generate, only do it once.
|
||||||
|
def signing_key():
|
||||||
|
private_key = RSA.generate(2048)
|
||||||
|
jwk = RSAKey(key=private_key.publickey()).serialize()
|
||||||
|
return {
|
||||||
|
'id': 'somekey',
|
||||||
|
'private_key': private_key.exportKey('PEM'),
|
||||||
|
'jwk': jwk,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def id_token(oidc_service, signing_key, app_config):
|
||||||
|
token_data = {
|
||||||
|
'iss': oidc_service.config['OIDC_SERVER'],
|
||||||
|
'aud': oidc_service.client_id(),
|
||||||
|
'nbf': int(time.time()),
|
||||||
|
'iat': int(time.time()),
|
||||||
|
'exp': int(time.time() + 600),
|
||||||
|
'sub': 'cooluser',
|
||||||
|
}
|
||||||
|
|
||||||
|
token_headers = {
|
||||||
|
'kid': signing_key['id'],
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(token_data, signing_key['private_key'], 'RS256', headers=token_headers)
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def valid_code():
|
||||||
|
return 'validcode'
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def token_handler(oidc_service, id_token, valid_code):
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'/token')
|
||||||
|
def handler(_, request):
|
||||||
|
params = urlparse.parse_qs(request.body)
|
||||||
|
if params.get('redirect_uri')[0] != 'http://localhost/oauth2/someoidc/callback':
|
||||||
|
return {'status_code': 400, 'content': 'Invalid redirect URI'}
|
||||||
|
|
||||||
|
if params.get('client_id')[0] != oidc_service.client_id():
|
||||||
|
return {'status_code': 401, 'content': 'Invalid client id'}
|
||||||
|
|
||||||
|
if params.get('client_secret')[0] != oidc_service.client_secret():
|
||||||
|
return {'status_code': 401, 'content': 'Invalid client secret'}
|
||||||
|
|
||||||
|
if params.get('code')[0] != valid_code:
|
||||||
|
return {'status_code': 401, 'content': 'Invalid code'}
|
||||||
|
|
||||||
|
if params.get('grant_type')[0] != 'authorization_code':
|
||||||
|
return {'status_code': 400, 'content': 'Invalid authorization type'}
|
||||||
|
|
||||||
|
content = {
|
||||||
|
'access_token': 'sometoken',
|
||||||
|
'id_token': id_token,
|
||||||
|
}
|
||||||
|
return {'status_code': 200, 'content': json.dumps(content)}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def jwks_handler(signing_key):
|
||||||
|
def jwk_with_kid(kid, jwk):
|
||||||
|
jwk = jwk.copy()
|
||||||
|
jwk.update({'kid': kid})
|
||||||
|
return jwk
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'/jwks')
|
||||||
|
def handler(_, __):
|
||||||
|
content = {'keys': [jwk_with_kid(signing_key['id'], signing_key['jwk'])]}
|
||||||
|
return {'status_code': 200, 'content': json.dumps(content)}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def emptykeys_jwks_handler():
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'/jwks')
|
||||||
|
def handler(_, __):
|
||||||
|
content = {'keys': []}
|
||||||
|
return {'status_code': 200, 'content': json.dumps(content)}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
@pytest.fixture(params=["someusername", None])
|
||||||
|
def preferred_username(request):
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def userinfo_handler(oidc_service, preferred_username):
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'/userinfo')
|
||||||
|
def handler(_, __):
|
||||||
|
content = {
|
||||||
|
'sub': 'cooluser',
|
||||||
|
'preferred_username':preferred_username,
|
||||||
|
'email': 'foo@example.com',
|
||||||
|
'email_verified': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'status_code': 200, 'content': json.dumps(content)}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def invalidsub_userinfo_handler(oidc_service):
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'/userinfo')
|
||||||
|
def handler(_, __):
|
||||||
|
content = {
|
||||||
|
'sub': 'invalidsub',
|
||||||
|
'preferred_username': 'someusername',
|
||||||
|
'email': 'foo@example.com',
|
||||||
|
'email_verified': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'status_code': 200, 'content': json.dumps(content)}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def missingemail_userinfo_handler(oidc_service, preferred_username):
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path=r'/userinfo')
|
||||||
|
def handler(_, __):
|
||||||
|
content = {
|
||||||
|
'sub': 'cooluser',
|
||||||
|
'preferred_username': preferred_username,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'status_code': 200, 'content': json.dumps(content)}
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def test_basic_config(oidc_service):
|
||||||
|
assert oidc_service.service_id() == 'someoidc'
|
||||||
|
assert oidc_service.service_name() == 'Some Cool Service'
|
||||||
|
assert oidc_service.get_icon() == 'http://some/icon'
|
||||||
|
|
||||||
|
def test_discovery(oidc_service, http_client, discovery_handler):
|
||||||
|
with HTTMock(discovery_handler):
|
||||||
|
assert oidc_service.authorize_endpoint() == 'http://fakeoidc/authorize?response_type=code&'
|
||||||
|
assert oidc_service.token_endpoint() == 'http://fakeoidc/token'
|
||||||
|
assert oidc_service.user_endpoint() == 'http://fakeoidc/userinfo'
|
||||||
|
assert oidc_service.get_login_scopes() == ['profile']
|
||||||
|
|
||||||
|
def test_public_config(oidc_service, discovery_handler):
|
||||||
|
with HTTMock(discovery_handler):
|
||||||
|
assert oidc_service.get_public_config()['OIDC']
|
||||||
|
assert oidc_service.get_public_config()['CLIENT_ID'] == 'foo'
|
||||||
|
|
||||||
|
assert 'CLIENT_SECRET' not in oidc_service.get_public_config()
|
||||||
|
assert 'bar' not in oidc_service.get_public_config().values()
|
||||||
|
|
||||||
|
def test_exchange_code_invalidcode(oidc_service, discovery_handler, app_config, http_client,
|
||||||
|
token_handler):
|
||||||
|
with HTTMock(token_handler, discovery_handler):
|
||||||
|
with pytest.raises(OAuthLoginException):
|
||||||
|
oidc_service.exchange_code_for_login(app_config, http_client, 'testcode', '')
|
||||||
|
|
||||||
|
def test_exchange_code_validcode(oidc_service, discovery_handler, app_config, http_client,
|
||||||
|
token_handler, userinfo_handler, jwks_handler, valid_code,
|
||||||
|
preferred_username):
|
||||||
|
with HTTMock(jwks_handler, token_handler, userinfo_handler, discovery_handler):
|
||||||
|
lid, lusername, lemail = oidc_service.exchange_code_for_login(app_config, http_client,
|
||||||
|
valid_code, '')
|
||||||
|
|
||||||
|
assert lid == 'cooluser'
|
||||||
|
assert lemail == 'foo@example.com'
|
||||||
|
|
||||||
|
if preferred_username is not None:
|
||||||
|
assert lusername == preferred_username
|
||||||
|
else:
|
||||||
|
assert lusername == lid
|
||||||
|
|
||||||
|
def test_exchange_code_missingemail(oidc_service, discovery_handler, app_config, http_client,
|
||||||
|
token_handler, missingemail_userinfo_handler, jwks_handler,
|
||||||
|
valid_code, preferred_username):
|
||||||
|
with HTTMock(jwks_handler, token_handler, missingemail_userinfo_handler, discovery_handler):
|
||||||
|
if app_config['FEATURE_MAILING']:
|
||||||
|
# Should fail because there is no valid email address.
|
||||||
|
with pytest.raises(OAuthLoginException):
|
||||||
|
oidc_service.exchange_code_for_login(app_config, http_client, valid_code, '')
|
||||||
|
else:
|
||||||
|
# Should succeed because, while there is no valid email address, it isn't necessary with
|
||||||
|
# mailing disabled.
|
||||||
|
lid, lusername, lemail = oidc_service.exchange_code_for_login(app_config, http_client,
|
||||||
|
valid_code, '')
|
||||||
|
|
||||||
|
assert lid == 'cooluser'
|
||||||
|
assert lemail is None
|
||||||
|
|
||||||
|
if preferred_username is not None:
|
||||||
|
assert lusername == preferred_username
|
||||||
|
else:
|
||||||
|
assert lusername == lid
|
||||||
|
|
||||||
|
def test_exchange_code_invalidsub(oidc_service, discovery_handler, app_config, http_client,
|
||||||
|
token_handler, invalidsub_userinfo_handler, jwks_handler,
|
||||||
|
valid_code):
|
||||||
|
with HTTMock(jwks_handler, token_handler, invalidsub_userinfo_handler, discovery_handler):
|
||||||
|
# Should fail because the sub of the user info doesn't match that returned by the id_token.
|
||||||
|
with pytest.raises(OAuthLoginException):
|
||||||
|
oidc_service.exchange_code_for_login(app_config, http_client, valid_code, '')
|
||||||
|
|
||||||
|
def test_exchange_code_missingkey(oidc_service, discovery_handler, app_config, http_client,
|
||||||
|
token_handler, userinfo_handler, emptykeys_jwks_handler,
|
||||||
|
valid_code):
|
||||||
|
with HTTMock(emptykeys_jwks_handler, token_handler, userinfo_handler, discovery_handler):
|
||||||
|
# Should fail because the key is missing.
|
||||||
|
with pytest.raises(OAuthLoginException):
|
||||||
|
oidc_service.exchange_code_for_login(app_config, http_client, valid_code, '')
|
|
@ -1,14 +1,13 @@
|
||||||
<span class="external-login-button-element">
|
<span class="external-login-button-element">
|
||||||
<a ng-class="isLink ? '' : 'btn btn-primary btn-block'"
|
<a ng-class="isLink ? '' : 'btn btn-primary btn-block'"
|
||||||
ng-if="providerInfo.enabled" ng-click="startSignin()" style="margin-bottom: 10px"
|
ng-click="startSignin()" style="margin-bottom: 10px"
|
||||||
ng-disabled="signingIn">
|
ng-disabled="signingIn">
|
||||||
<img ng-src="{{ providerInfo.icon().url }}" ng-if="providerInfo.icon().url">
|
<span class="icon-image-view" value="{{ provider.icon }}"></span>
|
||||||
<i class="fa" ng-class="providerInfo.icon().icon" ng-if="providerInfo.icon().icon"></i>
|
|
||||||
<span class="login-text" ng-if="action != 'attach'" style="vertical-align: middle">
|
<span class="login-text" ng-if="action != 'attach'" style="vertical-align: middle">
|
||||||
<span class="prefix">Sign in with </span><span class="suffix">{{ providerInfo.title() }}</span>
|
<span class="prefix">Sign in with </span><span class="suffix">{{ provider.title }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="login-text" ng-if="action == 'attach'" style="vertical-align: middle">
|
<span class="login-text" ng-if="action == 'attach'" style="vertical-align: middle">
|
||||||
Attach to {{ providerInfo.title() }}
|
Attach to {{ provider.title }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -14,27 +14,24 @@
|
||||||
|
|
||||||
<tr class="external-auth-provider" ng-repeat="provider in EXTERNAL_LOGINS">
|
<tr class="external-auth-provider" ng-repeat="provider in EXTERNAL_LOGINS">
|
||||||
<td class="external-auth-provider-title">
|
<td class="external-auth-provider-title">
|
||||||
<img ng-src="{{ provider.icon().url }}" ng-if="provider.icon().url">
|
<span class="icon-image-view" value="{{ provider.icon }}"></span>
|
||||||
<i class="fa" ng-class="provider.icon().icon" ng-if="provider.icon().icon"></i>
|
{{ provider.title }}
|
||||||
{{ provider.title() }}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span ng-if="externalLoginInfo[provider.id]">
|
<span ng-if="externalLoginInfo[provider.id]">
|
||||||
Attached to {{ provider.title() }} account
|
Attached to {{ provider.title }} account
|
||||||
<b ng-if="provider.hasUserInfo">
|
<b ng-if="externalLoginInfo[provider.id].metadata.service_username">
|
||||||
<a ng-href="{{ provider.getUserInfo(externalLoginInfo[provider.id]).endpoint }}" ng-safenewtab>
|
{{ externalLoginInfo[provider.id].metadata.service_username }}
|
||||||
{{ provider.getUserInfo(externalLoginInfo[provider.id]).username }}
|
|
||||||
</a>
|
|
||||||
</b>
|
</b>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="empty" ng-if="!externalLoginInfo[provider.id]">
|
<span class="empty" ng-if="!externalLoginInfo[provider.id]">
|
||||||
Not attached to {{ provider.title() }}
|
Not attached to {{ provider.title }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<span class="external-login-button" provider="{{ provider.id }}" action="attach" is-link="true"
|
<span class="external-login-button" provider="provider" action="attach" is-link="true"
|
||||||
ng-if="!externalLoginInfo[provider.id]"></span>
|
ng-if="!externalLoginInfo[provider.id]"></span>
|
||||||
<a ng-if="externalLoginInfo[provider.id] && Features.DIRECT_LOGIN"
|
<a ng-if="externalLoginInfo[provider.id] && Features.DIRECT_LOGIN"
|
||||||
ng-click="detachExternalLogin(provider.id)">Detach Account</a>
|
ng-click="detachExternalLogin(provider.id)">Detach Account</a>
|
||||||
|
|
4
static/directives/icon-image-view.html
Normal file
4
static/directives/icon-image-view.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<span class="icon-image-view-element">
|
||||||
|
<img ng-src="{{ value }}" ng-if="value.indexOf('http') == 0">
|
||||||
|
<i class="fa" ng-class="value" ng-if="value.indexOf('http') < 0"></i>
|
||||||
|
</span>
|
|
@ -6,7 +6,7 @@
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="external-logins" quay-show="EXTERNAL_LOGINS.length" ng-class="EXTERNAL_LOGINS.length > 2 ? 'smaller': 'larger'">
|
<div class="external-logins" quay-show="EXTERNAL_LOGINS.length" ng-class="EXTERNAL_LOGINS.length > 2 ? 'smaller': 'larger'">
|
||||||
<div class="external-login-button" provider="{{ provider.id }}" redirect-url="redirectUrl"
|
<div class="external-login-button" provider="provider" redirect-url="redirectUrl"
|
||||||
sign-in-started="markStarted()" ng-repeat="provider in EXTERNAL_LOGINS" is-link="true"></div>
|
sign-in-started="markStarted()" ng-repeat="provider in EXTERNAL_LOGINS" is-link="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -12,18 +12,15 @@ angular.module('quay').directive('externalLoginButton', function () {
|
||||||
'signInStarted': '&signInStarted',
|
'signInStarted': '&signInStarted',
|
||||||
'redirectUrl': '=redirectUrl',
|
'redirectUrl': '=redirectUrl',
|
||||||
'isLink': '=isLink',
|
'isLink': '=isLink',
|
||||||
'provider': '@provider',
|
'provider': '=provider',
|
||||||
'action': '@action'
|
'action': '@action'
|
||||||
},
|
},
|
||||||
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, ExternalLoginService) {
|
controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, ExternalLoginService) {
|
||||||
$scope.signingIn = false;
|
$scope.signingIn = false;
|
||||||
$scope.providerInfo = ExternalLoginService.getProvider($scope.provider);
|
|
||||||
|
|
||||||
$scope.startSignin = function() {
|
$scope.startSignin = function() {
|
||||||
$scope.signInStarted({'service': $scope.provider});
|
$scope.signInStarted({'service': $scope.provider});
|
||||||
ApiService.generateExternalLoginToken().then(function(data) {
|
ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login', function(url) {
|
||||||
var url = ExternalLoginService.getLoginUrl($scope.provider, $scope.action || 'login');
|
|
||||||
url = url + '&state=' + encodeURIComponent(data['token']);
|
|
||||||
|
|
||||||
// Save the redirect URL in a cookie so that we can redirect back after the service returns to us.
|
// Save the redirect URL in a cookie so that we can redirect back after the service returns to us.
|
||||||
var redirectURL = $scope.redirectUrl || window.location.toString();
|
var redirectURL = $scope.redirectUrl || window.location.toString();
|
||||||
|
@ -35,7 +32,7 @@ angular.module('quay').directive('externalLoginButton', function () {
|
||||||
$timeout(function() {
|
$timeout(function() {
|
||||||
document.location = url;
|
document.location = url;
|
||||||
}, 250);
|
}, 250);
|
||||||
}, ApiService.errorDisplay('Could not perform sign in'));
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -34,11 +34,11 @@ angular.module('quay').directive('externalLoginsManager', function () {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.detachExternalLogin = function(kind) {
|
$scope.detachExternalLogin = function(service_id) {
|
||||||
if (!Features.DIRECT_LOGIN) { return; }
|
if (!Features.DIRECT_LOGIN) { return; }
|
||||||
|
|
||||||
var params = {
|
var params = {
|
||||||
'servicename': kind
|
'service_id': service_id
|
||||||
};
|
};
|
||||||
|
|
||||||
ApiService.detachExternalLogin(null, params).then(function() {
|
ApiService.detachExternalLogin(null, params).then(function() {
|
||||||
|
|
|
@ -16,7 +16,9 @@ angular.module('quay').directive('headerBar', function () {
|
||||||
PlanService, ApiService, NotificationService, Config, Features,
|
PlanService, ApiService, NotificationService, Config, Features,
|
||||||
DocumentationService, ExternalLoginService) {
|
DocumentationService, ExternalLoginService) {
|
||||||
|
|
||||||
$scope.externalSigninUrl = ExternalLoginService.getSingleSigninUrl();
|
ExternalLoginService.getSingleSigninUrl(function(url) {
|
||||||
|
$scope.externalSigninUrl = url;
|
||||||
|
});
|
||||||
|
|
||||||
var hotkeysAdded = false;
|
var hotkeysAdded = false;
|
||||||
var userUpdated = function(cUser) {
|
var userUpdated = function(cUser) {
|
||||||
|
|
18
static/js/directives/ui/icon-image-view.js
Normal file
18
static/js/directives/ui/icon-image-view.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* An element which displays either an icon or an image, depending on the value.
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('iconImageView', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/icon-image-view.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'value': '@value'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -11,9 +11,10 @@
|
||||||
function SignInCtrl($scope, $location, ExternalLoginService, Features) {
|
function SignInCtrl($scope, $location, ExternalLoginService, Features) {
|
||||||
$scope.redirectUrl = '/';
|
$scope.redirectUrl = '/';
|
||||||
|
|
||||||
var singleUrl = ExternalLoginService.getSingleSigninUrl();
|
ExternalLoginService.getSingleSigninUrl(function(singleUrl) {
|
||||||
if (singleUrl) {
|
if (singleUrl) {
|
||||||
document.location = singleUrl;
|
document.location = singleUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,131 +1,42 @@
|
||||||
/**
|
/**
|
||||||
* Service which exposes the supported external logins.
|
* Service which exposes the supported external logins.
|
||||||
*/
|
*/
|
||||||
angular.module('quay').factory('ExternalLoginService', ['KeyService', 'Features', 'Config',
|
angular.module('quay').factory('ExternalLoginService', ['Features', 'Config', 'ApiService',
|
||||||
function(KeyService, Features, Config) {
|
function(Features, Config, ApiService) {
|
||||||
var externalLoginService = {};
|
var externalLoginService = {};
|
||||||
|
|
||||||
externalLoginService.getLoginUrl = function(service, action) {
|
externalLoginService.EXTERNAL_LOGINS = window.__external_login || [];
|
||||||
var serviceInfo = externalLoginService.getProvider(service);
|
|
||||||
if (!serviceInfo) { return ''; }
|
|
||||||
|
|
||||||
var loginUrl = KeyService.getConfiguration(serviceInfo.key, 'AUTHORIZE_ENDPOINT');
|
externalLoginService.getLoginUrl = function(loginService, action, callback) {
|
||||||
var clientId = KeyService.getConfiguration(serviceInfo.key, 'CLIENT_ID');
|
var errorDisplay = ApiService.errorDisplay('Could not load external login service ' +
|
||||||
|
'information. Please contact your service ' +
|
||||||
|
'administrator.')
|
||||||
|
|
||||||
var scope = serviceInfo.scopes();
|
var params = {
|
||||||
var redirectUri = Config.getUrl('/oauth2/' + service + '/callback');
|
'service_id': loginService['id']
|
||||||
|
};
|
||||||
|
|
||||||
if (action == 'attach') {
|
var data = {
|
||||||
redirectUri += '/attach';
|
'kind': action
|
||||||
}
|
};
|
||||||
|
|
||||||
var url = loginUrl + 'client_id=' + clientId + '&scope=' + scope + '&redirect_uri=' +
|
ApiService.retrieveExternalLoginAuthorizationUrl(data, params).then(function(resp) {
|
||||||
redirectUri;
|
callback(resp['auth_url']);
|
||||||
return url;
|
}, errorDisplay);
|
||||||
};
|
|
||||||
|
|
||||||
var DEX = {
|
|
||||||
id: 'dex',
|
|
||||||
key: 'DEX_LOGIN_CONFIG',
|
|
||||||
|
|
||||||
title: function() {
|
|
||||||
return KeyService.getConfiguration('DEX_LOGIN_CONFIG', 'OIDC_TITLE');
|
|
||||||
},
|
|
||||||
|
|
||||||
icon: function() {
|
|
||||||
return {'url': KeyService.getConfiguration('DEX_LOGIN_CONFIG', 'OIDC_LOGO') };
|
|
||||||
},
|
|
||||||
|
|
||||||
scopes: function() {
|
|
||||||
return 'openid email profile'
|
|
||||||
},
|
|
||||||
|
|
||||||
enabled: Features.DEX_LOGIN
|
|
||||||
};
|
|
||||||
|
|
||||||
var GITHUB = {
|
|
||||||
id: 'github',
|
|
||||||
key: 'GITHUB_LOGIN_CONFIG',
|
|
||||||
|
|
||||||
title: function() {
|
|
||||||
return KeyService.isEnterprise('github') ? 'GitHub Enterprise' : 'GitHub';
|
|
||||||
},
|
|
||||||
|
|
||||||
icon: function() {
|
|
||||||
return {'icon': 'fa-github'};
|
|
||||||
},
|
|
||||||
|
|
||||||
hasUserInfo: true,
|
|
||||||
getUserInfo: function(service_info) {
|
|
||||||
username = service_info['metadata']['service_username'];
|
|
||||||
return {
|
|
||||||
'username': username,
|
|
||||||
'endpoint': KeyService['githubEndpoint'] + username
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
scopes: function() {
|
|
||||||
var scopes = 'user:email';
|
|
||||||
if (KeyService.getConfiguration('GITHUB_LOGIN_CONFIG', 'ORG_RESTRICT')) {
|
|
||||||
scopes += ' read:org';
|
|
||||||
}
|
|
||||||
|
|
||||||
return scopes;
|
|
||||||
},
|
|
||||||
|
|
||||||
enabled: Features.GITHUB_LOGIN
|
|
||||||
};
|
|
||||||
|
|
||||||
var GOOGLE = {
|
|
||||||
id: 'google',
|
|
||||||
key: 'GOOGLE_LOGIN_CONFIG',
|
|
||||||
|
|
||||||
title: function() {
|
|
||||||
return 'Google';
|
|
||||||
},
|
|
||||||
|
|
||||||
icon: function() {
|
|
||||||
return {'icon': 'fa-google'};
|
|
||||||
},
|
|
||||||
|
|
||||||
scopes: function() {
|
|
||||||
return 'openid email';
|
|
||||||
},
|
|
||||||
|
|
||||||
enabled: Features.GOOGLE_LOGIN
|
|
||||||
};
|
|
||||||
|
|
||||||
externalLoginService.ALL_EXTERNAL_LOGINS = [
|
|
||||||
DEX, GITHUB, GOOGLE
|
|
||||||
];
|
|
||||||
|
|
||||||
externalLoginService.EXTERNAL_LOGINS = externalLoginService.ALL_EXTERNAL_LOGINS.filter(function(el) {
|
|
||||||
return el.enabled;
|
|
||||||
});
|
|
||||||
|
|
||||||
externalLoginService.getProvider = function(providerId) {
|
|
||||||
for (var i = 0; i < externalLoginService.EXTERNAL_LOGINS.length; ++i) {
|
|
||||||
var current = externalLoginService.EXTERNAL_LOGINS[i];
|
|
||||||
if (current.id == providerId) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
externalLoginService.hasSingleSignin = function() {
|
externalLoginService.hasSingleSignin = function() {
|
||||||
return externalLoginService.EXTERNAL_LOGINS.length == 1 && !Features.DIRECT_LOGIN;
|
return externalLoginService.EXTERNAL_LOGINS.length == 1 && !Features.DIRECT_LOGIN;
|
||||||
};
|
};
|
||||||
|
|
||||||
externalLoginService.getSingleSigninUrl = function() {
|
externalLoginService.getSingleSigninUrl = function(callback) {
|
||||||
// If there is a single external login service and direct login is disabled,
|
if (!externalLoginService.hasSingleSignin()) {
|
||||||
// then redirect to the external login directly.
|
return callback(null);
|
||||||
if (externalLoginService.hasSingleSignin()) {
|
|
||||||
return externalLoginService.getLoginUrl(externalLoginService.EXTERNAL_LOGINS[0].id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// If there is a single external login service and direct login is disabled,
|
||||||
|
// then redirect to the external login directly.
|
||||||
|
externalLoginService.getLoginUrl(externalLoginService.EXTERNAL_LOGINS[0], 'login', callback);
|
||||||
};
|
};
|
||||||
|
|
||||||
return externalLoginService;
|
return externalLoginService;
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
window.__features = {{ feature_set|tojson|safe }};
|
window.__features = {{ feature_set|tojson|safe }};
|
||||||
window.__config = {{ config_set|tojson|safe }};
|
window.__config = {{ config_set|tojson|safe }};
|
||||||
window.__oauth = {{ oauth_set|tojson|safe }};
|
window.__oauth = {{ oauth_set|tojson|safe }};
|
||||||
|
window.__external_login = {{ external_login_set|tojson|safe }};
|
||||||
window.__auth_scopes = {{ scope_set|tojson|safe }};
|
window.__auth_scopes = {{ scope_set|tojson|safe }};
|
||||||
window.__vuln_priority = {{ vuln_priority_set|tojson|safe }}
|
window.__vuln_priority = {{ vuln_priority_set|tojson|safe }}
|
||||||
window.__token = '{{ csrf_token() }}';
|
window.__token = '{{ csrf_token() }}';
|
||||||
|
|
|
@ -28,7 +28,7 @@ from endpoints.api.repositorynotification import RepositoryNotification, Reposit
|
||||||
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
||||||
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
||||||
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
|
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
|
||||||
ClientKey)
|
ClientKey, ExternalLoginInformation)
|
||||||
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
||||||
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
||||||
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
||||||
|
@ -505,10 +505,28 @@ class TestSignin(ApiTestCase):
|
||||||
self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
|
self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalLoginInformation(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(ExternalLoginInformation, service_id='someservice')
|
||||||
|
|
||||||
|
def test_post_anonymous(self):
|
||||||
|
self._run_test('POST', 400, None, {})
|
||||||
|
|
||||||
|
def test_post_freshuser(self):
|
||||||
|
self._run_test('POST', 400, 'freshuser', {})
|
||||||
|
|
||||||
|
def test_post_reader(self):
|
||||||
|
self._run_test('POST', 400, 'reader', {})
|
||||||
|
|
||||||
|
def test_post_devtable(self):
|
||||||
|
self._run_test('POST', 400, 'devtable', {})
|
||||||
|
|
||||||
|
|
||||||
class TestDetachExternal(ApiTestCase):
|
class TestDetachExternal(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
self._set_url(DetachExternal, servicename='someservice')
|
self._set_url(DetachExternal, service_id='someservice')
|
||||||
|
|
||||||
def test_post_anonymous(self):
|
def test_post_anonymous(self):
|
||||||
self._run_test('POST', 401, None, {})
|
self._run_test('POST', 401, None, {})
|
||||||
|
|
|
@ -8,7 +8,6 @@ import base64
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
from urlparse import urlparse, urlunparse, parse_qs
|
from urlparse import urlparse, urlunparse, parse_qs
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from httmock import urlmatch, HTTMock
|
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
|
@ -25,16 +24,9 @@ from endpoints.api.user import Signin
|
||||||
from endpoints.keyserver import jwk_with_kid
|
from endpoints.keyserver import jwk_with_kid
|
||||||
from endpoints.csrf import OAUTH_CSRF_TOKEN_NAME
|
from endpoints.csrf import OAUTH_CSRF_TOKEN_NAME
|
||||||
from endpoints.web import web as web_bp
|
from endpoints.web import web as web_bp
|
||||||
from endpoints.oauthlogin import oauthlogin as oauthlogin_bp
|
|
||||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from test.helpers import assert_action_logged
|
from test.helpers import assert_action_logged
|
||||||
|
|
||||||
try:
|
|
||||||
app.register_blueprint(oauthlogin_bp, url_prefix='/oauth')
|
|
||||||
except ValueError:
|
|
||||||
# This blueprint was already registered
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app.register_blueprint(web_bp, url_prefix='')
|
app.register_blueprint(web_bp, url_prefix='')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -129,140 +121,6 @@ class EndpointTestCase(unittest.TestCase):
|
||||||
self.assertEquals(rv.status_code, 200)
|
self.assertEquals(rv.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class OAuthLoginTestCase(EndpointTestCase):
|
|
||||||
def invoke_oauth_tests(self, callback_endpoint, attach_endpoint, service_name, service_ident,
|
|
||||||
new_username):
|
|
||||||
# Test callback.
|
|
||||||
created = self.invoke_oauth_test(callback_endpoint, service_name, service_ident, new_username)
|
|
||||||
|
|
||||||
# Delete the created user.
|
|
||||||
model.user.delete_user(created, [])
|
|
||||||
|
|
||||||
# Test attach.
|
|
||||||
self.login('devtable', 'password')
|
|
||||||
self.invoke_oauth_test(attach_endpoint, service_name, service_ident, 'devtable')
|
|
||||||
|
|
||||||
def invoke_oauth_test(self, endpoint_name, service_name, service_ident, username):
|
|
||||||
# No CSRF.
|
|
||||||
self.getResponse('oauthlogin.' + endpoint_name, expected_code=403)
|
|
||||||
|
|
||||||
# Invalid CSRF.
|
|
||||||
self.getResponse('oauthlogin.' + endpoint_name, state='somestate', expected_code=403)
|
|
||||||
|
|
||||||
# Valid CSRF, invalid code.
|
|
||||||
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
|
||||||
code='invalidcode', expected_code=400)
|
|
||||||
|
|
||||||
# Valid CSRF, valid code.
|
|
||||||
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
|
||||||
code='somecode', expected_code=302)
|
|
||||||
|
|
||||||
# Ensure the user was added/modified.
|
|
||||||
found_user = model.user.get_user(username)
|
|
||||||
self.assertIsNotNone(found_user)
|
|
||||||
|
|
||||||
federated_login = model.user.lookup_federated_login(found_user, service_name)
|
|
||||||
self.assertIsNotNone(federated_login)
|
|
||||||
self.assertEquals(federated_login.service_ident, service_ident)
|
|
||||||
return found_user
|
|
||||||
|
|
||||||
def test_google_oauth(self):
|
|
||||||
@urlmatch(netloc=r'accounts.google.com', path='/o/oauth2/token')
|
|
||||||
def account_handler(_, request):
|
|
||||||
if request.body.find("code=somecode") > 0:
|
|
||||||
content = {'access_token': 'someaccesstoken'}
|
|
||||||
return py_json.dumps(content)
|
|
||||||
else:
|
|
||||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
|
||||||
|
|
||||||
@urlmatch(netloc=r'www.googleapis.com', path='/oauth2/v1/userinfo')
|
|
||||||
def user_handler(_, __):
|
|
||||||
content = {
|
|
||||||
'id': 'someid',
|
|
||||||
'email': 'someemail@example.com',
|
|
||||||
'verified_email': True,
|
|
||||||
}
|
|
||||||
return py_json.dumps(content)
|
|
||||||
|
|
||||||
with HTTMock(account_handler, user_handler):
|
|
||||||
self.invoke_oauth_tests('google_oauth_callback', 'google_oauth_attach', 'google',
|
|
||||||
'someid', 'someemail')
|
|
||||||
|
|
||||||
def test_github_oauth(self):
|
|
||||||
@urlmatch(netloc=r'github.com', path='/login/oauth/access_token')
|
|
||||||
def account_handler(url, _):
|
|
||||||
if url.query.find("code=somecode") > 0:
|
|
||||||
content = {'access_token': 'someaccesstoken'}
|
|
||||||
return py_json.dumps(content)
|
|
||||||
else:
|
|
||||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
|
||||||
|
|
||||||
@urlmatch(netloc=r'github.com', path='/api/v3/user')
|
|
||||||
def user_handler(_, __):
|
|
||||||
content = {
|
|
||||||
'id': 'someid',
|
|
||||||
'login': 'someusername'
|
|
||||||
}
|
|
||||||
return py_json.dumps(content)
|
|
||||||
|
|
||||||
@urlmatch(netloc=r'github.com', path='/api/v3/user/emails')
|
|
||||||
def email_handler(_, __):
|
|
||||||
content = [{
|
|
||||||
'email': 'someemail@example.com',
|
|
||||||
'verified': True,
|
|
||||||
'primary': True,
|
|
||||||
}]
|
|
||||||
return py_json.dumps(content)
|
|
||||||
|
|
||||||
with HTTMock(account_handler, email_handler, user_handler):
|
|
||||||
self.invoke_oauth_tests('github_oauth_callback', 'github_oauth_attach', 'github',
|
|
||||||
'someid', 'someusername')
|
|
||||||
|
|
||||||
def test_dex_oauth(self):
|
|
||||||
# TODO(jschorr): Add tests for invalid and expired keys.
|
|
||||||
|
|
||||||
# Generate a public/private key pair for the OIDC transaction.
|
|
||||||
private_key = RSA.generate(2048)
|
|
||||||
jwk = RSAKey(key=private_key.publickey()).serialize()
|
|
||||||
token = jwt.encode({
|
|
||||||
'iss': 'https://oidcserver/',
|
|
||||||
'aud': 'someclientid',
|
|
||||||
'sub': 'someid',
|
|
||||||
'exp': int(time.time()) + 60,
|
|
||||||
'iat': int(time.time()),
|
|
||||||
'nbf': int(time.time()),
|
|
||||||
'email': 'someemail@example.com',
|
|
||||||
'email_verified': True,
|
|
||||||
}, private_key.exportKey('PEM'), 'RS256')
|
|
||||||
|
|
||||||
@urlmatch(netloc=r'oidcserver', path='/.well-known/openid-configuration')
|
|
||||||
def wellknown_handler(url, _):
|
|
||||||
return py_json.dumps({
|
|
||||||
'authorization_endpoint': 'http://oidcserver/auth',
|
|
||||||
'token_endpoint': 'http://oidcserver/token',
|
|
||||||
'jwks_uri': 'http://oidcserver/keys',
|
|
||||||
})
|
|
||||||
|
|
||||||
@urlmatch(netloc=r'oidcserver', path='/token')
|
|
||||||
def account_handler(url, request):
|
|
||||||
if request.body.find("code=somecode") > 0:
|
|
||||||
return py_json.dumps({
|
|
||||||
'access_token': token,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
|
||||||
|
|
||||||
@urlmatch(netloc=r'oidcserver', path='/keys')
|
|
||||||
def keys_handler(_, __):
|
|
||||||
return py_json.dumps({
|
|
||||||
"keys": [jwk],
|
|
||||||
})
|
|
||||||
|
|
||||||
with HTTMock(wellknown_handler, account_handler, keys_handler):
|
|
||||||
self.invoke_oauth_tests('dex_oauth_callback', 'dex_oauth_attach', 'dex',
|
|
||||||
'someid', 'someemail')
|
|
||||||
|
|
||||||
|
|
||||||
class WebEndpointTestCase(EndpointTestCase):
|
class WebEndpointTestCase(EndpointTestCase):
|
||||||
def test_index(self):
|
def test_index(self):
|
||||||
self.getResponse('web.index')
|
self.getResponse('web.index')
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from util.config.oauth import GithubOAuthConfig
|
from oauth.services.github import GithubOAuthService
|
||||||
|
|
||||||
class TestGithub(unittest.TestCase):
|
class TestGithub(unittest.TestCase):
|
||||||
def test_basic_enterprise_config(self):
|
def test_basic_enterprise_config(self):
|
||||||
|
@ -12,7 +12,7 @@ class TestGithub(unittest.TestCase):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
github_trigger = GithubOAuthConfig(config, 'GITHUB_TRIGGER_CONFIG')
|
github_trigger = GithubOAuthService(config, 'GITHUB_TRIGGER_CONFIG')
|
||||||
self.assertTrue(github_trigger.is_enterprise())
|
self.assertTrue(github_trigger.is_enterprise())
|
||||||
self.assertEquals('https://github.somedomain.com/login/oauth/authorize?', github_trigger.authorize_endpoint())
|
self.assertEquals('https://github.somedomain.com/login/oauth/authorize?', github_trigger.authorize_endpoint())
|
||||||
self.assertEquals('https://github.somedomain.com/login/oauth/access_token', github_trigger.token_endpoint())
|
self.assertEquals('https://github.somedomain.com/login/oauth/access_token', github_trigger.token_endpoint())
|
||||||
|
@ -33,7 +33,7 @@ class TestGithub(unittest.TestCase):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
github_trigger = GithubOAuthConfig(config, 'GITHUB_TRIGGER_CONFIG')
|
github_trigger = GithubOAuthService(config, 'GITHUB_TRIGGER_CONFIG')
|
||||||
self.assertTrue(github_trigger.is_enterprise())
|
self.assertTrue(github_trigger.is_enterprise())
|
||||||
self.assertEquals('https://github.somedomain.com/login/oauth/authorize?', github_trigger.authorize_endpoint())
|
self.assertEquals('https://github.somedomain.com/login/oauth/authorize?', github_trigger.authorize_endpoint())
|
||||||
self.assertEquals('https://github.somedomain.com/login/oauth/access_token', github_trigger.token_endpoint())
|
self.assertEquals('https://github.somedomain.com/login/oauth/access_token', github_trigger.token_endpoint())
|
||||||
|
|
175
test/test_oauth_login.py
Normal file
175
test/test_oauth_login.py
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
import json as py_json
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from httmock import urlmatch, HTTMock
|
||||||
|
from jwkest.jwk import RSAKey
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
from data import model
|
||||||
|
from endpoints.oauthlogin import oauthlogin as oauthlogin_bp
|
||||||
|
from test.test_endpoints import EndpointTestCase
|
||||||
|
|
||||||
|
try:
|
||||||
|
app.register_blueprint(oauthlogin_bp, url_prefix='/oauth2')
|
||||||
|
except ValueError:
|
||||||
|
# This blueprint was already registered
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OAuthLoginTestCase(EndpointTestCase):
|
||||||
|
def invoke_oauth_tests(self, callback_endpoint, attach_endpoint, service_name, service_ident,
|
||||||
|
new_username):
|
||||||
|
# Test callback.
|
||||||
|
created = self.invoke_oauth_test(callback_endpoint, service_name, service_ident, new_username)
|
||||||
|
|
||||||
|
# Delete the created user.
|
||||||
|
model.user.delete_user(created, [])
|
||||||
|
|
||||||
|
# Test attach.
|
||||||
|
self.login('devtable', 'password')
|
||||||
|
self.invoke_oauth_test(attach_endpoint, service_name, service_ident, 'devtable')
|
||||||
|
|
||||||
|
def invoke_oauth_test(self, endpoint_name, service_name, service_ident, username):
|
||||||
|
# No CSRF.
|
||||||
|
self.getResponse('oauthlogin.' + endpoint_name, expected_code=403)
|
||||||
|
|
||||||
|
# Invalid CSRF.
|
||||||
|
self.getResponse('oauthlogin.' + endpoint_name, state='somestate', expected_code=403)
|
||||||
|
|
||||||
|
# Valid CSRF, invalid code.
|
||||||
|
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
||||||
|
code='invalidcode', expected_code=400)
|
||||||
|
|
||||||
|
# Valid CSRF, valid code.
|
||||||
|
self.getResponse('oauthlogin.' + endpoint_name, state='someoauthtoken',
|
||||||
|
code='somecode', expected_code=302)
|
||||||
|
|
||||||
|
# Ensure the user was added/modified.
|
||||||
|
found_user = model.user.get_user(username)
|
||||||
|
self.assertIsNotNone(found_user)
|
||||||
|
|
||||||
|
federated_login = model.user.lookup_federated_login(found_user, service_name)
|
||||||
|
self.assertIsNotNone(federated_login)
|
||||||
|
self.assertEquals(federated_login.service_ident, service_ident)
|
||||||
|
return found_user
|
||||||
|
|
||||||
|
def test_google_oauth(self):
|
||||||
|
@urlmatch(netloc=r'accounts.google.com', path='/o/oauth2/token')
|
||||||
|
def account_handler(_, request):
|
||||||
|
if request.body.find("code=somecode") > 0:
|
||||||
|
content = {'access_token': 'someaccesstoken'}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
else:
|
||||||
|
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'www.googleapis.com', path='/oauth2/v1/userinfo')
|
||||||
|
def user_handler(_, __):
|
||||||
|
content = {
|
||||||
|
'id': 'someid',
|
||||||
|
'email': 'someemail@example.com',
|
||||||
|
'verified_email': True,
|
||||||
|
}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
|
||||||
|
with HTTMock(account_handler, user_handler):
|
||||||
|
self.invoke_oauth_tests('google_oauth_callback', 'google_oauth_attach', 'google',
|
||||||
|
'someid', 'someemail')
|
||||||
|
|
||||||
|
def test_github_oauth(self):
|
||||||
|
@urlmatch(netloc=r'github.com', path='/login/oauth/access_token')
|
||||||
|
def account_handler(url, _):
|
||||||
|
if url.query.find("code=somecode") > 0:
|
||||||
|
content = {'access_token': 'someaccesstoken'}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
else:
|
||||||
|
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'github.com', path='/api/v3/user')
|
||||||
|
def user_handler(_, __):
|
||||||
|
content = {
|
||||||
|
'id': 'someid',
|
||||||
|
'login': 'someusername'
|
||||||
|
}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'github.com', path='/api/v3/user/emails')
|
||||||
|
def email_handler(_, __):
|
||||||
|
content = [{
|
||||||
|
'email': 'someemail@example.com',
|
||||||
|
'verified': True,
|
||||||
|
'primary': True,
|
||||||
|
}]
|
||||||
|
return py_json.dumps(content)
|
||||||
|
|
||||||
|
with HTTMock(account_handler, email_handler, user_handler):
|
||||||
|
self.invoke_oauth_tests('github_oauth_callback', 'github_oauth_attach', 'github',
|
||||||
|
'someid', 'someusername')
|
||||||
|
|
||||||
|
def test_oidc_auth(self):
|
||||||
|
private_key = RSA.generate(2048)
|
||||||
|
generatedjwk = RSAKey(key=private_key.publickey()).serialize()
|
||||||
|
kid = 'somekey'
|
||||||
|
private_pem = private_key.exportKey('PEM')
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
'iss': app.config['TESTOIDC_LOGIN_CONFIG']['OIDC_SERVER'],
|
||||||
|
'aud': app.config['TESTOIDC_LOGIN_CONFIG']['CLIENT_ID'],
|
||||||
|
'nbf': int(time.time()),
|
||||||
|
'iat': int(time.time()),
|
||||||
|
'exp': int(time.time() + 600),
|
||||||
|
'sub': 'cooluser',
|
||||||
|
}
|
||||||
|
|
||||||
|
token_headers = {
|
||||||
|
'kid': kid,
|
||||||
|
}
|
||||||
|
|
||||||
|
id_token = jwt.encode(token_data, private_pem, 'RS256', headers=token_headers)
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path='/token')
|
||||||
|
def token_handler(_, request):
|
||||||
|
if request.body.find("code=somecode") >= 0:
|
||||||
|
content = {'access_token': 'someaccesstoken', 'id_token': id_token}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
else:
|
||||||
|
return {'status_code': 400, 'content': '{"message": "Invalid code"}'}
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path='/user')
|
||||||
|
def user_handler(_, __):
|
||||||
|
content = {
|
||||||
|
'sub': 'cooluser',
|
||||||
|
'preferred_username': 'someusername',
|
||||||
|
'email': 'someemail@example.com',
|
||||||
|
'email_verified': True,
|
||||||
|
}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path='/jwks')
|
||||||
|
def jwks_handler(_, __):
|
||||||
|
jwk = generatedjwk.copy()
|
||||||
|
jwk.update({'kid': kid})
|
||||||
|
|
||||||
|
content = {'keys': [jwk]}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
|
||||||
|
@urlmatch(netloc=r'fakeoidc', path='.+openid.+')
|
||||||
|
def discovery_handler(_, __):
|
||||||
|
content = {
|
||||||
|
'scopes_supported': ['profile'],
|
||||||
|
'authorization_endpoint': 'http://fakeoidc/authorize',
|
||||||
|
'token_endpoint': 'http://fakeoidc/token',
|
||||||
|
'userinfo_endpoint': 'http://fakeoidc/userinfo',
|
||||||
|
'jwks_uri': 'http://fakeoidc/jwks',
|
||||||
|
}
|
||||||
|
return py_json.dumps(content)
|
||||||
|
|
||||||
|
with HTTMock(discovery_handler, jwks_handler, token_handler, user_handler):
|
||||||
|
self.invoke_oauth_tests('testoidc_oauth_callback', 'testoidc_oauth_attach', 'testoidc',
|
||||||
|
'cooluser', 'someusername')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
|
@ -76,13 +76,17 @@ class TestConfig(DefaultConfig):
|
||||||
|
|
||||||
PROMETHEUS_AGGREGATOR_URL = None
|
PROMETHEUS_AGGREGATOR_URL = None
|
||||||
|
|
||||||
|
GITHUB_LOGIN_CONFIG = {}
|
||||||
|
GOOGLE_LOGIN_CONFIG = {}
|
||||||
|
|
||||||
FEATURE_GITHUB_LOGIN = True
|
FEATURE_GITHUB_LOGIN = True
|
||||||
FEATURE_GOOGLE_LOGIN = True
|
FEATURE_GOOGLE_LOGIN = True
|
||||||
FEATURE_DEX_LOGIN = True
|
|
||||||
|
|
||||||
DEX_LOGIN_CONFIG = {
|
TESTOIDC_LOGIN_CONFIG = {
|
||||||
'CLIENT_ID': 'someclientid',
|
'CLIENT_ID': 'foo',
|
||||||
'OIDC_SERVER': 'https://oidcserver/',
|
'CLIENT_SECRET': 'bar',
|
||||||
|
'OIDC_SERVER': 'http://fakeoidc',
|
||||||
|
'DEBUGGING': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
RECAPTCHA_SITE_KEY = 'somekey'
|
RECAPTCHA_SITE_KEY = 'somekey'
|
||||||
|
|
|
@ -1,370 +0,0 @@
|
||||||
import urlparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from cachetools import TTLCache
|
|
||||||
from cachetools.func import lru_cache
|
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
||||||
|
|
||||||
from jwkest.jwk import KEYS
|
|
||||||
from util import slash_join
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class OAuthConfig(object):
|
|
||||||
def __init__(self, config, key_name):
|
|
||||||
self.key_name = key_name
|
|
||||||
self.config = config.get(key_name) or {}
|
|
||||||
|
|
||||||
def service_name(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def token_endpoint(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def user_endpoint(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def validate_client_id_and_secret(self, http_client, app_config):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def client_id(self):
|
|
||||||
return self.config.get('CLIENT_ID')
|
|
||||||
|
|
||||||
def client_secret(self):
|
|
||||||
return self.config.get('CLIENT_SECRET')
|
|
||||||
|
|
||||||
def get_redirect_uri(self, app_config, redirect_suffix=''):
|
|
||||||
return '%s://%s/oauth2/%s/callback%s' % (app_config['PREFERRED_URL_SCHEME'],
|
|
||||||
app_config['SERVER_HOSTNAME'],
|
|
||||||
self.service_name().lower(),
|
|
||||||
redirect_suffix)
|
|
||||||
|
|
||||||
|
|
||||||
def exchange_code_for_token(self, app_config, http_client, code, form_encode=False,
|
|
||||||
redirect_suffix='', client_auth=False):
|
|
||||||
payload = {
|
|
||||||
'code': code,
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'redirect_uri': self.get_redirect_uri(app_config, redirect_suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
auth = None
|
|
||||||
if client_auth:
|
|
||||||
auth = (self.client_id(), self.client_secret())
|
|
||||||
else:
|
|
||||||
payload['client_id'] = self.client_id()
|
|
||||||
payload['client_secret'] = self.client_secret()
|
|
||||||
|
|
||||||
token_url = self.token_endpoint()
|
|
||||||
if form_encode:
|
|
||||||
get_access_token = http_client.post(token_url, data=payload, headers=headers, auth=auth)
|
|
||||||
else:
|
|
||||||
get_access_token = http_client.post(token_url, params=payload, headers=headers, auth=auth)
|
|
||||||
|
|
||||||
if get_access_token.status_code / 100 != 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
json_data = get_access_token.json()
|
|
||||||
if not json_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return json_data.get('access_token', None)
|
|
||||||
|
|
||||||
|
|
||||||
class GithubOAuthConfig(OAuthConfig):
|
|
||||||
def __init__(self, config, key_name):
|
|
||||||
super(GithubOAuthConfig, self).__init__(config, key_name)
|
|
||||||
|
|
||||||
def service_name(self):
|
|
||||||
return 'GitHub'
|
|
||||||
|
|
||||||
def allowed_organizations(self):
|
|
||||||
if not self.config.get('ORG_RESTRICT', False):
|
|
||||||
return None
|
|
||||||
|
|
||||||
allowed = self.config.get('ALLOWED_ORGANIZATIONS', None)
|
|
||||||
if allowed is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return [org.lower() for org in allowed]
|
|
||||||
|
|
||||||
def get_public_url(self, suffix):
|
|
||||||
return slash_join(self._endpoint(), suffix)
|
|
||||||
|
|
||||||
def _endpoint(self):
|
|
||||||
return self.config.get('GITHUB_ENDPOINT', 'https://github.com')
|
|
||||||
|
|
||||||
def is_enterprise(self):
|
|
||||||
return self._endpoint().find('.github.com') < 0
|
|
||||||
|
|
||||||
def authorize_endpoint(self):
|
|
||||||
return slash_join(self._endpoint(), '/login/oauth/authorize') + '?'
|
|
||||||
|
|
||||||
def token_endpoint(self):
|
|
||||||
return slash_join(self._endpoint(), '/login/oauth/access_token')
|
|
||||||
|
|
||||||
def _api_endpoint(self):
|
|
||||||
return self.config.get('API_ENDPOINT', slash_join(self._endpoint(), '/api/v3/'))
|
|
||||||
|
|
||||||
def api_endpoint(self):
|
|
||||||
endpoint = self._api_endpoint()
|
|
||||||
if endpoint.endswith('/'):
|
|
||||||
return endpoint[0:-1]
|
|
||||||
|
|
||||||
return endpoint
|
|
||||||
|
|
||||||
def user_endpoint(self):
|
|
||||||
return slash_join(self._api_endpoint(), 'user')
|
|
||||||
|
|
||||||
def email_endpoint(self):
|
|
||||||
return slash_join(self._api_endpoint(), 'user/emails')
|
|
||||||
|
|
||||||
def orgs_endpoint(self):
|
|
||||||
return slash_join(self._api_endpoint(), 'user/orgs')
|
|
||||||
|
|
||||||
def validate_client_id_and_secret(self, http_client, app_config):
|
|
||||||
# First: Verify that the github endpoint is actually Github by checking for the
|
|
||||||
# X-GitHub-Request-Id here.
|
|
||||||
api_endpoint = self._api_endpoint()
|
|
||||||
result = http_client.get(api_endpoint, auth=(self.client_id(), self.client_secret()), timeout=5)
|
|
||||||
if not 'X-GitHub-Request-Id' in result.headers:
|
|
||||||
raise Exception('Endpoint is not a Github (Enterprise) installation')
|
|
||||||
|
|
||||||
# Next: Verify the client ID and secret.
|
|
||||||
# Note: The following code is a hack until such time as Github officially adds an API endpoint
|
|
||||||
# for verifying a {client_id, client_secret} pair. That being said, this hack was given to us
|
|
||||||
# *by a Github Engineer*, so I think it is okay for the time being :)
|
|
||||||
#
|
|
||||||
# TODO(jschorr): Replace with the real API call once added.
|
|
||||||
#
|
|
||||||
# Hitting the endpoint applications/{client_id}/tokens/foo will result in the following
|
|
||||||
# behavior IF the client_id is given as the HTTP username and the client_secret as the HTTP
|
|
||||||
# password:
|
|
||||||
# - If the {client_id, client_secret} pair is invalid in some way, we get a 401 error.
|
|
||||||
# - If the pair is valid, then we get a 404 because the 'foo' token does not exists.
|
|
||||||
validate_endpoint = slash_join(api_endpoint, 'applications/%s/tokens/foo' % self.client_id())
|
|
||||||
result = http_client.get(validate_endpoint, auth=(self.client_id(), self.client_secret()),
|
|
||||||
timeout=5)
|
|
||||||
return result.status_code == 404
|
|
||||||
|
|
||||||
def validate_organization(self, organization_id, http_client):
|
|
||||||
org_endpoint = slash_join(self._api_endpoint(), 'orgs/%s' % organization_id.lower())
|
|
||||||
|
|
||||||
result = http_client.get(org_endpoint,
|
|
||||||
headers={'Accept': 'application/vnd.github.moondragon+json'},
|
|
||||||
timeout=5)
|
|
||||||
|
|
||||||
return result.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def get_public_config(self):
|
|
||||||
return {
|
|
||||||
'CLIENT_ID': self.client_id(),
|
|
||||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
|
||||||
'GITHUB_ENDPOINT': self._endpoint(),
|
|
||||||
'ORG_RESTRICT': self.config.get('ORG_RESTRICT', False)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleOAuthConfig(OAuthConfig):
|
|
||||||
def __init__(self, config, key_name):
|
|
||||||
super(GoogleOAuthConfig, self).__init__(config, key_name)
|
|
||||||
|
|
||||||
def service_name(self):
|
|
||||||
return 'Google'
|
|
||||||
|
|
||||||
def authorize_endpoint(self):
|
|
||||||
return 'https://accounts.google.com/o/oauth2/auth?response_type=code&'
|
|
||||||
|
|
||||||
def token_endpoint(self):
|
|
||||||
return 'https://accounts.google.com/o/oauth2/token'
|
|
||||||
|
|
||||||
def user_endpoint(self):
|
|
||||||
return 'https://www.googleapis.com/oauth2/v1/userinfo'
|
|
||||||
|
|
||||||
def validate_client_id_and_secret(self, http_client, app_config):
|
|
||||||
# To verify the Google client ID and secret, we hit the
|
|
||||||
# https://www.googleapis.com/oauth2/v3/token endpoint with an invalid request. If the client
|
|
||||||
# ID or secret are invalid, we get returned a 403 Unauthorized. Otherwise, we get returned
|
|
||||||
# another response code.
|
|
||||||
url = 'https://www.googleapis.com/oauth2/v3/token'
|
|
||||||
data = {
|
|
||||||
'code': 'fakecode',
|
|
||||||
'client_id': self.client_id(),
|
|
||||||
'client_secret': self.client_secret(),
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'redirect_uri': 'http://example.com'
|
|
||||||
}
|
|
||||||
|
|
||||||
result = http_client.post(url, data=data, timeout=5)
|
|
||||||
return result.status_code != 401
|
|
||||||
|
|
||||||
def get_public_config(self):
|
|
||||||
return {
|
|
||||||
'CLIENT_ID': self.client_id(),
|
|
||||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class GitLabOAuthConfig(OAuthConfig):
|
|
||||||
def __init__(self, config, key_name):
|
|
||||||
super(GitLabOAuthConfig, self).__init__(config, key_name)
|
|
||||||
|
|
||||||
def _endpoint(self):
|
|
||||||
return self.config.get('GITLAB_ENDPOINT', 'https://gitlab.com')
|
|
||||||
|
|
||||||
def api_endpoint(self):
|
|
||||||
return self._endpoint()
|
|
||||||
|
|
||||||
def get_public_url(self, suffix):
|
|
||||||
return slash_join(self._endpoint(), suffix)
|
|
||||||
|
|
||||||
def service_name(self):
|
|
||||||
return 'GitLab'
|
|
||||||
|
|
||||||
def authorize_endpoint(self):
|
|
||||||
return slash_join(self._endpoint(), '/oauth/authorize')
|
|
||||||
|
|
||||||
def token_endpoint(self):
|
|
||||||
return slash_join(self._endpoint(), '/oauth/token')
|
|
||||||
|
|
||||||
def validate_client_id_and_secret(self, http_client, app_config):
|
|
||||||
url = self.token_endpoint()
|
|
||||||
redirect_uri = self.get_redirect_uri(app_config, redirect_suffix='trigger')
|
|
||||||
data = {
|
|
||||||
'code': 'fakecode',
|
|
||||||
'client_id': self.client_id(),
|
|
||||||
'client_secret': self.client_secret(),
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'redirect_uri': redirect_uri
|
|
||||||
}
|
|
||||||
|
|
||||||
# We validate by checking the error code we receive from this call.
|
|
||||||
result = http_client.post(url, data=data, timeout=5)
|
|
||||||
value = result.json()
|
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return value.get('error', '') != 'invalid_client'
|
|
||||||
|
|
||||||
def get_public_config(self):
|
|
||||||
return {
|
|
||||||
'CLIENT_ID': self.client_id(),
|
|
||||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
|
||||||
'GITLAB_ENDPOINT': self._endpoint(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
OIDC_WELLKNOWN = ".well-known/openid-configuration"
|
|
||||||
PUBLIC_KEY_CACHE_TTL = 3600 # 1 hour
|
|
||||||
|
|
||||||
class OIDCConfig(OAuthConfig):
|
|
||||||
def __init__(self, config, key_name):
|
|
||||||
super(OIDCConfig, self).__init__(config, key_name)
|
|
||||||
|
|
||||||
self._public_key_cache = TTLCache(1, PUBLIC_KEY_CACHE_TTL, missing=self._get_public_key)
|
|
||||||
self._config = config
|
|
||||||
self._http_client = config['HTTPCLIENT']
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _oidc_config(self):
|
|
||||||
if self.config.get('OIDC_SERVER'):
|
|
||||||
return self._load_via_discovery(self._config.get('DEBUGGING', False))
|
|
||||||
else:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _load_via_discovery(self, is_debugging):
|
|
||||||
oidc_server = self.config['OIDC_SERVER']
|
|
||||||
if not oidc_server.startswith('https://') and not is_debugging:
|
|
||||||
raise Exception('OIDC server must be accessed over SSL')
|
|
||||||
|
|
||||||
discovery_url = urlparse.urljoin(oidc_server, OIDC_WELLKNOWN)
|
|
||||||
discovery = self._http_client.get(discovery_url, timeout=5)
|
|
||||||
|
|
||||||
if discovery.status_code / 100 != 2:
|
|
||||||
raise Exception("Could not load OIDC discovery information")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(discovery.text)
|
|
||||||
except ValueError:
|
|
||||||
logger.exception('Could not parse OIDC discovery for url: %s', discovery_url)
|
|
||||||
raise Exception("Could not parse OIDC discovery information")
|
|
||||||
|
|
||||||
def authorize_endpoint(self):
|
|
||||||
return self._oidc_config().get('authorization_endpoint', '') + '?'
|
|
||||||
|
|
||||||
def token_endpoint(self):
|
|
||||||
return self._oidc_config().get('token_endpoint')
|
|
||||||
|
|
||||||
def user_endpoint(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def validate_client_id_and_secret(self, http_client, app_config):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_public_config(self):
|
|
||||||
return {
|
|
||||||
'CLIENT_ID': self.client_id(),
|
|
||||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint()
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def issuer(self):
|
|
||||||
return self.config.get('OIDC_ISSUER', self.config['OIDC_SERVER'])
|
|
||||||
|
|
||||||
def get_public_key(self, force_refresh=False):
|
|
||||||
""" Retrieves the public key for this handler. """
|
|
||||||
# If force_refresh is true, we expire all the items in the cache by setting the time to
|
|
||||||
# the current time + the expiration TTL.
|
|
||||||
if force_refresh:
|
|
||||||
self._public_key_cache.expire(time=time.time() + PUBLIC_KEY_CACHE_TTL)
|
|
||||||
|
|
||||||
# Retrieve the public key from the cache. If the cache does not contain the public key,
|
|
||||||
# it will internally call _get_public_key to retrieve it and then save it. The None is
|
|
||||||
# a random key chose to be stored in the cache, and could be anything.
|
|
||||||
return self._public_key_cache[None]
|
|
||||||
|
|
||||||
def _get_public_key(self, _):
|
|
||||||
""" Retrieves the public key for this handler. """
|
|
||||||
keys_url = self._oidc_config()['jwks_uri']
|
|
||||||
|
|
||||||
keys = KEYS()
|
|
||||||
keys.load_from_url(keys_url)
|
|
||||||
|
|
||||||
if not list(keys):
|
|
||||||
raise Exception('No keys provided by OIDC provider')
|
|
||||||
|
|
||||||
rsa_key = list(keys)[0]
|
|
||||||
rsa_key.deserialize()
|
|
||||||
|
|
||||||
# Reload the key so that we can give a key *instance* to PyJWT to work around its weird parsing
|
|
||||||
# issues.
|
|
||||||
return load_der_public_key(rsa_key.key.exportKey('DER'), backend=default_backend())
|
|
||||||
|
|
||||||
|
|
||||||
class DexOAuthConfig(OIDCConfig):
|
|
||||||
def service_name(self):
|
|
||||||
return 'Dex'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def public_title(self):
|
|
||||||
return self.get_public_config()['OIDC_TITLE']
|
|
||||||
|
|
||||||
def get_public_config(self):
|
|
||||||
return {
|
|
||||||
'CLIENT_ID': self.client_id(),
|
|
||||||
'AUTHORIZE_ENDPOINT': self.authorize_endpoint(),
|
|
||||||
|
|
||||||
# TODO(jschorr): This should ideally come from the Dex side.
|
|
||||||
'OIDC_TITLE': 'Dex',
|
|
||||||
'OIDC_LOGO': 'https://tectonic.com/assets/ico/favicon-96x96.png'
|
|
||||||
}
|
|
|
@ -22,7 +22,9 @@ from data.users.externaljwt import ExternalJWTAuthN
|
||||||
from data.users.externalldap import LDAPConnection, LDAPUsers
|
from data.users.externalldap import LDAPConnection, LDAPUsers
|
||||||
from data.users.keystone import get_keystone_users
|
from data.users.keystone import get_keystone_users
|
||||||
from storage import get_storage_driver
|
from storage import get_storage_driver
|
||||||
from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig
|
from oauth.services.github import GithubOAuthService
|
||||||
|
from oauth.services.google import GoogleOAuthService
|
||||||
|
from oauth.services.gitlab import GitLabOAuthService
|
||||||
from util.secscan.api import SecurityScannerAPI
|
from util.secscan.api import SecurityScannerAPI
|
||||||
from util.registry.torrent import torrent_jwt
|
from util.registry.torrent import torrent_jwt
|
||||||
from util.security.signing import SIGNING_ENGINES
|
from util.security.signing import SIGNING_ENGINES
|
||||||
|
@ -159,7 +161,7 @@ def _validate_gitlab(config, user_obj, _):
|
||||||
raise ConfigValidationException('Missing Client Secret')
|
raise ConfigValidationException('Missing Client Secret')
|
||||||
|
|
||||||
client = app.config['HTTPCLIENT']
|
client = app.config['HTTPCLIENT']
|
||||||
oauth = GitLabOAuthConfig(config, 'GITLAB_TRIGGER_CONFIG')
|
oauth = GitLabOAuthService(config, 'GITLAB_TRIGGER_CONFIG')
|
||||||
result = oauth.validate_client_id_and_secret(client, app.config)
|
result = oauth.validate_client_id_and_secret(client, app.config)
|
||||||
if not result:
|
if not result:
|
||||||
raise ConfigValidationException('Invalid client id or client secret')
|
raise ConfigValidationException('Invalid client id or client secret')
|
||||||
|
@ -193,7 +195,7 @@ def _validate_github_with_key(config_key, config):
|
||||||
'organization')
|
'organization')
|
||||||
|
|
||||||
client = app.config['HTTPCLIENT']
|
client = app.config['HTTPCLIENT']
|
||||||
oauth = GithubOAuthConfig(config, config_key)
|
oauth = GithubOAuthService(config, config_key)
|
||||||
result = oauth.validate_client_id_and_secret(client, app.config)
|
result = oauth.validate_client_id_and_secret(client, app.config)
|
||||||
if not result:
|
if not result:
|
||||||
raise ConfigValidationException('Invalid client id or client secret')
|
raise ConfigValidationException('Invalid client id or client secret')
|
||||||
|
@ -239,7 +241,7 @@ def _validate_google_login(config, user_obj, _):
|
||||||
raise ConfigValidationException('Missing Client Secret')
|
raise ConfigValidationException('Missing Client Secret')
|
||||||
|
|
||||||
client = app.config['HTTPCLIENT']
|
client = app.config['HTTPCLIENT']
|
||||||
oauth = GoogleOAuthConfig(config, 'GOOGLE_LOGIN_CONFIG')
|
oauth = GoogleOAuthService(config, 'GOOGLE_LOGIN_CONFIG')
|
||||||
result = oauth.validate_client_id_and_secret(client, app.config)
|
result = oauth.validate_client_id_and_secret(client, app.config)
|
||||||
if not result:
|
if not result:
|
||||||
raise ConfigValidationException('Invalid client id or client secret')
|
raise ConfigValidationException('Invalid client id or client secret')
|
||||||
|
|
Reference in a new issue