Merge pull request #2393 from coreos-inc/oidc-ui

OIDC configuration support in superuser config panel
This commit is contained in:
josephschorr 2017-03-10 12:13:48 -05:00 committed by GitHub
commit 6d6be63ca6
8 changed files with 363 additions and 129 deletions

View file

@ -12,7 +12,7 @@ PREFIX_BLACKLIST = ['ldap', 'jwt', 'keystone']
class OAuthLoginManager(object): class OAuthLoginManager(object):
""" Helper class which manages all registered OAuth login services. """ """ Helper class which manages all registered OAuth login services. """
def __init__(self, config): def __init__(self, config, client=None):
self.services = [] self.services = []
# Register the endpoints for each of the OAuth login services. # Register the endpoints for each of the OAuth login services.
@ -28,7 +28,7 @@ class OAuthLoginManager(object):
if prefix in PREFIX_BLACKLIST: if prefix in PREFIX_BLACKLIST:
raise Exception('Cannot use reserved config name %s' % key) 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): def get_service(self, service_id):
for service in self.services: for service in self.services:

View file

@ -34,12 +34,12 @@ class PublicKeyLoadException(Exception):
class OIDCLoginService(OAuthService): class OIDCLoginService(OAuthService):
""" Defines a generic service for all OpenID-connect compatible login services. """ """ 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) super(OIDCLoginService, self).__init__(config, key_name)
self._public_key_cache = TTLCache(1, PUBLIC_KEY_CACHE_TTL, missing=self._load_public_key) 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._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) self._mailing = config.get('FEATURE_MAILING', False)
def service_id(self): def service_id(self):
@ -71,6 +71,9 @@ class OIDCLoginService(OAuthService):
def user_endpoint(self): def user_endpoint(self):
return self._oidc_config().get('userinfo_endpoint') 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): def validate_client_id_and_secret(self, http_client, app_config):
# TODO: find a way to verify client secret too. # TODO: find a way to verify client secret too.
check_auth_url = http_client.get(self.get_auth_url()) check_auth_url = http_client.get(self.get_auth_url())

View file

@ -431,6 +431,18 @@ a:focus {
border-top: 1px solid #eee; 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 { .config-bool-field-element input {
margin-right: 6px; margin-right: 6px;
font-size: 24px; font-size: 24px;

View file

@ -525,17 +525,18 @@
</div> </div>
</div> <!-- /E-mail --> </div> <!-- /E-mail -->
<!-- Authentication --> <!-- Internal Authentication -->
<div class="co-panel"> <div class="co-panel">
<div class="co-panel-heading"> <div class="co-panel-heading">
<i class="fa fa-users"></i> Authentication <i class="fa fa-users"></i> Internal Authentication
</div> </div>
<div class="co-panel-body"> <div class="co-panel-body">
<div class="description"> <div class="description">
<p> <p>
Authentication for the registry can be handled by either the registry itself, LDAP or external JWT endpoint. Authentication for the registry can be handled by either the registry itself, LDAP or external JWT endpoint.
<br> </p>
Additional external authentication providers (such as GitHub) can be used on top of this choice. <p>
Additional <strong>external</strong> authentication providers (such as GitHub) can be used in addition for <strong>login into the UI</strong>.
</p> </p>
</div> </div>
@ -807,134 +808,228 @@
</tr> </tr>
</table> </table>
</div> </div>
</div> <!-- /Authentication --> </div> <!-- / Internal Authentication -->
<!-- GitHub Authentication --> <div class="co-panel"> <!-- External Authentication -->
<div class="co-panel">
<div class="co-panel-heading"> <div class="co-panel-heading">
<i class="fa fa-github"></i> GitHub (Enterprise) Authentication <i class="fa fa-id-card"></i> External Authorization (OAuth)
</div> </div>
<div class="co-panel-body"> <div class="co-panel-body">
<div class="description"> <!-- GitHub Authentication -->
<p> <div class="co-panel">
If enabled, users can use GitHub or GitHub Enterprise to authenticate to the registry. <div class="co-panel-heading">
</p> <i class="fa fa-github"></i> GitHub (Enterprise) Authentication
<p> </div>
<strong>Note:</strong> A registered GitHub (Enterprise) OAuth application is required. <div class="co-panel-body">
View instructions on how to <div class="description">
<a href="https://coreos.com/docs/enterprise-registry/github-app/" ng-safenewtab> <p>
Create an OAuth Application in GitHub If enabled, users can use GitHub or GitHub Enterprise to authenticate to the registry.
</a> </p>
</p> <p>
<strong>Note:</strong> A registered GitHub (Enterprise) OAuth application is required.
View instructions on how to
<a href="https://coreos.com/docs/enterprise-registry/github-app/" ng-safenewtab>
Create an OAuth Application in GitHub
</a>
</p>
</div>
<div class="config-bool-field" binding="config.FEATURE_GITHUB_LOGIN">
Enable GitHub Authentication
</div>
<table class="config-table" ng-if="config.FEATURE_GITHUB_LOGIN">
<tr>
<td>GitHub:</td>
<td>
<select class="form-control" ng-model="mapped.GITHUB_LOGIN_KIND">
<option value="hosted">GitHub.com</option>
<option value="enterprise">GitHub Enterprise</option>
</select>
</td>
</tr>
<tr ng-if="mapped.GITHUB_LOGIN_KIND == 'enterprise'">
<td>GitHub Endpoint:</td>
<td>
<span class="config-string-field"
binding="config.GITHUB_LOGIN_CONFIG.GITHUB_ENDPOINT"
placeholder="https://my.githubserver"
pattern="{{ GITHOST_REGEX }}">
</span>
<div class="help-text">
The GitHub Enterprise endpoint. Must start with http:// or https://.
</div>
</td>
</tr>
<tr>
<td>OAuth Client ID:</td>
<td>
<span class="config-string-field" binding="config.GITHUB_LOGIN_CONFIG.CLIENT_ID">
</span>
</td>
</tr>
<tr>
<td>OAuth Client Secret:</td>
<td>
<span class="config-string-field" binding="config.GITHUB_LOGIN_CONFIG.CLIENT_SECRET">
</span>
</td>
</tr>
<tr>
<td>Organization Filtering:</td>
<td>
<div class="config-bool-field" binding="config.GITHUB_LOGIN_CONFIG.ORG_RESTRICT">
Restrict By Organization Membership
</div>
<div class="help-text" style="margin-bottom: 20px;">
If enabled, only members of specified GitHub
<span ng-if="mapped.GITHUB_LOGIN_KIND == 'enterprise'">Enterprise</span> organizations will be allowed to login via GitHub
<span ng-if="mapped.GITHUB_LOGIN_KIND == 'enterprise'">Enterprise</span>.
</div>
<span class="config-list-field"
item-title="Organization ID"
binding="config.GITHUB_LOGIN_CONFIG.ALLOWED_ORGANIZATIONS"
ng-if="config.GITHUB_LOGIN_CONFIG.ORG_RESTRICT">
</span>
</td>
</tr>
</table>
</div>
</div> <!-- /GitHub Authentication -->
<!-- Google Authentication -->
<div class="co-panel">
<div class="co-panel-heading">
<i class="fa fa-google"></i> Google Authentication
</div>
<div class="co-panel-body">
<div class="description">
<p>
If enabled, users can use Google to authenticate to the registry.
</p>
<p>
<strong>Note:</strong> A registered Google OAuth application is required.
Visit the
<a href="https://console.developers.google.com" ng-safenewtab>
Google Developer Console
</a>
to register an application.
</p>
</div>
<div class="config-bool-field" binding="config.FEATURE_GOOGLE_LOGIN">
Enable Google Authentication
</div>
<table class="config-table" ng-if="config.FEATURE_GOOGLE_LOGIN">
<tr>
<td>OAuth Client ID:</td>
<td>
<span class="config-string-field" binding="config.GOOGLE_LOGIN_CONFIG.CLIENT_ID">
</span>
</td>
</tr>
<tr>
<td>OAuth Client Secret:</td>
<td>
<span class="config-string-field" binding="config.GOOGLE_LOGIN_CONFIG.CLIENT_SECRET">
</span>
</td>
</tr>
</table>
</div>
</div> <!-- /Google Authentication -->
<!-- Custom OIDC providers -->
<div class="co-panel" ng-repeat="provider in getOIDCProviders(config)">
<div class="co-panel-heading">
<span class="icon-image-view" value="{{ config[provider]['SERVICE_ICON'] || 'fa-user-circle' }}" style="margin-right: 6px;"></span>
{{ config[provider]['SERVICE_NAME'] || (getOIDCProviderId(provider) + ' Authentication') }}
<span style="display: inline-block; margin-left: 10px">(<a href="javascript:void(0)" ng-click="removeOIDCProvider(provider)">Delete</a>)</span>
</div>
<div class="co-panel-body">
<div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE != 'Database' && !(config[provider].LOGIN_BINDING_FIELD)">
Warning: This OIDC provider is not bound to your <strong>{{ config.AUTHENTICATION_TYPE }}</strong> authentication. Logging in via this provider will create a <strong><span class="registry-name"></span>-only user</strong>, which is not the recommended approach. It is <strong>highly</strong> recommended to choose a "Binding Field" below.
</div>
<table class="config-table">
<tr>
<td class="non-input">Service ID:</td>
<td>
<code>{{ getOIDCProviderId(provider) }}</code>
</td>
</tr>
<tr>
<td>OIDC Server:</td>
<td>
<span class="config-string-field"
binding="config[provider].OIDC_SERVER"
placeholder="https://path/to/oidc/compliant/server"
pattern="https://.+">
</span>
<div class="help-text">
The URL of an OIDC-compliant server.
</div>
</td>
</tr>
<tr>
<td>Service Name:</td>
<td>
<span class="config-string-field"
binding="config[provider].SERVICE_NAME"
placeholder="My Authentication Service">
</span>
<div class="help-text">
The user friendly name to display for the service on the login page.
</div>
</td>
</tr>
<tr>
<td>Service Icon (optional):</td>
<td>
<span class="config-string-field"
binding="config[provider].SERVICE_ICON"
placeholder="URL of the icon to use for this service OR a font awesome CSS name"
is-optional="true">
</span>
<div class="help-text">
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 <a href="http://fontawesome.io" ng-safenewtab>Font Awesome</a>
</div>
</td>
</tr>
<tr ng-if="config.AUTHENTICATION_TYPE != 'Database'">
<td>Binding Field:</td>
<td>
<select class="form-control" ng-model="config[provider].LOGIN_BINDING_FIELD">
<option value="">(None)</option>
<option value="sub">Subject (User ID)</option>
<option value="username">Username</option>
<option value="email">E-mail address</option>
</select>
<div class="help-text">
If selected, when a user logs in via this OIDC provider, they will be automatically bound to their user in <strong>{{ config.AUTHENTICATION_TYPE }}</strong> by matching the selected field from the OIDC provider to the associated user in {{ config.AUTHENTICATION_TYPE }}.
</div>
<div class="help-text">
For example, selecting <code>Subject</code> 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.
</div>
<div class="help-text">
If none selected, a <strong>user unique to <span class="registry-name"></span></strong> will be created on initial login with this OIDC provider. <strong>This is not the recommended setup.</strong>
</div>
</td>
</tr>
</table>
</div>
</div> </div>
<div class="config-bool-field" binding="config.FEATURE_GITHUB_LOGIN"> <!-- Add Provider -->
Enable GitHub Authentication <a class="btn btn-default" ng-click="addOIDCProvider()" style="margin-right: 6px;">Add OIDC Provider</a>
</div> <a href="http://openid.net/connect/" ng-safenewtab>What is OIDC?</a>
<table class="config-table" ng-if="config.FEATURE_GITHUB_LOGIN">
<tr>
<td>GitHub:</td>
<td>
<select class="form-control" ng-model="mapped.GITHUB_LOGIN_KIND">
<option value="hosted">GitHub.com</option>
<option value="enterprise">GitHub Enterprise</option>
</select>
</td>
</tr>
<tr ng-if="mapped.GITHUB_LOGIN_KIND == 'enterprise'">
<td>GitHub Endpoint:</td>
<td>
<span class="config-string-field"
binding="config.GITHUB_LOGIN_CONFIG.GITHUB_ENDPOINT"
placeholder="https://my.githubserver"
pattern="{{ GITHOST_REGEX }}">
</span>
<div class="help-text">
The GitHub Enterprise endpoint. Must start with http:// or https://.
</div>
</td>
</tr>
<tr>
<td>OAuth Client ID:</td>
<td>
<span class="config-string-field" binding="config.GITHUB_LOGIN_CONFIG.CLIENT_ID">
</span>
</td>
</tr>
<tr>
<td>OAuth Client Secret:</td>
<td>
<span class="config-string-field" binding="config.GITHUB_LOGIN_CONFIG.CLIENT_SECRET">
</span>
</td>
</tr>
<tr>
<td>Organization Filtering:</td>
<td>
<div class="config-bool-field" binding="config.GITHUB_LOGIN_CONFIG.ORG_RESTRICT">
Restrict By Organization Membership
</div>
<div class="help-text" style="margin-bottom: 20px;">
If enabled, only members of specified GitHub
<span ng-if="mapped.GITHUB_LOGIN_KIND == 'enterprise'">Enterprise</span> organizations will be allowed to login via GitHub
<span ng-if="mapped.GITHUB_LOGIN_KIND == 'enterprise'">Enterprise</span>.
</div>
<span class="config-list-field"
item-title="Organization ID"
binding="config.GITHUB_LOGIN_CONFIG.ALLOWED_ORGANIZATIONS"
ng-if="config.GITHUB_LOGIN_CONFIG.ORG_RESTRICT">
</span>
</td>
</tr>
</table>
</div> </div>
</div> <!-- /GitHub Authentication --> </div> <!-- /External Authentication -->
<!-- Google Authentication -->
<div class="co-panel">
<div class="co-panel-heading">
<i class="fa fa-google"></i> Google Authentication
</div>
<div class="co-panel-body">
<div class="description">
<p>
If enabled, users can use Google to authenticate to the registry.
</p>
<p>
<strong>Note:</strong> A registered Google OAuth application is required.
Visit the
<a href="https://console.developers.google.com" ng-safenewtab>
Google Developer Console
</a>
to register an application.
</p>
</div>
<div class="config-bool-field" binding="config.FEATURE_GOOGLE_LOGIN">
Enable Google Authentication
</div>
<table class="config-table" ng-if="config.FEATURE_GOOGLE_LOGIN">
<tr>
<td>OAuth Client ID:</td>
<td>
<span class="config-string-field" binding="config.GOOGLE_LOGIN_CONFIG.CLIENT_ID">
</span>
</td>
</tr>
<tr>
<td>OAuth Client Secret:</td>
<td>
<span class="config-string-field" binding="config.GOOGLE_LOGIN_CONFIG.CLIENT_SECRET">
</span>
</td>
</tr>
</table>
</div>
</div> <!-- /Google Authentication -->
<!-- Build Support --> <!-- Build Support -->
<div class="co-panel"> <div class="co-panel">

View file

@ -71,7 +71,11 @@ angular.module("core-config-setup", ['angularFileUpload'])
{'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) { {'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) {
return config.FEATURE_BITTORRENT; return config.FEATURE_BITTORRENT;
}} }},
{'id': 'oidc-login', 'title': 'OIDC Login(s)', 'condition': function(config) {
return $scope.getOIDCProviders(config).length > 0;
}},
]; ];
$scope.STORAGE_CONFIG_FIELDS = { $scope.STORAGE_CONFIG_FIELDS = {
@ -147,6 +151,59 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.validating = null; $scope.validating = null;
$scope.savingConfiguration = false; $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) { $scope.getServices = function(config) {
var services = []; var services = [];
if (!config) { return services; } if (!config) { return services; }

View file

@ -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_bitbucket_trigger import BitbucketTriggerValidator
from util.config.validators.validate_gitlab_trigger import GitLabTriggerValidator from util.config.validators.validate_gitlab_trigger import GitLabTriggerValidator
from util.config.validators.validate_github import GitHubLoginValidator, GitHubTriggerValidator from util.config.validators.validate_github import GitHubLoginValidator, GitHubTriggerValidator
from util.config.validators.validate_oidc import OIDCLoginValidator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,6 +52,7 @@ VALIDATORS = {
SignerValidator.name: SignerValidator.validate, SignerValidator.name: SignerValidator.validate,
SecurityScannerValidator.name: SecurityScannerValidator.validate, SecurityScannerValidator.name: SecurityScannerValidator.validate,
BittorrentValidator.name: BittorrentValidator.validate, BittorrentValidator.name: BittorrentValidator.validate,
OIDCLoginValidator.name: OIDCLoginValidator.validate,
} }
def validate_service_for_config(service, config, password=None): def validate_service_for_config(service, config, password=None):

View file

@ -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]

View file

@ -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)