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:
parent
e58e04b0e9
commit
3eb17b7caa
12 changed files with 88 additions and 1 deletions
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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', ''),
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -66,3 +66,4 @@ toposort
|
||||||
trollius
|
trollius
|
||||||
tzlocal
|
tzlocal
|
||||||
xhtml2pdf
|
xhtml2pdf
|
||||||
|
recaptcha2
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -11,3 +11,23 @@
|
||||||
.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;
|
||||||
|
}
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Reference in a new issue