Merge pull request #2867 from coreos-inc/invite-only
Add support for invite-only user creation
This commit is contained in:
commit
fa954466f7
9 changed files with 128 additions and 6 deletions
|
@ -220,6 +220,10 @@ class DefaultConfig(ImmutableConfig):
|
||||||
# Feature Flag: Whether users can be created (by non-super users).
|
# Feature Flag: Whether users can be created (by non-super users).
|
||||||
FEATURE_USER_CREATION = True
|
FEATURE_USER_CREATION = True
|
||||||
|
|
||||||
|
# Feature Flag: Whether users being created must be invited by another user. If FEATURE_USER_CREATION is off,
|
||||||
|
# this flag has no effect.
|
||||||
|
FEATURE_INVITE_ONLY_USER_CREATION = False
|
||||||
|
|
||||||
# Feature Flag: Whether users can be renamed
|
# Feature Flag: Whether users can be renamed
|
||||||
FEATURE_USER_RENAME = False
|
FEATURE_USER_RENAME = False
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import features
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
|
from data.users.shared import can_create_user
|
||||||
from util.validation import generate_valid_usernames
|
from util.validation import generate_valid_usernames
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -99,7 +100,7 @@ class FederatedUsers(object):
|
||||||
db_user = model.user.verify_federated_login(self._federated_service, username)
|
db_user = model.user.verify_federated_login(self._federated_service, username)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
# We must create the user in our db. Check to see if this is allowed.
|
# We must create the user in our db. Check to see if this is allowed.
|
||||||
if not features.USER_CREATION:
|
if not can_create_user(email):
|
||||||
return (None, DISABLED_MESSAGE)
|
return (None, DISABLED_MESSAGE)
|
||||||
|
|
||||||
valid_username = None
|
valid_username = None
|
||||||
|
|
18
data/users/shared.py
Normal file
18
data/users/shared.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import features
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
|
||||||
|
def can_create_user(email_address):
|
||||||
|
""" Returns true if a user with the specified e-mail address can be created. """
|
||||||
|
if not features.USER_CREATION:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if features.INVITE_ONLY_USER_CREATION:
|
||||||
|
if not email_address:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check to see that there is an invite for the e-mail address.
|
||||||
|
return bool(model.team.lookup_team_invites_by_email(email_address))
|
||||||
|
|
||||||
|
# Otherwise the user can be created (assuming it doesn't already exist, of course)
|
||||||
|
return True
|
38
data/users/test/test_shared.py
Normal file
38
data/users/test/test_shared.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
from data.database import model
|
||||||
|
from data.users.shared import can_create_user
|
||||||
|
|
||||||
|
from test.fixtures import *
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('open_creation, invite_only, email, has_invite, can_create', [
|
||||||
|
# Open user creation => always allowed.
|
||||||
|
(True, False, None, False, True),
|
||||||
|
|
||||||
|
# Open user creation => always allowed.
|
||||||
|
(True, False, 'foo@example.com', False, True),
|
||||||
|
|
||||||
|
# Invite only user creation + no invite => disallowed.
|
||||||
|
(True, True, None, False, False),
|
||||||
|
|
||||||
|
# Invite only user creation + no invite => disallowed.
|
||||||
|
(True, True, 'foo@example.com', False, False),
|
||||||
|
|
||||||
|
# Invite only user creation + invite => allowed.
|
||||||
|
(True, True, 'foo@example.com', True, True),
|
||||||
|
|
||||||
|
# No open creation => Disallowed.
|
||||||
|
(False, True, 'foo@example.com', False, False),
|
||||||
|
(False, True, 'foo@example.com', True, False),
|
||||||
|
])
|
||||||
|
def test_can_create_user(open_creation, invite_only, email, has_invite, can_create, app):
|
||||||
|
if has_invite:
|
||||||
|
inviter = model.user.get_user('devtable')
|
||||||
|
team = model.team.get_organization_team('buynlarge', 'owners')
|
||||||
|
model.team.add_or_invite_to_team(inviter, team, email=email)
|
||||||
|
|
||||||
|
with patch('features.USER_CREATION', open_creation):
|
||||||
|
with patch('features.INVITE_ONLY_USER_CREATION', invite_only):
|
||||||
|
assert can_create_user(email) == can_create
|
|
@ -21,6 +21,7 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository
|
||||||
from data import model
|
from data import model
|
||||||
from data.billing import get_plan
|
from data.billing import get_plan
|
||||||
from data.database import Repository as RepositoryTable
|
from data.database import Repository as RepositoryTable
|
||||||
|
from data.users.shared import can_create_user
|
||||||
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
|
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
|
||||||
log_action, internal_only, require_user_admin, parse_args,
|
log_action, internal_only, require_user_admin, parse_args,
|
||||||
query_param, require_scope, format_date, show_if,
|
query_param, require_scope, format_date, show_if,
|
||||||
|
@ -424,9 +425,20 @@ class User(ApiResource):
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise request_error(message='The username already exists')
|
raise request_error(message='The username already exists')
|
||||||
|
|
||||||
|
# Ensure an e-mail address was specified if required.
|
||||||
if features.MAILING and not user_data.get('email'):
|
if features.MAILING and not user_data.get('email'):
|
||||||
raise request_error(message='Email address is required')
|
raise request_error(message='Email address is required')
|
||||||
|
|
||||||
|
# If invite-only user creation is turned on and no invite code was sent, return an error.
|
||||||
|
# Technically, this is handled by the can_create_user call below as well, but it makes
|
||||||
|
# a nicer error.
|
||||||
|
if features.INVITE_ONLY_USER_CREATION and not invite_code:
|
||||||
|
raise request_error(message='Cannot create non-invited user')
|
||||||
|
|
||||||
|
# Ensure that this user can be created.
|
||||||
|
if not can_create_user(user_data.get('email')):
|
||||||
|
raise request_error(message='Creation of a user account for this e-mail is disabled; please contact an administrator')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prompts = model.user.get_default_user_prompts(features)
|
prompts = model.user.get_default_user_prompts(features)
|
||||||
new_user = model.user.create_user(user_data['username'], user_data['password'],
|
new_user = model.user.create_user(user_data['username'], user_data['password'],
|
||||||
|
|
|
@ -12,6 +12,7 @@ from app import app, analytics, get_app_url, oauth_login, authentication
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.decorators import require_session_login
|
from auth.decorators import require_session_login
|
||||||
from data import model
|
from data import model
|
||||||
|
from data.users.shared import can_create_user
|
||||||
from endpoints.common import common_login
|
from endpoints.common import common_login
|
||||||
from endpoints.web import index, render_page_template_with_routedata
|
from endpoints.web import index, render_page_template_with_routedata
|
||||||
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME, generate_csrf_token
|
from endpoints.csrf import csrf_protect, OAUTH_CSRF_TOKEN_NAME, generate_csrf_token
|
||||||
|
@ -86,7 +87,7 @@ def _conduct_oauth_login(auth_system, login_service, lid, lusername, lemail, met
|
||||||
return _oauthresult(user_obj=user_obj, service_name=service_name)
|
return _oauthresult(user_obj=user_obj, service_name=service_name)
|
||||||
|
|
||||||
# Otherwise, we need to create a new user account.
|
# Otherwise, we need to create a new user account.
|
||||||
if not features.USER_CREATION:
|
if not can_create_user(lemail):
|
||||||
error_message = 'User creation is disabled. Please contact your administrator'
|
error_message = 'User creation is disabled. Please contact your administrator'
|
||||||
return _oauthresult(service_name=service_name, error_message=error_message)
|
return _oauthresult(service_name=service_name, error_message=error_message)
|
||||||
|
|
||||||
|
@ -130,7 +131,8 @@ def _conduct_oauth_login(auth_system, login_service, lid, lusername, lemail, met
|
||||||
def _render_ologin_error(service_name, error_message=None, register_redirect=False):
|
def _render_ologin_error(service_name, error_message=None, register_redirect=False):
|
||||||
""" Returns a Flask response indicating an OAuth error. """
|
""" Returns a Flask response indicating an OAuth error. """
|
||||||
|
|
||||||
user_creation = bool(features.USER_CREATION and features.DIRECT_LOGIN)
|
user_creation = bool(features.USER_CREATION and features.DIRECT_LOGIN and
|
||||||
|
not features.INVITE_ONLY_USER_CREATION)
|
||||||
error_info = {
|
error_info = {
|
||||||
'reason': 'ologinerror',
|
'reason': 'ologinerror',
|
||||||
'service_name': service_name,
|
'service_name': service_name,
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
from data import model, database
|
from data import model, database
|
||||||
from data.users import get_users_handler, DatabaseUsers
|
from data.users import get_users_handler, DatabaseUsers
|
||||||
from endpoints.oauth.login import _conduct_oauth_login
|
from endpoints.oauth.login import _conduct_oauth_login
|
||||||
|
@ -71,6 +73,37 @@ def test_new_account_via_database(login_service):
|
||||||
federated_login = model.user.lookup_federated_login(new_user, login_service.service_id())
|
federated_login = model.user.lookup_federated_login(new_user, login_service.service_id())
|
||||||
assert federated_login is not None
|
assert federated_login is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('open_creation, invite_only, has_invite, expect_success', [
|
||||||
|
# Open creation -> Success!
|
||||||
|
(True, False, False, True),
|
||||||
|
|
||||||
|
# Open creation + invite only + no invite -> Failure!
|
||||||
|
(True, True, False, False),
|
||||||
|
|
||||||
|
# Open creation + invite only + invite -> Success!
|
||||||
|
(True, True, True, True),
|
||||||
|
|
||||||
|
# Close creation -> Failure!
|
||||||
|
(False, False, False, False),
|
||||||
|
])
|
||||||
|
def test_flagged_user_creation(open_creation, invite_only, has_invite, expect_success, login_service):
|
||||||
|
login_service_lid = 'someexternaluser'
|
||||||
|
email = 'some@example.com'
|
||||||
|
|
||||||
|
if has_invite:
|
||||||
|
inviter = model.user.get_user('devtable')
|
||||||
|
team = model.team.get_organization_team('buynlarge', 'owners')
|
||||||
|
model.team.add_or_invite_to_team(inviter, team, email=email)
|
||||||
|
|
||||||
|
internal_auth = DatabaseUsers()
|
||||||
|
|
||||||
|
with patch('features.USER_CREATION', open_creation):
|
||||||
|
with patch('features.INVITE_ONLY_USER_CREATION', invite_only):
|
||||||
|
# Conduct login.
|
||||||
|
result = _conduct_oauth_login(internal_auth, login_service, login_service_lid, login_service_lid,
|
||||||
|
email)
|
||||||
|
assert (result.user_obj is not None) == expect_success
|
||||||
|
assert (result.error_message is None) == expect_success
|
||||||
|
|
||||||
@pytest.mark.parametrize('binding_field, lid, lusername, lemail, expected_error', [
|
@pytest.mark.parametrize('binding_field, lid, lusername, lemail, expected_error', [
|
||||||
# No binding field + newly seen user -> New unlinked user
|
# No binding field + newly seen user -> New unlinked user
|
||||||
|
|
|
@ -1218,8 +1218,20 @@
|
||||||
Enable Open User Creation
|
Enable Open User Creation
|
||||||
</div>
|
</div>
|
||||||
<div class="help-text">
|
<div class="help-text">
|
||||||
If enabled, user accounts can be created by anyone.
|
If enabled, user accounts can be created by anyone (unless restricted below to invited users).
|
||||||
Users can always be created in the users panel under this superuser view.
|
Users can always be created in the users panel in this superuser tool, even if this feature is disabled.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-show="config.FEATURE_USER_CREATION && config.FEATURE_MAILING">
|
||||||
|
<td class="non-input">Invite-only User Creation:</td>
|
||||||
|
<td colspan="2">
|
||||||
|
<div class="config-bool-field" binding="config.FEATURE_INVITE_ONLY_USER_CREATION">
|
||||||
|
Enable Invite-only User Creation
|
||||||
|
</div>
|
||||||
|
<div class="help-text">
|
||||||
|
If enabled, user accounts can only be created when a user has been invited, by e-mail address, to join a team.
|
||||||
|
Users can always be created in the users panel in this superuser tool, even if this feature is enabled.
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -27,8 +27,10 @@
|
||||||
|
|
||||||
<div class="user-footer-links">
|
<div class="user-footer-links">
|
||||||
<a ng-click="setView('createAccount')"
|
<a ng-click="setView('createAccount')"
|
||||||
quay-show="Features.USER_CREATION && Config.AUTHENTICATION_TYPE == 'Database' && Features.DIRECT_LOGIN"
|
quay-show="Features.USER_CREATION && Config.AUTHENTICATION_TYPE == 'Database' && Features.DIRECT_LOGIN && !Features.INVITE_ONLY_USER_CREATION"
|
||||||
ng-if="currentView != 'createAccount'">Create Account</a>
|
ng-if="currentView != 'createAccount'">Create Account</a>
|
||||||
|
<span quay-show="Features.USER_CREATION && Config.AUTHENTICATION_TYPE == 'Database' && Features.DIRECT_LOGIN && Features.INVITE_ONLY_USER_CREATION"
|
||||||
|
ng-if="currentView != 'createAccount'">Invitation required to sign up</span>
|
||||||
<a ng-click="setView('signin')" ng-if="currentView != 'signin'">Sign In</a>
|
<a ng-click="setView('signin')" ng-if="currentView != 'signin'">Sign In</a>
|
||||||
<a ng-click="setView('forgotPassword')"
|
<a ng-click="setView('forgotPassword')"
|
||||||
quay-show="Features.MAILING && Config.AUTHENTICATION_TYPE == 'Database' && Features.DIRECT_LOGIN"
|
quay-show="Features.MAILING && Config.AUTHENTICATION_TYPE == 'Database' && Features.DIRECT_LOGIN"
|
||||||
|
|
Reference in a new issue