Skip to content

Commit

Permalink
[#1178] Admins can invite users
Browse files Browse the repository at this point in the history
The invited user starts in pending state, with the password reset key set. We
still have to send an email to the user telling him/her to change the password
and log in.

I had to change authorization code to only automatically unauthorize deleted
users, not pending. This was because the users needs to be able to perform the
password reset when pending, to be able to become active.
  • Loading branch information
vitorbaptista committed Aug 16, 2013
1 parent c0c6803 commit 74f649c
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 20 deletions.
2 changes: 1 addition & 1 deletion ckan/controllers/user.py
Expand Up @@ -447,7 +447,7 @@ def perform_reset(self, id):
# FIXME We should reset the reset key when it is used to prevent
# reuse of the url
context = {'model': model, 'session': model.Session,
'user': c.user,
'user': c.user or id,
'keep_sensitive_data': True}

data_dict = {'id': id}
Expand Down
37 changes: 37 additions & 0 deletions ckan/logic/action/create.py
@@ -1,6 +1,8 @@
'''API functions for adding data to CKAN.'''

import logging
import random
import re

from pylons import config
import paste.deploy.converters
Expand All @@ -15,6 +17,7 @@
import ckan.lib.dictization.model_dictize as model_dictize
import ckan.lib.dictization.model_save as model_save
import ckan.lib.navl.dictization_functions
import ckan.lib.navl.validators as validators

from ckan.common import _

Expand Down Expand Up @@ -836,6 +839,40 @@ def user_create(context, data_dict):
log.debug('Created user {name}'.format(name=user.name))
return user_dict

def user_invite(context, data_dict):
'''docstring'''
_check_access('user_invite', context, data_dict)

user_invite_schema = {
'email': [validators.not_empty, unicode]
}
_, errors = _validate(data_dict, user_invite_schema, context)
if errors:
raise ValidationError(errors)

while True:
try:
import ckan.lib.mailer
name = _get_random_username_from_email(data_dict['email'])
password = str(random.SystemRandom().random())
data_dict['name'] = name
data_dict['password'] = password
data_dict['state'] = ckan.model.State.PENDING
user_dict = _get_action('user_create')(context, data_dict)
user = ckan.model.User.get(user_dict['id'])
ckan.lib.mailer.create_reset_key(user)
return model_dictize.user_dictize(user, context)
except ValidationError as e:
if 'name' not in e.error_dict:
raise e

def _get_random_username_from_email(email):
localpart = email.split('@')[0]
cleaned_localpart = re.sub(r'[^\w]', '', localpart)
random_number = random.SystemRandom().random() * 10000
name = '%s-%d' % (cleaned_localpart, random_number)
return name

## Modifications for rest api

def package_create_rest(context, data_dict):
Expand Down
2 changes: 2 additions & 0 deletions ckan/logic/auth/create.py
Expand Up @@ -112,6 +112,8 @@ def user_create(context, data_dict=None):
else:
return {'success': True}

def user_invite(context, data_dict=None):
return {'success': False}

def _check_group_auth(context, data_dict):
# FIXME This code is shared amoung other logic.auth files and should be
Expand Down
1 change: 1 addition & 0 deletions ckan/logic/schema.py
Expand Up @@ -395,6 +395,7 @@ def default_user_schema():
'apikey': [ignore],
'reset_key': [ignore],
'activity_streams_email_notifications': [ignore_missing],
'state': [ignore_missing],
}
return schema

Expand Down
4 changes: 2 additions & 2 deletions ckan/new_authz.py
Expand Up @@ -151,8 +151,8 @@ def is_authorized(action, context, data_dict=None):
user = _get_user(username)

if user:
# inactive users are always unauthorized
if not user.is_active():
# deleted users are always unauthorized
if user.is_deleted():
return {'success': False}
# sysadmins can do anything unless the auth_sysadmins_check
# decorator was used in which case they are treated like all other
Expand Down
20 changes: 19 additions & 1 deletion ckan/tests/functional/test_user.py
Expand Up @@ -959,7 +959,7 @@ def test_perform_reset_user_password_link_user_incorrect(self):
def test_perform_reset_activates_pending_user(self):
password = 'password'
params = { 'password1': password, 'password2': password }
user = CreateTestData.create_user(name='username',
user = CreateTestData.create_user(name='pending_user',
email='user@email.com')
user.set_pending()
create_reset_key(user)
Expand All @@ -973,3 +973,21 @@ def test_perform_reset_activates_pending_user(self):

user = model.User.get(user.id)
assert user.is_active(), user

def test_perform_reset_doesnt_activate_deleted_user(self):
password = 'password'
params = { 'password1': password, 'password2': password }
user = CreateTestData.create_user(name='deleted_user',
email='user@email.com')
user.delete()
create_reset_key(user)
assert user.is_deleted(), user.state

offset = url_for(controller='user',
action='perform_reset',
id=user.id,
key=user.reset_key)
res = self.app.post(offset, params=params, status=302)

user = model.User.get(user.id)
assert user.is_deleted(), user
50 changes: 50 additions & 0 deletions ckan/tests/logic/test_action.py
Expand Up @@ -6,6 +6,7 @@
from nose.plugins.skip import SkipTest
from pylons import config
import datetime
import mock

import vdm.sqlalchemy
import ckan
Expand Down Expand Up @@ -561,6 +562,55 @@ def test_12_user_update_errors(self):
for expected_message in test_call['messages']:
assert expected_message[1] in ''.join(res_obj['error'][expected_message[0]])

def test_user_invite(self):
email_username = 'invited_user$ckan'
email = '%s@email.com' % email_username
user_dict = {'email': email}
postparams = '%s=1' % json.dumps(user_dict)
extra_environ = {'Authorization': str(self.sysadmin_user.apikey)}

res = self.app.post('/api/action/user_invite', params=postparams,
extra_environ=extra_environ)

res_obj = json.loads(res.body)
user = model.User.get(res_obj['result']['id'])
expected_username = email_username.replace('$', '')
assert res_obj['success'] is True, res_obj
assert user.email == email, (user.email, email)
assert user.name.startswith(expected_username), (user.name, expected_username)
assert user.is_pending(), user
assert user.reset_key is not None, user

def test_user_invite_without_email_raises_error(self):
user_dict = {}
postparams = '%s=1' % json.dumps(user_dict)
extra_environ = {'Authorization': str(self.sysadmin_user.apikey)}

res = self.app.post('/api/action/user_invite', params=postparams,
extra_environ=extra_environ,
status=StatusCodes.STATUS_409_CONFLICT)

res_obj = json.loads(res.body)
assert res_obj['success'] is False, res_obj
assert 'email' in res_obj['error'], res_obj

@mock.patch('ckan.logic.action.create._get_random_username_from_email')
def test_user_invite_should_work_even_if_tried_username_already_exists(self, random_username_mock):
email = 'invited_user@email.com'
user_dict = {'email': email}
postparams = '%s=1' % json.dumps(user_dict)
extra_environ = {'Authorization': str(self.sysadmin_user.apikey)}

usernames = ['first', 'first', 'second']
random_username_mock.side_effect = lambda email: usernames.pop(0)

for _ in range(2):
res = self.app.post('/api/action/user_invite', params=postparams,
extra_environ=extra_environ)

res_obj = json.loads(res.body)
assert res_obj['success'] is True, res_obj

def test_user_delete(self):
name = 'normal_user'
CreateTestData.create_user(name)
Expand Down
22 changes: 6 additions & 16 deletions ckan/tests/logic/test_auth.py
Expand Up @@ -49,6 +49,12 @@ def create_user(self, name):


class TestAuthUsers(TestAuth):
def test_only_sysadmins_can_invite_users(self):
username = 'normal_user'
self.create_user(username)

assert not new_authz.is_authorized_boolean('user_invite', {'user': username})

def test_only_sysadmins_can_delete_users(self):
username = 'username'
user = {'id': username}
Expand All @@ -73,22 +79,6 @@ def test_auth_deleted_users_are_always_unauthorized(self):

del new_authz._AuthFunctions._functions['always_success']

def test_auth_pending_users_are_always_unauthorized(self):
always_success = lambda x,y: {'success': True}
new_authz._AuthFunctions._build()
new_authz._AuthFunctions._functions['always_success'] = always_success
# We can't reuse the username with the other tests because we can't
# rebuild_db(), because in the setup_class we get the sysadmin. If we
# rebuild the DB, we would delete the sysadmin as well.
username = 'pending_user'
self.create_user(username)
user = model.User.get(username)
user.state = model.State.PENDING

assert not new_authz.is_authorized_boolean('always_success', {'user': username})

del new_authz._AuthFunctions._functions['always_success']


class TestAuthOrgs(TestAuth):
def test_01_create_users(self):
Expand Down
1 change: 1 addition & 0 deletions dev-requirements.txt
Expand Up @@ -7,3 +7,4 @@ nose==1.3.0
pep8==1.4.6
Sphinx==1.2b1
polib==1.0.3
mock==1.0.1

0 comments on commit 74f649c

Please sign in to comment.