diff --git a/README.rst b/README.rst index 36b7d59..1b76d9e 100644 --- a/README.rst +++ b/README.rst @@ -6,6 +6,7 @@ Requirements ============ * `python-odesk` + * `PyCrypto` Authentication @@ -44,6 +45,10 @@ To do so, add the `django_odesk.auth.backends.ModelBackend` to your 'django_odesk.auth.backends.ModelBackend', ) +.. note:: + Please, note that `django_odesk` authentication backend may cause failing + Django tests. Please, read a paragraph below to find out why this happens. + You will also need to enable both `SessionMiddleware` and `AuthenticationMiddleware`:: @@ -111,6 +116,77 @@ For simple cases you may just set login page to the LOGIN_URL = '/odesk_auth/authenticate/' +Groups +------ + +`django-odesk` has support for Django groups. Groups are direct mapping +of oDesk teams. For each team the user is a member of, the corresponding +`django.contrib.auth.models.Group` is created with the name in the form:: + + company:team@odesk.com + +The synchronyzation between oDesk teams and user groups is happening on +user object's creation by default. You can make it happen each time the +user logs in by setting:: + + ODESK_SYNC_PERMISSIONS_ON_LOGIN = True + +Similar to creation of the unknown user, you can disable automatic creation +of new groups by setting:: + + ODESK_CREATE_UNKNOWN_GROUP = False + +Since Django's authentication system has no support for roles, `django-odeask` +has limited support for "pseudo-roles". If the user has "admin" role in oDesk +team, they become the member of additional group, with the name of the from:: + + company:team:admins@odesk.com + +Currently only admin role is supported. + +In order turn on the "pseudo-groups" feature, set the corresponding variable:: + + ODESK_CREATE_PSEUDO_GROUPS = True + +It is sometimes desirable to limit the view only to the members of the +specific oDesk team. `django-odesk` provides the convenient decorator to +check for group membership:: + + from django_odesk.auth.decorators import group_required + + @group_required('company:team@odesk.com') + def my_view(request) + ... + +You can also give the list of group names. The user passes test if they +belong to at least one of them:: + + from django_odesk.auth.decorators import group_required + + @group_required(['company:team@odesk.com','company:team2@odesk.com']) + def my_view(request) + ... + +Auth-only mode +-------------- + +Using oDesk APIs imposes an inherent secuirty risk. The person who has access +to the application server is capable of performing arbitrary actions on oDesk +on behalf of everyone who've been using the application, uless the users have +de-authorized the application expicitly. But sometimes all you need is just +to authenticate oDesk users and not make any other API calls. You can reduce +the mentioned risk, by not storing the API token anywhere after user has +logged in. + +Since version 0.0.2 `django-odesk` supports "auth-only" mode that works +exactly like that. To turn it on, set:: + + ODESK_AUTH_ONLY = True + +Please note that if you use `django_odesk.core.clients.RequestClient`, either +directly or with `RequestClientMiddleware` in auth-only mode, the client will +only be capable of calling public API methods. + Authentication without a database --------------------------------- @@ -190,6 +266,14 @@ used in conjunction with `django_odesk.auth`:: client.team.get_teamrooms() # ... +Note that the token is stored in django session encrypted (by default). The +encryption method used is AES. This key is stored in client +browser cookies and has expiration time set to two hours. +You can disable the encryption via specifying the following option +in your settings.py file: + + ODESK_ENCRYPT_API_TOKEN = False + If you plan to use odesk API calls extensively in your views, there is another shortcut, the `django_odesk.core.middleware.RequestClientMiddleware`. It populates `request` with `odesk_client` attribute, which is an instance @@ -209,3 +293,25 @@ Then you may use the client in your views:: request.odesk_client.team.get_teamrooms() # ... +Django Tests Failure +==================== + +If your project is using `django_odesk` with it's model authentication backend +`django_odesk.auth.backends.ModelBackend`, you will face problems with running +standard Django's tests (in particular - `django.contrib.auth` tests): + + `$ python manage.py test` + +will give you lot's of errors. +This happens due to the nature of `django.contrib.auth` tests. While officially +Django's auth system supports third-party backends, its tests are intended to +check only standard (or very close to standard) backend. Really, `here `_ +you can see intension to authenticate user via username/password pair which +is of course incorrect in our case. + +There is no way to prevent this by changing `django_odesk` package. Thus one who uses +`django_odesk` has two possible choices: + +- ignore tests failure +- add Django's standard `django.contrib.auth.backends.ModelBackend` to the end of + `AUTHENTICATION_BACKENDS` tuple in your `settings.py` file. diff --git a/django_odesk/__init__.py b/django_odesk/__init__.py index a260241..da6558b 100644 --- a/django_odesk/__init__.py +++ b/django_odesk/__init__.py @@ -1,4 +1,4 @@ -VERSION = (0, 0, 3, 'alpha', 1) +VERSION = (0, 0, 2, 'final', 0) def get_version(): version = '%s.%s' % (VERSION[0], VERSION[1]) diff --git a/django_odesk/auth/backends.py b/django_odesk/auth/backends.py index 321b2b0..7e18208 100644 --- a/django_odesk/auth/backends.py +++ b/django_odesk/auth/backends.py @@ -8,6 +8,7 @@ from django.contrib.auth.models import Group from django.db import transaction from django_odesk.auth.models import get_user_model +from django_odesk.auth.utils import sync_odesk_permissions from django_odesk.conf import settings from django_odesk.core.clients import DefaultClient @@ -107,12 +108,12 @@ def has_module_perms(self, user_obj, app_label): class BaseModelBackend(ModelBackend): create_unknown_user = True + create_unknown_group = True + sync_permissions_on_login = True def __init__(self, *args, **kwargs): super(BaseModelBackend, self).__init__(*args, **kwargs) - self.api_token = None - self.auth_user = None - self.client = None + pass def authenticate(self, token=None): client = DefaultClient(token) @@ -130,18 +131,22 @@ def authenticate(self, token=None): if self.create_unknown_user: user, created = model.objects.get_or_create(username=username) if created: - user = self.configure_user(user, auth_user) + user = self.configure_user(user, auth_user, token) else: try: user = model.objects.get(username=username) except model.DoesNotExist: pass + + if self.sync_permissions_on_login: + sync_odesk_permissions(user, token, self.create_unknown_group) + return user def clean_username(self, auth_user): - return auth_user['mail'] + return auth_user['uid']+'@odesk.com' - def configure_user(self, user, auth_user): + def configure_user(self, user, auth_user, token): return user def get_user(self, user_id): @@ -153,109 +158,32 @@ def get_user(self, user_id): class ModelBackend(BaseModelBackend): - + + supports_object_permissions = False + @property def create_unknown_user(self): return settings.ODESK_CREATE_UNKNOWN_USER - def set_user_info(self, user, auth_user): + @property + def create_unknown_group(self): + return settings.ODESK_CREATE_UNKNOWN_GROUP + + @property + def sync_permissions_on_login(self): + return settings.ODESK_SYNC_PERMISSIONS_ON_LOGIN + + def configure_user(self, user, auth_user, token): user.first_name = auth_user['first_name'] user.last_name = auth_user['last_name'] user.email = auth_user['mail'] - user.set_unusable_password() - return user - - def set_user_status(self, user, auth_user): admins = settings.ODESK_ADMINS superusers = settings.ODESK_SUPERUSERS if user.username in admins: user.is_staff = True if user.username in superusers: user.is_superuser = True - return user - - def configure_user(self, user, auth_user): - self.set_user_info(user, auth_user) - self.set_user_status(user, auth_user) + user.set_unusable_password() user.save() - return user - - -class TeamAuthBackend(ModelBackend): - - def sync_django_groups(self, user, userteams): - """ - Assign existing `Group`s with userteam names to `user.groups` - """ - from django.db import connection - - def clear_groups(user): - cursor = connection.cursor() - cursor.execute("DELETE FROM auth_user_groups WHERE user_id=%s", (user.id,)) - - def bulk_groups_insert(user, groups_query): - group_ids = filter(lambda gid: gid is not None, - (grp.get('id') for grp in groups_query.values('id'))) - values = zip([user.id]*len(group_ids), group_ids) - if len(values) > 0: - sql_values = ','.join("(%i,%i)" % v for v in values) - cursor = connection.cursor() - cursor.execute("INSERT INTO auth_user_groups (user_id, group_id) VALUES %s" % sql_values) - - @transaction.commit_on_success - def run_in_tx(): - clear_groups(user) - bulk_groups_insert(user, Group.objects.filter(name__in=userteams)) - - run_in_tx() - - - def authenticate(self, token=None): - client = DefaultClient(token) - self.client = client - try: - api_token, auth_user = client.auth.check_token() - self.api_token, self.auth_user = api_token, auth_user - except HTTPError: - return None - - user = None - username = self.clean_username(auth_user) - model = get_user_model() - - userteams = set(team[u'id'] for team in client.hr.get_teams()) - # TODO authorize subteams of parents in ODESK_AUTH_TEAMS - auth_teams = userteams.intersection(set(settings.ODESK_AUTH_TEAMS)) - - if auth_teams or username in settings.ODESK_AUTH_USERS: - - if self.create_unknown_user: - user, created = model.objects.get_or_create(username=username) - user = self.set_user_info(user, auth_user) - else: - try: - user = model.objects.get(username=username) - except model.DoesNotExist: - pass - - if user is not None: - - self.sync_django_groups(user, auth_teams) - - if userteams.intersection(set(settings.ODESK_AUTH_ADMIN_TEAMS)) or \ - username in settings.ODESK_ADMINS: - user.is_staff=True - else: - user.is_staff=False - - if userteams.intersection(set(settings.ODESK_AUTH_SUPERUSER_TEAMS)) or \ - username in settings.ODESK_SUPERUSERS: - user.is_superuser=True - else: - user.is_superuser=False - - # attach api_token to user instance, so it can be passed to post_save signal handler - user.odesk_api_token = api_token - user.save() - + sync_odesk_permissions(user, token, self.create_unknown_group) return user diff --git a/django_odesk/auth/decorators.py b/django_odesk/auth/decorators.py new file mode 100644 index 0000000..172d050 --- /dev/null +++ b/django_odesk/auth/decorators.py @@ -0,0 +1,12 @@ +from django.contrib.auth.decorators import user_passes_test +from django.contrib.auth.models import Group + +def group_required(names, login_url=None): + """ + Checks if the user is a member of a particular group (or at least one + group from the list) + """ + if not hasattr(names,'__iter__'): + names = [names] + return user_passes_test(lambda u: u.groups.filter(name__in=names), + login_url=login_url) diff --git a/django_odesk/auth/utils.py b/django_odesk/auth/utils.py new file mode 100644 index 0000000..362f702 --- /dev/null +++ b/django_odesk/auth/utils.py @@ -0,0 +1,61 @@ +from django.db import IntegrityError +from django.contrib.auth.models import Group + +from django_odesk.core.clients import DefaultClient +from django_odesk.conf import settings + +def get_odesk_permissions(auth_token): + """ + Gets oDesk team roles/permissions for the authenticated user + """ + client = DefaultClient(auth_token) + response = client.hr.get_user_role() + permissions = [] + # oDesk API returns empty string if user has no membership in any group + if not response: + return [] + roles = response['userrole'] + # oDesk API returns dict if there is single role + if isinstance(roles, dict): + roles = [roles] + for record in roles: + data = dict(team_id = record['team__id'], role=record['role']) + if record['permissions']: + data['permissions'] = record['permissions']['permission'] + permissions.append(data) + return permissions + + + +def sync_odesk_permissions(user, auth_token, create_groups=True): + """ + Syncs oDesk team roles/permissions with Django groups + """ + permissions = get_odesk_permissions(auth_token) + user.groups.through.objects.filter(user=user, + group__name__contains = '@odesk.com').delete() + + def get_or_create_group(name): + created = False + group = None + try: + group = Group.objects.get(name=name) + except Group.DoesNotExist: + if create_groups: + group = Group(name=name) + group.save() + if group: + user.groups.add(group) + return group, created + + for perm in permissions: + name = '%s@odesk.com' % perm['team_id'] + group, c = get_or_create_group(name=name) + if settings.ODESK_CREATE_PSEUDO_GROUPS: + #Adding special groups for admins + #May later add other pseudo-roles + if perm['role'] == 'admin': + name = '%s:%s@odesk.com' % (perm['team_id'], 'admins') + group, c = get_or_create_group(name=name) + return True + diff --git a/django_odesk/auth/views.py b/django_odesk/auth/views.py index 4dc7112..98da3ca 100644 --- a/django_odesk/auth/views.py +++ b/django_odesk/auth/views.py @@ -8,7 +8,7 @@ from django_odesk.auth import ODESK_REDIRECT_SESSION_KEY, \ ODESK_TOKEN_SESSION_KEY, \ ENCRYPTION_KEY_NAME - +from django_odesk.conf import settings from encrypt import encrypt_token def authenticate(request): @@ -32,9 +32,14 @@ def callback(request, redirect_url=None): } logging.error(msg) return HttpResponseRedirect(redirect_url or '/') - encryption_key, encrypted_token = encrypt_token(token) - - request.session[ODESK_TOKEN_SESSION_KEY] = encrypted_token + if not settings.ODESK_AUTH_ONLY: + if settings.ODESK_ENCRYPT_API_TOKEN: + encryption_key, encrypted_token = encrypt_token(token) + put_in_session = encrypted_token + else: + put_in_session = token + request.session[ODESK_TOKEN_SESSION_KEY] = put_in_session + #TODO: Get rid of (conceptually correct) additional request to odesk.com user = django_authenticate(token = token) if user: @@ -45,9 +50,10 @@ def callback(request, redirect_url=None): redirect_url = request.session.pop(ODESK_REDIRECT_SESSION_KEY, redirect_url) response = HttpResponseRedirect(redirect_url or '/') - expires = datetime.timedelta(hours = 2) + datetime.datetime.utcnow() # this is for Django 1.3 - # string conversion for django 1.2 somehow doesn't work either, so I use max_age - response.set_cookie(ENCRYPTION_KEY_NAME, encryption_key, expires = expires, max_age = 60*60*2) + if not settings.ODESK_AUTH_ONLY and settings.ODESK_ENCRYPT_API_TOKEN: + expires = datetime.timedelta(hours = 2) + datetime.datetime.utcnow() # this is for Django 1.3 + # string conversion for django 1.2 somehow doesn't work either, so I use max_age + response.set_cookie(ENCRYPTION_KEY_NAME, encryption_key, expires = expires, max_age = 60*60*2) return response else: diff --git a/django_odesk/conf/default_settings.py b/django_odesk/conf/default_settings.py index 107ec18..b7d8abd 100644 --- a/django_odesk/conf/default_settings.py +++ b/django_odesk/conf/default_settings.py @@ -1,10 +1,33 @@ - -#TODO: Annotate settings - +#oDesk API keys ODESK_PUBLIC_KEY = '' ODESK_PRIVATE_KEY = '' +#Whether a new user should be created if doesn't exist in the DB yet ODESK_CREATE_UNKNOWN_USER = True + +#Setting this to True would prevent the backend from storing the +#authentication token in the session, so no API calls would be possible. +#Use if all you want is authentication +ODESK_AUTH_ONLY = False + +#Define oDesk users who will get staff status when added to the DB ODESK_ADMINS = () + +#Define oDesk users who will get superuser status when added to the DB ODESK_SUPERUSERS = () + +#Use to define your own `User` model ODESK_CUSTOM_USER_MODEL = None + +#Whether a new group should be created if doesn't exist in the DB yet +ODESK_CREATE_UNKNOWN_GROUP = True + +#Define whether "pseudo-groups" (like company:team:admins) are created +ODESK_CREATE_PSEUDO_GROUPS = False + +#Do we want permissions (groups) to be synced with oDesk +#each time user logs in +ODESK_SYNC_PERMISSIONS_ON_LOGIN = False + +#Whether oDesk api_token should be encrypted or not +ODESK_ENCRYPT_API_TOKEN = True diff --git a/django_odesk/core/clients.py b/django_odesk/core/clients.py index b425339..4e7278c 100644 --- a/django_odesk/core/clients.py +++ b/django_odesk/core/clients.py @@ -6,6 +6,7 @@ from django_odesk.auth import ODESK_TOKEN_SESSION_KEY, ENCRYPTION_KEY_NAME from django_odesk.auth.encrypt import decrypt_token + class DefaultClient(Client): def __init__(self, api_token=None): @@ -21,10 +22,14 @@ def __init__(self, api_token=None): class RequestClient(DefaultClient): def __init__(self, request): - encryption_key = request.COOKIES.get(ENCRYPTION_KEY_NAME, None) - encrypted_token = request.session.get(ODESK_TOKEN_SESSION_KEY, None) - api_token = None - if encryption_key and encrypted_token: - api_token = decrypt_token(encryption_key, encrypted_token) + from_session = request.session.get(ODESK_TOKEN_SESSION_KEY, None) + if settings.ODESK_ENCRYPT_API_TOKEN: + encryption_key = request.COOKIES.get(ENCRYPTION_KEY_NAME, None) + encrypted_token = from_session + api_token = None + if encryption_key and encrypted_token: + api_token = decrypt_token(encryption_key, encrypted_token) + else: + api_token = from_session super(RequestClient, self).__init__(api_token) diff --git a/setup.py b/setup.py index 8a55461..9b20219 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from distutils.core import setup +from setuptools import setup, find_packages version = __import__('django_odesk').get_version() @@ -8,8 +8,8 @@ long_description='', author='Oleksiy Solyanyk', author_email='solex@odesk.com', - packages = ['django_odesk','django_odesk.auth', 'django_odesk.core', 'django_odesk.conf'], - install_requires = ['python-odesk>=0.1.2', ], + packages = find_packages(), + install_requires = ['setuptools','python-odesk>=0.1.2', 'pycrypto>=2.1'], classifiers=['Development Status :: 1 - Alpha', 'Environment :: Web Environment', 'Intended Audience :: Developers',