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:
Joseph Schorr 2015-07-16 15:00:51 +03:00
parent 5d86fa80e7
commit 687bab1c05
7 changed files with 3185 additions and 35 deletions

3041
data/model/legacy.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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".

View file

@ -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')

View file

@ -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>

View file

@ -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;

View file

@ -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);

View file

@ -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)