From c0286d1ac3ba8320b26f2a0fef6945f2f3f9d512 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 4 Sep 2015 16:14:46 -0400 Subject: [PATCH] Add support for Dex to Quay Fixes #306 - Adds support for Dex as an OAuth external login provider - Adds support for OIDC in general - Extract out external logins on the JS side into a service - Add a feature flag for disabling direct login - Add support for directing to the single external login service - Does *not* yet support the config in the superuser tool --- app.py | 8 +- config.py | 6 + .../3a3bb77e17d5_add_support_for_dex_login.py | 26 ++++ endpoints/api/user.py | 3 + endpoints/oauthlogin.py | 91 ++++++++++- initdb.py | 1 + .../directives/ui/external-login-button.css | 7 +- .../directives/ui/external-logins-manager.css | 5 +- static/css/directives/ui/signup-form.css | 10 ++ static/directives/external-login-button.html | 34 ++--- .../directives/external-logins-manager.html | 55 +++---- static/directives/header-bar.html | 6 +- static/directives/signin-form.html | 19 ++- static/directives/signup-form.html | 65 ++++---- static/directives/user-setup.html | 4 +- .../js/directives/ui/external-login-button.js | 10 +- .../directives/ui/external-logins-manager.js | 24 ++- static/js/directives/ui/header-bar.js | 5 +- static/js/directives/ui/signin-form.js | 6 +- static/js/directives/ui/signup-form.js | 4 +- static/js/pages/signin.js | 7 +- static/js/pages/user-view.js | 3 +- static/js/services/external-login-service.js | 141 ++++++++++++++++++ static/js/services/key-service.js | 40 +---- static/partials/user-view.html | 5 +- templates/ologinerror.html | 2 +- util/config/oauth.py | 122 ++++++++++++++- 27 files changed, 533 insertions(+), 176 deletions(-) create mode 100644 data/migrations/versions/3a3bb77e17d5_add_support_for_dex_login.py create mode 100644 static/js/services/external-login-service.js diff --git a/app.py b/app.py index ebb5a3391..68e527ee2 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,9 @@ from util import get_app_url from util.saas.analytics import Analytics from util.saas.exceptionlog import Sentry from util.names import urn_generator -from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig +from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig, + DexOAuthConfig) + from util.security.signing import Signer from util.saas.cloudwatch import start_cloudwatch_sender from util.saas.metricqueue import MetricQueue @@ -135,7 +137,9 @@ github_login = GithubOAuthConfig(app.config, 'GITHUB_LOGIN_CONFIG') github_trigger = GithubOAuthConfig(app.config, 'GITHUB_TRIGGER_CONFIG') gitlab_trigger = GitLabOAuthConfig(app.config, 'GITLAB_TRIGGER_CONFIG') google_login = GoogleOAuthConfig(app.config, 'GOOGLE_LOGIN_CONFIG') -oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login] +dex_login = DexOAuthConfig(app.config, 'DEX_LOGIN_CONFIG') + +oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login, dex_login] image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf) image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf) diff --git a/config.py b/config.py index ea65ad54e..f97447f45 100644 --- a/config.py +++ b/config.py @@ -153,6 +153,9 @@ class DefaultConfig(object): # Feature Flag: Whether Google login is supported. FEATURE_GOOGLE_LOGIN = False + # Feature Flag: Whther Dex login is supported. + FEATURE_DEX_LOGIN = False + # Feature flag, whether to enable olark chat FEATURE_OLARK_CHAT = False @@ -184,6 +187,9 @@ class DefaultConfig(object): # Feature Flag: Whether to automatically replicate between storage engines. FEATURE_STORAGE_REPLICATION = False + # Feature Flag: Whether users can directly login to the UI. + FEATURE_DIRECT_LOGIN = True + BUILD_MANAGER = ('enterprise', {}) DISTRIBUTED_STORAGE_CONFIG = { diff --git a/data/migrations/versions/3a3bb77e17d5_add_support_for_dex_login.py b/data/migrations/versions/3a3bb77e17d5_add_support_for_dex_login.py new file mode 100644 index 000000000..5e883237e --- /dev/null +++ b/data/migrations/versions/3a3bb77e17d5_add_support_for_dex_login.py @@ -0,0 +1,26 @@ +"""Add support for Dex login + +Revision ID: 3a3bb77e17d5 +Revises: 9512773a4a2 +Create Date: 2015-09-04 15:57:38.007822 + +""" + +# revision identifiers, used by Alembic. +revision = '3a3bb77e17d5' +down_revision = '9512773a4a2' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + op.bulk_insert(tables.loginservice, [{'id': 7, 'name': 'dex'}]) + + +def downgrade(tables): + op.execute( + tables.loginservice.delete() + .where(tables.loginservice.c.name == op.inline_literal('dex')) + ) + diff --git a/endpoints/api/user.py b/endpoints/api/user.py index de928597f..7c3094cc7 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -306,6 +306,7 @@ class User(ApiResource): return user_view(user) @show_if(features.USER_CREATION) + @show_if(features.DIRECT_LOGIN) @nickname('createNewUser') @internal_only @validate_json_request('NewUser') @@ -496,6 +497,7 @@ class ConvertToOrganization(ApiResource): @resource('/v1/signin') +@show_if(features.DIRECT_LOGIN) @internal_only class Signin(ApiResource): """ Operations for signing in the user. """ @@ -595,6 +597,7 @@ class Signout(ApiResource): @resource('/v1/detachexternal/') +@show_if(features.DIRECT_LOGIN) @internal_only class DetachExternal(ApiResource): """ Resource for detaching an external login. """ diff --git a/endpoints/oauthlogin.py b/endpoints/oauthlogin.py index ae41af0ef..665801a6d 100644 --- a/endpoints/oauthlogin.py +++ b/endpoints/oauthlogin.py @@ -5,7 +5,7 @@ from flask import request, redirect, url_for, Blueprint from flask.ext.login import current_user from endpoints.common import render_page_template, common_login, route_show_if -from app import app, analytics, get_app_url, github_login, google_login +from app import app, analytics, get_app_url, github_login, google_login, dex_login from data import model from util.names import parse_repository_name from util.validation import generate_valid_usernames @@ -14,6 +14,7 @@ from auth.auth import require_session_login from peewee import IntegrityError import features +from util.security.strictjwt import decode, InvalidTokenError logger = logging.getLogger(__name__) client = app.config['HTTPCLIENT'] @@ -24,7 +25,7 @@ def render_ologin_error(service_name, return render_page_template('ologinerror.html', service_name=service_name, error_message=error_message, service_url=get_app_url(), - user_creation=features.USER_CREATION) + user_creation=features.USER_CREATION and features.DIRECT_LOGIN) def get_user(service, token): @@ -86,7 +87,7 @@ def conduct_oauth_login(service, user_id, username, email, metadata={}): return render_ologin_error(service_name) -def get_google_username(user_data): +def get_email_username(user_data): username = user_data['email'] at = username.find('@') if at > 0: @@ -108,7 +109,7 @@ def google_oauth_callback(): if not user_data or not user_data.get('id', None) or not user_data.get('email', None): return render_ologin_error('Google') - username = get_google_username(user_data) + username = get_email_username(user_data) metadata = { 'service_username': user_data['email'] } @@ -194,7 +195,7 @@ def google_oauth_attach(): google_id = user_data['id'] user_obj = current_user.db_user() - username = get_google_username(user_data) + username = get_email_username(user_data) metadata = { 'service_username': user_data['email'] } @@ -236,3 +237,83 @@ def github_oauth_attach(): return render_ologin_error('GitHub', err) return redirect(url_for('web.user')) + + +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) +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) + + 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 +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 not token: + return render_ologin_error(dex_login.public_title) + + try: + payload = decode_user_jwt(token, dex_login) + except jwt.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')) diff --git a/initdb.py b/initdb.py index be50d12c4..57024f00d 100644 --- a/initdb.py +++ b/initdb.py @@ -218,6 +218,7 @@ def initialize_database(): LoginService.create(name='ldap') LoginService.create(name='jwtauthn') LoginService.create(name='keystone') + LoginService.create(name='dex') BuildTriggerService.create(name='github') BuildTriggerService.create(name='custom-git') diff --git a/static/css/directives/ui/external-login-button.css b/static/css/directives/ui/external-login-button.css index 390eda57d..8cf5ff69e 100644 --- a/static/css/directives/ui/external-login-button.css +++ b/static/css/directives/ui/external-login-button.css @@ -1,3 +1,8 @@ -.external-login-button i.fa { +.external-login-button i.fa, +.external-login-button img { margin-right: 4px; + width: 24px; + font-size: 18px; + text-align: center; + vertical-align: middle; } \ No newline at end of file diff --git a/static/css/directives/ui/external-logins-manager.css b/static/css/directives/ui/external-logins-manager.css index 2c5ca7302..947870dbd 100644 --- a/static/css/directives/ui/external-logins-manager.css +++ b/static/css/directives/ui/external-logins-manager.css @@ -6,6 +6,9 @@ font-size: 18px; } -.external-logins-manager .external-auth-provider td:first-child i.fa { +.external-logins-manager .external-auth-provider-title i.fa, +.external-logins-manager .external-auth-provider-title img { margin-right: 6px; + width: 24px; + text-align: center; } \ No newline at end of file diff --git a/static/css/directives/ui/signup-form.css b/static/css/directives/ui/signup-form.css index 5a3dede2f..1d71692ee 100644 --- a/static/css/directives/ui/signup-form.css +++ b/static/css/directives/ui/signup-form.css @@ -4,4 +4,14 @@ .signup-form-element .co-alert { color: black; +} + +.signup-form-element .single-sign-on a { + font-size: 24px; +} + +.signup-form-element .single-sign-on .external-login-button i.fa, +.signup-form-element .single-sign-on .external-login-button img { + width: 30px; + font-size: 24px; } \ No newline at end of file diff --git a/static/directives/external-login-button.html b/static/directives/external-login-button.html index edf81a36a..65aaee41d 100644 --- a/static/directives/external-login-button.html +++ b/static/directives/external-login-button.html @@ -1,24 +1,14 @@ diff --git a/static/directives/external-logins-manager.html b/static/directives/external-logins-manager.html index 2c07c2a3a..ed7198a75 100644 --- a/static/directives/external-logins-manager.html +++ b/static/directives/external-logins-manager.html @@ -9,52 +9,35 @@ Provider Account Status - Attach/Detach + Attach/Detach - - - - GitHub Enterprise + + + + + {{ provider.title() }} - - Attached to GitHub Enterprise account {{githubLogin}} + + Attached to {{ provider.title() }} account + + + {{ provider.getUserInfo(externalLoginInfo[provider.id]).username }} + + - - (Not attached to GitHub Enterprise) + + Not attached to {{ provider.title() }} - - Detach Account - - - - - - - Google Account - - - - Attached to Google account {{ googleLogin }} - - - - (Not attached to a Google account) - - - - - - Detach Account + + Detach Account diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 27d3ff4cd..b3b2111ea 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -42,7 +42,8 @@
  • - Sign in + Sign in + Sign in
  • @@ -133,7 +134,8 @@
  • - Sign in + Sign in + Sign in
  • diff --git a/static/directives/signin-form.html b/static/directives/signin-form.html index fc5727828..ccaedc9a9 100644 --- a/static/directives/signin-form.html +++ b/static/directives/signin-form.html @@ -1,25 +1,28 @@