diff --git a/oauth/loginmanager.py b/oauth/loginmanager.py index 457fc2343..2504d661b 100644 --- a/oauth/loginmanager.py +++ b/oauth/loginmanager.py @@ -12,7 +12,7 @@ PREFIX_BLACKLIST = ['ldap', 'jwt', 'keystone'] class OAuthLoginManager(object): """ Helper class which manages all registered OAuth login services. """ - def __init__(self, config): + def __init__(self, config, client=None): self.services = [] # Register the endpoints for each of the OAuth login services. @@ -28,7 +28,7 @@ class OAuthLoginManager(object): if prefix in PREFIX_BLACKLIST: raise Exception('Cannot use reserved config name %s' % key) - self.services.append(OIDCLoginService(config, key)) + self.services.append(OIDCLoginService(config, key, client=client)) def get_service(self, service_id): for service in self.services: diff --git a/oauth/oidc.py b/oauth/oidc.py index f858bb1b3..f3dab2032 100644 --- a/oauth/oidc.py +++ b/oauth/oidc.py @@ -34,12 +34,12 @@ class PublicKeyLoadException(Exception): class OIDCLoginService(OAuthService): """ Defines a generic service for all OpenID-connect compatible login services. """ - def __init__(self, config, key_name): + def __init__(self, config, key_name, client=None): 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._http_client = client or config['HTTPCLIENT'] self._mailing = config.get('FEATURE_MAILING', False) def service_id(self): @@ -71,6 +71,9 @@ class OIDCLoginService(OAuthService): def user_endpoint(self): return self._oidc_config().get('userinfo_endpoint') + def validate(self): + return bool(self.user_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()) diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 89a82ad7d..c273a6633 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -431,6 +431,18 @@ a:focus { border-top: 1px solid #eee; } +.co-panel-body .co-panel-heading { + font-size: 120%; + border-bottom: 0px; + margin: 0px; + margin-bottom: -6px; +} + +.co-panel-body .co-panel-body { + padding-left: 38px; +} + + .config-bool-field-element input { margin-right: 6px; font-size: 24px; diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 23b043399..12caf46cd 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -525,17 +525,18 @@ - +
- Authentication + Internal Authentication

Authentication for the registry can be handled by either the registry itself, LDAP or external JWT endpoint. -
- Additional external authentication providers (such as GitHub) can be used on top of this choice. +

+

+ Additional external authentication providers (such as GitHub) can be used in addition for login into the UI.

@@ -807,134 +808,228 @@
-
+ - -
+
- GitHub (Enterprise) Authentication + External Authorization (OAuth)
-
-

- If enabled, users can use GitHub or GitHub Enterprise to authenticate to the registry. -

-

- Note: A registered GitHub (Enterprise) OAuth application is required. - View instructions on how to - - Create an OAuth Application in GitHub - -

+ +
+
+ GitHub (Enterprise) Authentication +
+
+
+

+ If enabled, users can use GitHub or GitHub Enterprise to authenticate to the registry. +

+

+ Note: A registered GitHub (Enterprise) OAuth application is required. + View instructions on how to + + Create an OAuth Application in GitHub + +

+
+ +
+ Enable GitHub Authentication +
+ + + + + + + + + + + + + + + + + + + + + + +
GitHub: + +
GitHub Endpoint: + + +
+ The GitHub Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
Organization Filtering: +
+ Restrict By Organization Membership +
+ +
+ If enabled, only members of specified GitHub + Enterprise organizations will be allowed to login via GitHub + Enterprise. +
+ + + +
+
+
+ + +
+
+ Google Authentication +
+
+
+

+ If enabled, users can use Google to authenticate to the registry. +

+

+ Note: A registered Google OAuth application is required. + Visit the + + Google Developer Console + + to register an application. +

+
+ +
+ Enable Google Authentication +
+ + + + + + + + + + +
OAuth Client ID: + + +
OAuth Client Secret: + + +
+
+
+ + +
+
+ + {{ config[provider]['SERVICE_NAME'] || (getOIDCProviderId(provider) + ' Authentication') }} + (Delete) +
+
+
+ Warning: This OIDC provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + + + + + + + + + +
Service ID: + {{ getOIDCProviderId(provider) }} +
OIDC Server: + + +
+ The URL of an OIDC-compliant server. +
+
Service Name: + + +
+ The user friendly name to display for the service on the login page. +
+
Service Icon (optional): + + +
+ If specified, the icon to display for this login service on the login page. Can be either a URL to an icon or a CSS class name from Font Awesome +
+
Binding Field: + +
+ If selected, when a user logs in via this OIDC provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the OIDC provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this OIDC provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this OIDC provider. This is not the recommended setup. +
+
+ +
-
- Enable GitHub Authentication -
- - - - - - - - - - - - - - - - - - - - - - -
GitHub: - -
GitHub Endpoint: - - -
- The GitHub Enterprise endpoint. Must start with http:// or https://. -
-
OAuth Client ID: - - -
OAuth Client Secret: - - -
Organization Filtering: -
- Restrict By Organization Membership -
- -
- If enabled, only members of specified GitHub - Enterprise organizations will be allowed to login via GitHub - Enterprise. -
- - - -
+ + Add OIDC Provider + What is OIDC?
-
+
- -
-
- Google Authentication -
-
-
-

- If enabled, users can use Google to authenticate to the registry. -

-

- Note: A registered Google OAuth application is required. - Visit the - - Google Developer Console - - to register an application. -

-
- -
- Enable Google Authentication -
- - - - - - - - - - -
OAuth Client ID: - - -
OAuth Client Secret: - - -
-
-
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index 827a78871..1347e82b0 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -71,7 +71,11 @@ angular.module("core-config-setup", ['angularFileUpload']) {'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) { return config.FEATURE_BITTORRENT; - }} + }}, + + {'id': 'oidc-login', 'title': 'OIDC Login(s)', 'condition': function(config) { + return $scope.getOIDCProviders(config).length > 0; + }}, ]; $scope.STORAGE_CONFIG_FIELDS = { @@ -147,6 +151,59 @@ angular.module("core-config-setup", ['angularFileUpload']) $scope.validating = null; $scope.savingConfiguration = false; + $scope.removeOIDCProvider = function(provider) { + delete $scope.config[provider]; + }; + + $scope.addOIDCProvider = function() { + bootbox.prompt('Enter an ID for the OIDC provider', function(result) { + if (!result) { + return; + } + + result = result.toUpperCase(); + + if (!result.match(/^[A-Z0-9]+$/)) { + bootbox.alert('Invalid ID for OIDC provider: must be alphanumeric'); + return; + } + + if (result == 'GITHUB' || result == 'GOOGLE') { + bootbox.alert('Invalid ID for OIDC provider: cannot be a reserved name'); + return; + } + + var key = result + '_LOGIN_CONFIG'; + if ($scope.config[key]) { + bootbox.alert('Invalid ID for OIDC provider: already exists'); + return; + } + + $scope.config[key] = {}; + }); + }; + + $scope.getOIDCProviderId = function(key) { + var index = key.indexOf('_LOGIN_CONFIG'); + if (index <= 0) { + return null; + } + + return key.substr(0, index); + }; + + $scope.getOIDCProviders = function(config) { + var keys = Object.keys(config || {}); + return keys.filter(function(key) { + if (key == 'GITHUB_LOGIN_CONFIG' || key == 'GOOGLE_LOGIN_CONFIG') { + // Has custom UI and config. + return false; + } + + return !!$scope.getOIDCProviderId(key); + }); + }; + $scope.getServices = function(config) { var services = []; if (!config) { return services; } diff --git a/util/config/validator.py b/util/config/validator.py index 9dd9246ae..ae3f02be8 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -18,6 +18,7 @@ from util.config.validators.validate_google_login import GoogleLoginValidator from util.config.validators.validate_bitbucket_trigger import BitbucketTriggerValidator from util.config.validators.validate_gitlab_trigger import GitLabTriggerValidator from util.config.validators.validate_github import GitHubLoginValidator, GitHubTriggerValidator +from util.config.validators.validate_oidc import OIDCLoginValidator logger = logging.getLogger(__name__) @@ -51,6 +52,7 @@ VALIDATORS = { SignerValidator.name: SignerValidator.validate, SecurityScannerValidator.name: SecurityScannerValidator.validate, BittorrentValidator.name: BittorrentValidator.validate, + OIDCLoginValidator.name: OIDCLoginValidator.validate, } def validate_service_for_config(service, config, password=None): diff --git a/util/config/validators/test/test_validate_oidc.py b/util/config/validators/test/test_validate_oidc.py new file mode 100644 index 000000000..0bc3c89a6 --- /dev/null +++ b/util/config/validators/test/test_validate_oidc.py @@ -0,0 +1,38 @@ +import json +import pytest + +from httmock import urlmatch, HTTMock + +from oauth.oidc import OIDC_WELLKNOWN +from util.config.validators import ConfigValidationException +from util.config.validators.validate_oidc import OIDCLoginValidator + +@pytest.mark.parametrize('unvalidated_config', [ + ({'SOMETHING_LOGIN_CONFIG': {}}), +]) +def test_validate_invalid_oidc_login_config(unvalidated_config): + validator = OIDCLoginValidator() + + with pytest.raises(ConfigValidationException): + validator.validate(unvalidated_config, None, None) + +def test_validate_oidc_login(): + url_hit = [False] + @urlmatch(netloc=r'someserver', path=r'/\.well-known/openid-configuration') + def handler(_, __): + url_hit[0] = True + data = { + 'userinfo_endpoint': 'foobar', + } + return {'status_code': 200, 'content': json.dumps(data)} + + with HTTMock(handler): + validator = OIDCLoginValidator() + validator.validate({ + 'SOMETHING_LOGIN_CONFIG': { + 'OIDC_SERVER': 'http://someserver', + 'DEBUGGING': True, # Allows for HTTP. + }, + }, None, None) + + assert url_hit[0] diff --git a/util/config/validators/validate_oidc.py b/util/config/validators/validate_oidc.py new file mode 100644 index 000000000..84e626b46 --- /dev/null +++ b/util/config/validators/validate_oidc.py @@ -0,0 +1,27 @@ +from app import app +from oauth.loginmanager import OAuthLoginManager +from oauth.oidc import OIDCLoginService, DiscoveryFailureException +from util.config.validators import BaseValidator, ConfigValidationException + +class OIDCLoginValidator(BaseValidator): + name = "oidc-login" + + @classmethod + def validate(cls, config, user, user_password): + client = app.config['HTTPCLIENT'] + login_manager = OAuthLoginManager(config, client=client) + for service in login_manager.services: + if not isinstance(service, OIDCLoginService): + continue + + if service.config.get('OIDC_SERVER') is None: + msg = 'Missing OIDC_SERVER on OIDC service %s' % service.service_id() + raise ConfigValidationException(msg) + + try: + if not service.validate(): + msg = 'Could not validate OIDC service %s' % service.service_id() + raise ConfigValidationException(msg) + except DiscoveryFailureException as dfe: + msg = 'Could not validate OIDC service %s: %s' % (service.service_id(), dfe.message) + raise ConfigValidationException(msg)