Add support for recaptcha during the create account flow

If the feature is enabled and recaptcha keys are given in config, then a recaptcha box is displayed in the UI when creating a user and a recaptcha response code *must* be sent with the create API call for it to succeed.
This commit is contained in:
Joseph Schorr 2016-12-17 02:13:36 -05:00
parent e58e04b0e9
commit 3eb17b7caa
12 changed files with 88 additions and 1 deletions

View file

@ -20,7 +20,7 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT', 'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION', 'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID', 'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
'STATIC_SITE_BUCKET'] 'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY']
def frontend_visible_config(config_dict): def frontend_visible_config(config_dict):
@ -406,3 +406,8 @@ class DefaultConfig(object):
# Example: 10 builds per minute is accomplished by setting ITEMS = 10, SECS = 60 # Example: 10 builds per minute is accomplished by setting ITEMS = 10, SECS = 60
MAX_BUILD_QUEUE_RATE_ITEMS = -1 MAX_BUILD_QUEUE_RATE_ITEMS = -1
MAX_BUILD_QUEUE_RATE_SECS = -1 MAX_BUILD_QUEUE_RATE_SECS = -1
# Site key and secret key for using recaptcha.
FEATURE_RECAPTCHA = False
RECAPTCHA_SITE_KEY = None
RECAPTCHA_SECRET_KEY = None

View file

@ -2,6 +2,7 @@
import logging import logging
import json import json
import recaptcha2
from flask import request, abort from flask import request, abort
from flask_login import logout_user from flask_login import logout_user
@ -183,6 +184,10 @@ class User(ApiResource):
'type': 'string', 'type': 'string',
'description': 'The optional invite code', 'description': 'The optional invite code',
}, },
'recaptcha_response': {
'type': 'string',
'description': 'The (may be disabled) recaptcha response code for verification',
},
} }
}, },
'UpdateUser': { 'UpdateUser': {
@ -382,6 +387,19 @@ class User(ApiResource):
abort(404) abort(404)
user_data = request.get_json() user_data = request.get_json()
# If recaptcha is enabled, then verify the user is a human.
if features.RECAPTCHA:
recaptcha_response = user_data.get('recaptcha_response', '')
result = recaptcha2.verify(app.config['RECAPTCHA_SECRET_KEY'],
recaptcha_response,
request.remote_addr)
if not result['success']:
return {
'message': 'Are you a bot? If not, please revalidate the captcha.'
}, 400
invite_code = user_data.get('invite_code', '') invite_code = user_data.get('invite_code', '')
existing_user = model.user.get_nonrobot_user(user_data['username']) existing_user = model.user.get_nonrobot_user(user_data['username'])
if existing_user: if existing_user:

View file

@ -220,6 +220,7 @@ def render_page_template(name, route_data=None, **kwargs):
enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''), enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''),
mixpanel_key=app.config.get('MIXPANEL_KEY', ''), mixpanel_key=app.config.get('MIXPANEL_KEY', ''),
munchkin_key=app.config.get('MARKETO_MUNCHKIN_ID', ''), munchkin_key=app.config.get('MARKETO_MUNCHKIN_ID', ''),
recaptcha_key=app.config.get('RECAPTCHA_SITE_KEY', ''),
google_tagmanager_key=app.config.get('GOOGLE_TAGMANAGER_KEY', ''), google_tagmanager_key=app.config.get('GOOGLE_TAGMANAGER_KEY', ''),
google_anaytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''), google_anaytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''),
sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''), sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''),

View file

@ -18,6 +18,7 @@ EXTERNAL_JS = [
'cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3', 'cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3',
'cdn.ravenjs.com/3.1.0/angular/raven.min.js', 'cdn.ravenjs.com/3.1.0/angular/raven.min.js',
'cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.min.js', 'cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.min.js',
'cdnjs.cloudflare.com/ajax/libs/angular-recaptcha/3.2.1/angular-recaptcha.min.js',
] ]
EXTERNAL_CSS = [ EXTERNAL_CSS = [

View file

@ -66,3 +66,4 @@ toposort
trollius trollius
tzlocal tzlocal
xhtml2pdf xhtml2pdf
recaptcha2

View file

@ -98,6 +98,7 @@ python-swiftclient==3.1.0
pytz==2016.7 pytz==2016.7
PyYAML==3.12 PyYAML==3.12
raven==5.29.0 raven==5.29.0
recaptcha2==0.1
redis==2.10.5 redis==2.10.5
redlock==1.2.0 redlock==1.2.0
reportlab==2.7 reportlab==2.7

View file

@ -10,4 +10,24 @@
.signup-form-element input { .signup-form-element input {
margin-bottom: 10px; margin-bottom: 10px;
}
.signup-form-element .captcha {
display: block;
text-align: center;
}
.signup-form-element .captcha div {
display: inline-block;
margin: 2px;
}
.signup-form-element .captcha {
height: 0px;
transition: height ease-in-out 250ms;
overflow: hidden;
}
.user-setup-element .captcha.expanded {
height: 94px;
} }

View file

@ -30,6 +30,10 @@
match="newUser.password" required match="newUser.password" required
ng-pattern="/^.{8,}$/"> ng-pattern="/^.{8,}$/">
<div class="captcha" quay-require="['RECAPTCHA']"
ng-class="{'expanded': newUser.password == newUser.repeatPassword && newUser.password}">
<div vc-recaptcha ng-model="newUser.recaptcha_response" key="Config.RECAPTCHA_SITE_KEY"></div>
</div>
<button id="signupButton" <button id="signupButton"
class="btn btn-primary btn-block landing-signup-button" ng-disabled="signupForm.$invalid" type="submit" class="btn btn-primary btn-block landing-signup-button" ng-disabled="signupForm.$invalid" type="submit"
@ -39,6 +43,7 @@
</button> </button>
</form> </form>
<div class="cor-loader" ng-show="registering"></div> <div class="cor-loader" ng-show="registering"></div>
<div class="co-alert co-alert-info" ng-show="awaitingConfirmation && hideRegisteredMessage != 'true'"> <div class="co-alert co-alert-info" ng-show="awaitingConfirmation && hideRegisteredMessage != 'true'">
Thank you for registering! We have sent you an activation email. Thank you for registering! We have sent you an activation email.

View file

@ -55,6 +55,10 @@ if (window.__config && window.__config.GOOGLE_ANALYTICS_KEY) {
quayDependencies.push('angulartics.google.analytics'); quayDependencies.push('angulartics.google.analytics');
} }
if (window.__config && window.__config.RECAPTCHA_SITE_KEY) {
quayDependencies.push('vcRecaptcha');
}
// Define the application. // Define the application.
quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoadingBarProvider) { quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoadingBarProvider) {
cfpLoadingBarProvider.includeSpinner = false; cfpLoadingBarProvider.includeSpinner = false;

View file

@ -74,6 +74,10 @@
</script> </script>
{% endif %} {% endif %}
{% if recaptcha_key %}
<script src="//www.google.com/recaptcha/api.js?render=explicit&onload=vcRecaptchaApiLoaded" async defer></script>
{% endif %}
{% if munchkin_key %} {% if munchkin_key %}
<script type="text/javascript"> <script type="text/javascript">
(function() { (function() {

View file

@ -9,6 +9,7 @@ import json as py_json
from contextlib import contextmanager from contextlib import contextmanager
from calendar import timegm from calendar import timegm
from httmock import urlmatch, HTTMock, all_requests
from StringIO import StringIO from StringIO import StringIO
from urllib import urlencode from urllib import urlencode
from urlparse import urlparse, urlunparse, parse_qs from urlparse import urlparse, urlunparse, parse_qs
@ -665,6 +666,29 @@ class TestCreateNewUser(ApiTestCase):
data = self.postJsonResponse(User, data=NEW_USER_DETAILS, expected_code=200) data = self.postJsonResponse(User, data=NEW_USER_DETAILS, expected_code=200)
self.assertEquals(True, data['awaiting_verification']) self.assertEquals(True, data['awaiting_verification'])
def test_createuser_captcha(self):
@urlmatch(netloc=r'(.*\.)?google.com', path='/recaptcha/api/siteverify')
def captcha_endpoint(url, request):
if url.query.find('response=somecode') > 0:
return {'status_code': 200, 'content': py_json.dumps({'success': True})}
else:
return {'status_code': 400, 'content': py_json.dumps({'success': False})}
with HTTMock(captcha_endpoint):
with self.toggleFeature('RECAPTCHA', True):
# Try with a missing captcha.
self.postResponse(User, data=NEW_USER_DETAILS, expected_code=400)
# Try with an invalid captcha.
details = dict(NEW_USER_DETAILS)
details['recaptcha_response'] = 'someinvalidcode'
self.postResponse(User, data=details, expected_code=400)
# Try with a valid captcha.
details = dict(NEW_USER_DETAILS)
details['recaptcha_response'] = 'somecode'
self.postResponse(User, data=details, expected_code=200)
def test_createuser_withteaminvite(self): def test_createuser_withteaminvite(self):
inviter = model.user.get_user(ADMIN_ACCESS_USER) inviter = model.user.get_user(ADMIN_ACCESS_USER)
team = model.team.get_organization_team(ORGANIZATION, 'owners') team = model.team.get_organization_team(ORGANIZATION, 'owners')

View file

@ -84,3 +84,6 @@ class TestConfig(DefaultConfig):
'CLIENT_ID': 'someclientid', 'CLIENT_ID': 'someclientid',
'OIDC_SERVER': 'https://oidcserver/', 'OIDC_SERVER': 'https://oidcserver/',
} }
RECAPTCHA_SITE_KEY = 'somekey'
RECAPTCHA_SECRET_KEY = 'somesecretkey'