Support invite codes for verification of email
Also changes the system so we don't apply the invite until it is called explicitly from the frontend Fixes #241
This commit is contained in:
parent
5d86fa80e7
commit
687bab1c05
7 changed files with 3185 additions and 35 deletions
3041
data/model/legacy.py
Normal file
3041
data/model/legacy.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -253,9 +253,26 @@ def delete_team_invite(code, user_obj=None):
|
||||||
return (team, inviter)
|
return (team, inviter)
|
||||||
|
|
||||||
|
|
||||||
def confirm_team_invite(code, user_obj):
|
def find_matching_team_invite(code, user_obj):
|
||||||
|
""" Finds a team invite with the given code that applies to the given user and returns it or
|
||||||
|
raises a DataModelException if not found. """
|
||||||
found = lookup_team_invite(code)
|
found = lookup_team_invite(code)
|
||||||
|
|
||||||
|
# If the invite is for a specific user, we have to confirm that here.
|
||||||
|
if found.user is not None and found.user != user_obj:
|
||||||
|
message = """This invite is intended for user "%s".
|
||||||
|
Please login to that account and try again.""" % found.user.username
|
||||||
|
raise DataModelException(message)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_team_invite(code, user_obj):
|
||||||
|
""" Confirms the given team invite code for the given user by adding the user to the team
|
||||||
|
and deleting the code. Raises a DataModelException if the code was not found or does
|
||||||
|
not apply to the given user. """
|
||||||
|
found = find_matching_team_invite(code, user_obj)
|
||||||
|
|
||||||
# If the invite is for a specific user, we have to confirm that here.
|
# If the invite is for a specific user, we have to confirm that here.
|
||||||
if found.user is not None and found.user != user_obj:
|
if found.user is not None and found.user != user_obj:
|
||||||
message = """This invite is intended for user "%s".
|
message = """This invite is intended for user "%s".
|
||||||
|
|
|
@ -19,7 +19,7 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques
|
||||||
from endpoints.api.subscribe import subscribe
|
from endpoints.api.subscribe import subscribe
|
||||||
from endpoints.common import common_login
|
from endpoints.common import common_login
|
||||||
from endpoints.decorators import anon_allowed
|
from endpoints.decorators import anon_allowed
|
||||||
from endpoints.api.team import try_accept_invite
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.billing import get_plan
|
from data.billing import get_plan
|
||||||
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
||||||
|
@ -33,6 +33,33 @@ from util.names import parse_single_urn
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def handle_invite_code(invite_code, user):
|
||||||
|
""" Checks that the given invite code matches the specified user's e-mail address. If so, the
|
||||||
|
user is marked as having a verified e-mail address and this method returns True.
|
||||||
|
"""
|
||||||
|
parsed_invite = parse_single_urn(invite_code)
|
||||||
|
if parsed_invite is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if parsed_invite[0] != 'teaminvite':
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check to see if the team invite is valid. If so, then we know the user has
|
||||||
|
# a possible matching email address.
|
||||||
|
try:
|
||||||
|
found = model.team.find_matching_team_invite(invite_code, user)
|
||||||
|
except model.DataModelException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Since we sent the invite code via email, mark the user as having a verified
|
||||||
|
# email address.
|
||||||
|
if found.email != user.email:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.verified = True
|
||||||
|
user.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def user_view(user):
|
def user_view(user):
|
||||||
def org_view(o):
|
def org_view(o):
|
||||||
|
@ -105,7 +132,6 @@ class User(ApiResource):
|
||||||
""" Operations related to users. """
|
""" Operations related to users. """
|
||||||
schemas = {
|
schemas = {
|
||||||
'NewUser': {
|
'NewUser': {
|
||||||
|
|
||||||
'id': 'NewUser',
|
'id': 'NewUser',
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'description': 'Fields which must be specified for a new user.',
|
'description': 'Fields which must be specified for a new user.',
|
||||||
|
@ -299,18 +325,8 @@ class User(ApiResource):
|
||||||
new_user = model.user.create_user(user_data['username'], user_data['password'],
|
new_user = model.user.create_user(user_data['username'], user_data['password'],
|
||||||
user_data['email'], auto_verify=not features.MAILING)
|
user_data['email'], auto_verify=not features.MAILING)
|
||||||
|
|
||||||
# Handle any invite codes.
|
email_address_confirmed = handle_invite_code(invite_code, new_user)
|
||||||
parsed_invite = parse_single_urn(invite_code)
|
if features.MAILING and not email_address_confirmed:
|
||||||
if parsed_invite is not None:
|
|
||||||
if parsed_invite[0] == 'teaminvite':
|
|
||||||
# Add the user to the team.
|
|
||||||
try:
|
|
||||||
try_accept_invite(invite_code, new_user)
|
|
||||||
except model.user.DataModelException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if features.MAILING:
|
|
||||||
code = model.user.create_confirm_email_code(new_user)
|
code = model.user.create_confirm_email_code(new_user)
|
||||||
send_confirmation_email(new_user.username, new_user.email, code.code)
|
send_confirmation_email(new_user.username, new_user.email, code.code)
|
||||||
return {
|
return {
|
||||||
|
@ -389,18 +405,23 @@ class ClientKey(ApiResource):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def conduct_signin(username_or_email, password):
|
def conduct_signin(username_or_email, password, invite_code=None):
|
||||||
needs_email_verification = False
|
needs_email_verification = False
|
||||||
invalid_credentials = False
|
invalid_credentials = False
|
||||||
|
found_user = None
|
||||||
|
|
||||||
verified = None
|
|
||||||
try:
|
try:
|
||||||
(verified, error_message) = authentication.verify_and_link_user(username_or_email, password)
|
(found_user, error_message) = authentication.verify_and_link_user(username_or_email, password)
|
||||||
except model.user.TooManyUsersException as ex:
|
except model.user.TooManyUsersException as ex:
|
||||||
raise license_error(exception=ex)
|
raise license_error(exception=ex)
|
||||||
|
|
||||||
if verified:
|
# If there is an attached invitation code, handle it here. This will mark the
|
||||||
if common_login(verified):
|
# user as verified if the code is valid.
|
||||||
|
if invite_code:
|
||||||
|
handle_invite_code(invite_code, found_user)
|
||||||
|
|
||||||
|
if found_user:
|
||||||
|
if common_login(found_user):
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
else:
|
else:
|
||||||
needs_email_verification = True
|
needs_email_verification = True
|
||||||
|
@ -501,6 +522,10 @@ class Signin(ApiResource):
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'The user\'s password',
|
'description': 'The user\'s password',
|
||||||
},
|
},
|
||||||
|
'invite_code': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The optional invite code'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -516,8 +541,9 @@ class Signin(ApiResource):
|
||||||
|
|
||||||
username = signin_data['username']
|
username = signin_data['username']
|
||||||
password = signin_data['password']
|
password = signin_data['password']
|
||||||
|
invite_code = signin_data.get('invite_code', '')
|
||||||
|
|
||||||
return conduct_signin(username, password)
|
return conduct_signin(username, password, invite_code=invite_code)
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/signin/verify')
|
@resource('/v1/signin/verify')
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="collapseSignin" class="panel-collapse collapse" ng-class="hasSignedIn() ? 'in' : 'out'">
|
<div id="collapseSignin" class="panel-collapse collapse" ng-class="hasSignedIn() ? 'in' : 'out'">
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="signin-form" signed-in="signedIn()" sign-in-started="signInStarted()" redirect-url="redirectUrl"></div>
|
<div class="signin-form" signed-in="signedIn()" sign-in-started="signInStarted()" redirect-url="redirectUrl" invite-code="inviteCode"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,6 +9,7 @@ angular.module('quay').directive('signinForm', function () {
|
||||||
transclude: true,
|
transclude: true,
|
||||||
restrict: 'C',
|
restrict: 'C',
|
||||||
scope: {
|
scope: {
|
||||||
|
'inviteCode': '=inviteCode',
|
||||||
'redirectUrl': '=redirectUrl',
|
'redirectUrl': '=redirectUrl',
|
||||||
'signInStarted': '&signInStarted',
|
'signInStarted': '&signInStarted',
|
||||||
'signedIn': '&signedIn'
|
'signedIn': '&signedIn'
|
||||||
|
@ -49,6 +50,10 @@ angular.module('quay').directive('signinForm', function () {
|
||||||
$scope.markStarted();
|
$scope.markStarted();
|
||||||
$scope.cancelInterval();
|
$scope.cancelInterval();
|
||||||
|
|
||||||
|
if ($scope.inviteCode) {
|
||||||
|
$scope.user['invite_code'] = $scope.inviteCode;
|
||||||
|
}
|
||||||
|
|
||||||
ApiService.signinUser($scope.user).then(function() {
|
ApiService.signinUser($scope.user).then(function() {
|
||||||
$scope.signingIn = false;
|
$scope.signingIn = false;
|
||||||
$scope.needsEmailVerification = false;
|
$scope.needsEmailVerification = false;
|
||||||
|
|
|
@ -35,11 +35,13 @@ angular.module('quay').directive('signupForm', function () {
|
||||||
mixpanel.alias($scope.newUser.username);
|
mixpanel.alias($scope.newUser.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$scope.awaitingConfirmation) {
|
$scope.userRegistered({'username': $scope.newUser.username});
|
||||||
|
|
||||||
|
if (!$scope.awaitingConfirmation && !$scope.inviteCode) {
|
||||||
document.location = '/';
|
document.location = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope.userRegistered({'username': $scope.newUser.username});
|
UserService.load();
|
||||||
}, function(result) {
|
}, function(result) {
|
||||||
$scope.registering = false;
|
$scope.registering = false;
|
||||||
UIService.showFormError('#signupButton', result);
|
UIService.showFormError('#signupButton', result);
|
||||||
|
|
|
@ -182,6 +182,12 @@ class ApiTestCase(unittest.TestCase):
|
||||||
parsed = py_json.loads(data)
|
parsed = py_json.loads(data)
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def assertNotInTeam(self, data, membername):
|
||||||
|
for memberData in data['members']:
|
||||||
|
if memberData['name'] == membername:
|
||||||
|
self.fail(membername + ' found in team: ' + json.dumps(data))
|
||||||
|
|
||||||
def assertInTeam(self, data, membername):
|
def assertInTeam(self, data, membername):
|
||||||
for member_data in data['members']:
|
for member_data in data['members']:
|
||||||
if member_data['name'] == membername:
|
if member_data['name'] == membername:
|
||||||
|
@ -469,7 +475,7 @@ class TestCreateNewUser(ApiTestCase):
|
||||||
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')
|
||||||
invite = model.team.add_or_invite_to_team(inviter, team, None, 'foo@example.com')
|
invite = model.team.add_or_invite_to_team(inviter, team, None, NEW_USER_DETAILS['email'])
|
||||||
|
|
||||||
details = {
|
details = {
|
||||||
'invite_code': invite.invite_token
|
'invite_code': invite.invite_token
|
||||||
|
@ -477,14 +483,42 @@ class TestCreateNewUser(ApiTestCase):
|
||||||
details.update(NEW_USER_DETAILS)
|
details.update(NEW_USER_DETAILS)
|
||||||
|
|
||||||
data = self.postJsonResponse(User, data=details, expected_code=200)
|
data = self.postJsonResponse(User, data=details, expected_code=200)
|
||||||
self.assertEquals(True, data['awaiting_verification'])
|
|
||||||
|
|
||||||
# Make sure the user was added to the team.
|
# Make sure the user is verified since the email address of the user matches
|
||||||
|
# that of the team invite.
|
||||||
|
self.assertFalse('awaiting_verification' in data)
|
||||||
|
|
||||||
|
# Make sure the user was not (yet) added to the team.
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
json = self.getJsonResponse(TeamMemberList,
|
json = self.getJsonResponse(TeamMemberList,
|
||||||
params=dict(orgname=ORGANIZATION,
|
params=dict(orgname=ORGANIZATION,
|
||||||
teamname='owners'))
|
teamname='owners'))
|
||||||
self.assertInTeam(json, NEW_USER_DETAILS['username'])
|
self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_createuser_withteaminvite_differentemails(self):
|
||||||
|
inviter = model.user.get_user(ADMIN_ACCESS_USER)
|
||||||
|
team = model.team.get_organization_team(ORGANIZATION, 'owners')
|
||||||
|
invite = model.team.add_or_invite_to_team(inviter, team, None, 'differentemail@example.com')
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'invite_code': invite.invite_token
|
||||||
|
}
|
||||||
|
details.update(NEW_USER_DETAILS)
|
||||||
|
|
||||||
|
data = self.postJsonResponse(User, data=details, expected_code=200)
|
||||||
|
|
||||||
|
# Make sure the user is *not* verified since the email address of the user
|
||||||
|
# does not match that of the team invite.
|
||||||
|
self.assertTrue(data['awaiting_verification'])
|
||||||
|
|
||||||
|
# Make sure the user was not (yet) added to the team.
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
json = self.getJsonResponse(TeamMemberList,
|
||||||
|
params=dict(orgname=ORGANIZATION,
|
||||||
|
teamname='owners'))
|
||||||
|
self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestSignin(ApiTestCase):
|
class TestSignin(ApiTestCase):
|
||||||
|
@ -492,6 +526,38 @@ class TestSignin(ApiTestCase):
|
||||||
self.postResponse(Signin, data=dict(username=u'\xe5\x8c\x97\xe4\xba\xac\xe5\xb8\x82',
|
self.postResponse(Signin, data=dict(username=u'\xe5\x8c\x97\xe4\xba\xac\xe5\xb8\x82',
|
||||||
password='password'), expected_code=403)
|
password='password'), expected_code=403)
|
||||||
|
|
||||||
|
def test_signin_invitecode(self):
|
||||||
|
# Create a new user (unverified)
|
||||||
|
data = self.postJsonResponse(User, data=NEW_USER_DETAILS, expected_code=200)
|
||||||
|
self.assertTrue(data['awaiting_verification'])
|
||||||
|
|
||||||
|
# Try to sign in without an invite code.
|
||||||
|
data = self.postJsonResponse(Signin, data=NEW_USER_DETAILS, expected_code=403)
|
||||||
|
self.assertTrue(data['needsEmailVerification'])
|
||||||
|
|
||||||
|
# Try to sign in with an invalid invite code.
|
||||||
|
details = {
|
||||||
|
'invite_code': 'someinvalidcode'
|
||||||
|
}
|
||||||
|
details.update(NEW_USER_DETAILS)
|
||||||
|
|
||||||
|
data = self.postJsonResponse(Signin, data=details, expected_code=403)
|
||||||
|
self.assertTrue(data['needsEmailVerification'])
|
||||||
|
|
||||||
|
# Sign in with an invite code and ensure the user becomes verified.
|
||||||
|
inviter = model.user.get_user(ADMIN_ACCESS_USER)
|
||||||
|
team = model.team.get_organization_team(ORGANIZATION, 'owners')
|
||||||
|
invite = model.team.add_or_invite_to_team(inviter, team, None, NEW_USER_DETAILS['email'])
|
||||||
|
|
||||||
|
details = {
|
||||||
|
'invite_code': invite.invite_token
|
||||||
|
}
|
||||||
|
details.update(NEW_USER_DETAILS)
|
||||||
|
|
||||||
|
data = self.postJsonResponse(Signin, data=details, expected_code=200)
|
||||||
|
self.assertFalse('needsEmailVerification' in data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestSignout(ApiTestCase):
|
class TestSignout(ApiTestCase):
|
||||||
def test_signout(self):
|
def test_signout(self):
|
||||||
|
@ -1050,13 +1116,6 @@ class TestUpdateOrganizationTeamMember(ApiTestCase):
|
||||||
|
|
||||||
|
|
||||||
class TestAcceptTeamMemberInvite(ApiTestCase):
|
class TestAcceptTeamMemberInvite(ApiTestCase):
|
||||||
def assertInTeam(self, data, membername):
|
|
||||||
for member_data in data['members']:
|
|
||||||
if member_data['name'] == membername:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.fail(membername + ' not found in team: ' + py_json.dumps(data))
|
|
||||||
|
|
||||||
def test_accept(self):
|
def test_accept(self):
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
|
Reference in a new issue