├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── primate ├── __init__.py ├── admin.py ├── auth │ ├── __init__.py │ ├── base.py │ ├── forms.py │ ├── helpers.py │ ├── mixins.py │ └── models.py └── models.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | ._* 4 | .~*# 5 | *.swp 6 | *.swo 7 | *.swn 8 | .svn 9 | *.orig 10 | _build 11 | build 12 | dist 13 | *.egg-info 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | django-primate 2 | ============== 3 | 4 | Copyright (c) 2010, Aino 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the sorl-thumbnail the names of its contributors 18 | may be used to endorse or promote products derived from this software 19 | without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | Django 33 | ====== 34 | Copyright (c) Django Software Foundation and individual contributors. 35 | All rights reserved. 36 | 37 | Redistribution and use in source and binary forms, with or without modification, 38 | are permitted provided that the following conditions are met: 39 | 40 | 1. Redistributions of source code must retain the above copyright notice, 41 | this list of conditions and the following disclaimer. 42 | 43 | 2. Redistributions in binary form must reproduce the above copyright 44 | notice, this list of conditions and the following disclaimer in the 45 | documentation and/or other materials provided with the distribution. 46 | 47 | 3. Neither the name of Django nor the names of its contributors may be used 48 | to endorse or promote products derived from this software without 49 | specific prior written permission. 50 | 51 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 52 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 53 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 54 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 55 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 56 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 57 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 58 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 59 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 60 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-primate 2 | ============== 3 | 4 | - A modular django user. 5 | 6 | I am not going to discuss if this is a good idea or not. This Django 7 | application monkey patches django in order to have a custom User model that 8 | plugs into the ``django.contrib.auth`` application. 9 | 10 | 11 | Installation 12 | ------------ 13 | First of all install the module by checking out the latest code or use pip:: 14 | 15 | pip install django-primate 16 | 17 | In order to monkey patch we need to do this early. I have created a small 18 | modified ``manage.py`` file that you can use for your development. This sets up 19 | your environment and right before you run your management command (such as 20 | ``runserver``) we apply the patch. Copy this into your project and overwrite the 21 | default ``manage.py``:: 22 | 23 | #!/usr/bin/env python 24 | from django.core.management import setup_environ, ManagementUtility 25 | import imp 26 | try: 27 | imp.find_module('settings') # Assumed to be in the same directory. 28 | except ImportError: 29 | import sys 30 | sys.stderr.write( 31 | "Error: Can't find the file 'settings.py' in the directory " 32 | "containing %r. It appears you've customized things.\nYou'll have to " 33 | "run django-admin.py, passing it your settings module.\n" % __file__ 34 | ) 35 | sys.exit(1) 36 | 37 | import settings 38 | 39 | if __name__ == "__main__": 40 | setup_environ(settings) 41 | import primate 42 | primate.patch() 43 | ManagementUtility().execute() 44 | 45 | To monkey patch your deployment you would apply the patch right after setting up 46 | the ``DJANGO_SETTINGS_MODULE``. 47 | 48 | 49 | Now add ``django.contrib.auth`` to your ``INSTALLED_APPS`` 50 | 51 | 52 | Using 53 | ----- 54 | After installing this patch you effectively have no User model at all. You have 55 | to create one on your own and define it in your settings. I will give you an 56 | example on how to do this using the provided ``UserBase`` class. 57 | 58 | ``project/users/models.py``:: 59 | 60 | from primate.models import UserBase, UserMeta 61 | from django.db import models 62 | 63 | class CustomUser(UserBase): 64 | __metaclass__ = UserMeta 65 | name = models.CharField(max_length=500, default='Jon Deg') 66 | title = models.CharField(max_length=20, blank=True) 67 | 68 | 69 | ``settings.py``:: 70 | 71 | ``AUTH_USER_MODEL = 'users.models.CustomUser'`` 72 | 73 | 74 | Now you can import this model by ``from django.contrib.auth.models import 75 | User`` or ``from project.users.models import CustomUser`` 76 | 77 | 78 | Custom fields and overriding default fields 79 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 80 | It's simple 81 | 82 | - To add a field just add a field to the model as you would normally. 83 | - To override a field just override the field name and it will be used instead 84 | of the one defined in ``UserBase``. 85 | 86 | The overriding feature is something special not available in normal Django 87 | model abstract classes and is done in the custom metaclass. You can also remove 88 | fields defined in the ``UserBase`` class by altering the metaclass a little, you 89 | can have a look in the code, its a really simple. 90 | 91 | 92 | Admin 93 | ^^^^^ 94 | To make the admin work I have made the monkey patch ``primate.patch`` patch the 95 | ``admin.autodiscover`` so that it does not register the default admin class for 96 | ``django.contrib.auth.User``. This means that you will need to register that 97 | your self. The easiest way to do that is to first add ``users`` to your 98 | ``INSTALLED_APPS`` and then add something like this to ``users/admin.py``:: 99 | 100 | from primate.admin import UserAdminBase 101 | from django.contrib import admin 102 | from django.contrib.auth.models import User 103 | 104 | 105 | class UserAdmin(UserAdminBase): 106 | pass 107 | 108 | 109 | admin.site.register(User, UserAdmin) 110 | 111 | 112 | What's new in UserBase compared to django.contrib.auth.models.User? 113 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 114 | I have made some minor changes: 115 | 116 | 1. Removed ``first_name`` and ``last_name`` 117 | 118 | 2. Added ``name`` 119 | 120 | 3. ``username`` is now max 50 chars 121 | 122 | 4. Made ``email`` unique 123 | 124 | 5. ``get_profile`` method just returns self 125 | 126 | 127 | As stated earlier, you can now change all this, remove add and override fields 128 | in your user model. 129 | 130 | 131 | South 132 | ^^^^^ 133 | I was worried, this is a major feature, luckily Andrew already thought of this: 134 | quote from the documentation under ``SOUTH_MIGRATION_MODULES``: 135 | 136 | "Note that the keys in this dictionary are ‘app labels’, not the full paths to 137 | apps; for example, were I to provide a migrations directory for 138 | django.contrib.auth, I'd want to use auth as the key here." 139 | 140 | So the time has come, just add this to your settings:: 141 | 142 | SOUTH_MIGRATION_MODULES = { 143 | 'auth': 'users.migrations', 144 | } 145 | 146 | 147 | Alternative password hashing 148 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 149 | SHA-1 is the default django hashing algorithm for passwords. Some may not agree 150 | that this is the best choice. ``django-primate`` makes it simple for you to use 151 | alternative hashing as you can just override the ``check_password`` and 152 | ``set_password`` methods in your custom user model. Since bcrypt is a good 153 | choice there is a simple way for you to implement hashing using this:: 154 | 155 | # project/users/models.py 156 | 157 | from primate.models import UserBase, UserMeta, BcryptMixin 158 | from django.db import models 159 | 160 | class CustomUser(BcryptMixin, UserBase): 161 | __metaclass__ = UserMeta 162 | 163 | 164 | Note that this will update all passwords on authorization success to use bcrypt. 165 | 166 | -------------------------------------------------------------------------------- /primate/__init__.py: -------------------------------------------------------------------------------- 1 | def patch(): 2 | """ 3 | Monkeypatches the django.contrib.auth.models module and the admin 4 | autodiscover function 5 | """ 6 | import django.contrib.auth 7 | import primate.auth.models 8 | django.contrib.auth.models = primate.auth.models 9 | import django.contrib.admin 10 | import primate.admin 11 | django.contrib.admin.autodiscover = primate.admin.autodiscover 12 | -------------------------------------------------------------------------------- /primate/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.admin.sites import site 3 | from django.contrib.auth.forms import UserCreationForm, AdminPasswordChangeForm 4 | from django.contrib.auth.models import Group 5 | from django.contrib import admin 6 | from django.contrib import messages 7 | from django.core.exceptions import PermissionDenied 8 | from django.db import transaction 9 | from django.http import HttpResponseRedirect, Http404 10 | from django.shortcuts import render_to_response, get_object_or_404 11 | from django.template import RequestContext 12 | from django.utils.decorators import method_decorator 13 | from django.utils.html import escape 14 | from django.utils.translation import ugettext, ugettext_lazy as _ 15 | from django.views.decorators.csrf import csrf_protect 16 | from primate.auth.forms import UserChangeForm 17 | 18 | 19 | csrf_protect_m = method_decorator(csrf_protect) 20 | 21 | 22 | def autodiscover(): 23 | """ 24 | Auto-discover INSTALLED_APPS admin.py modules and fail silently when 25 | not present. This forces an import on them to register any admin bits they 26 | may want. 27 | """ 28 | 29 | import copy 30 | from django.conf import settings 31 | from django.utils.importlib import import_module 32 | from django.utils.module_loading import module_has_submodule 33 | 34 | for app in settings.INSTALLED_APPS: 35 | if app == 'django.contrib.auth': 36 | admin.site.register(Group, GroupAdmin) 37 | continue 38 | mod = import_module(app) 39 | # Attempt to import the app's admin module. 40 | try: 41 | before_import_registry = copy.copy(site._registry) 42 | import_module('%s.admin' % app) 43 | except: 44 | # Reset the model registry to the state before the last import as 45 | # this import will have to reoccur on the next request and this 46 | # could raise NotRegistered and AlreadyRegistered exceptions 47 | # (see #8245). 48 | site._registry = before_import_registry 49 | 50 | # Decide whether to bubble up this error. If the app just 51 | # doesn't have an admin module, we can ignore the error 52 | # attempting to import it, otherwise we want it to bubble up. 53 | if module_has_submodule(mod, 'admin'): 54 | raise 55 | 56 | 57 | class GroupAdmin(admin.ModelAdmin): 58 | search_fields = ('name',) 59 | ordering = ('name',) 60 | filter_horizontal = ('permissions',) 61 | 62 | 63 | class UserAdminBase(admin.ModelAdmin): 64 | add_form_template = 'admin/auth/user/add_form.html' 65 | change_user_password_template = None 66 | fieldsets = ( 67 | (None, {'fields': ('username', 'password')}), 68 | (_('Personal info'), {'fields': ('name', 'email')}), 69 | (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), 70 | (_('Groups'), {'fields': ('groups',)}), 71 | ) 72 | add_fieldsets = ( 73 | (None, { 74 | 'classes': ('wide',), 75 | 'fields': ('username', 'password1', 'password2')} 76 | ), 77 | ) 78 | form = UserChangeForm 79 | add_form = UserCreationForm 80 | change_password_form = AdminPasswordChangeForm 81 | list_display = ('username', 'email', 'name', 'is_staff') 82 | list_filter = ('is_staff', 'is_superuser', 'is_active') 83 | search_fields = ('username', 'name', 'email') 84 | ordering = ('username',) 85 | filter_horizontal = ('user_permissions',) 86 | 87 | def __call__(self, request, url): 88 | # this should not be here, but must be due to the way __call__ routes 89 | # in ModelAdmin. 90 | if url is None: 91 | return self.changelist_view(request) 92 | if url.endswith('password'): 93 | return self.user_change_password(request, url.split('/')[0]) 94 | return super(UserAdminBase, self).__call__(request, url) 95 | 96 | def get_fieldsets(self, request, obj=None): 97 | if not obj: 98 | return self.add_fieldsets 99 | return super(UserAdminBase, self).get_fieldsets(request, obj) 100 | 101 | def get_form(self, request, obj=None, **kwargs): 102 | """ 103 | Use special form during user creation 104 | """ 105 | defaults = {} 106 | if obj is None: 107 | defaults.update({ 108 | 'form': self.add_form, 109 | 'fields': admin.util.flatten_fieldsets(self.add_fieldsets), 110 | }) 111 | defaults.update(kwargs) 112 | return super(UserAdminBase, self).get_form(request, obj, **defaults) 113 | 114 | def get_urls(self): 115 | from django.conf.urls.defaults import patterns 116 | return patterns('', 117 | (r'^(\d+)/password/$', self.admin_site.admin_view(self.user_change_password)) 118 | ) + super(UserAdminBase, self).get_urls() 119 | 120 | @csrf_protect_m 121 | @transaction.commit_on_success 122 | def add_view(self, request, form_url='', extra_context=None): 123 | # It's an error for a user to have add permission but NOT change 124 | # permission for users. If we allowed such users to add users, they 125 | # could create superusers, which would mean they would essentially have 126 | # the permission to change users. To avoid the problem entirely, we 127 | # disallow users from adding users if they don't have change 128 | # permission. 129 | if not self.has_change_permission(request): 130 | if self.has_add_permission(request) and settings.DEBUG: 131 | # Raise Http404 in debug mode so that the user gets a helpful 132 | # error message. 133 | raise Http404('Your user does not have the "Change user" permission. In order to add users, Django requires that your user account have both the "Add user" and "Change user" permissions set.') 134 | raise PermissionDenied 135 | if extra_context is None: 136 | extra_context = {} 137 | defaults = { 138 | 'auto_populated_fields': (), 139 | 'username_help_text': self.model._meta.get_field('username').help_text, 140 | } 141 | extra_context.update(defaults) 142 | return super(UserAdminBase, self).add_view(request, form_url, extra_context) 143 | 144 | def user_change_password(self, request, id): 145 | if not self.has_change_permission(request): 146 | raise PermissionDenied 147 | user = get_object_or_404(self.model, pk=id) 148 | if request.method == 'POST': 149 | form = self.change_password_form(user, request.POST) 150 | if form.is_valid(): 151 | new_user = form.save() 152 | msg = ugettext('Password changed successfully.') 153 | messages.success(request, msg) 154 | return HttpResponseRedirect('..') 155 | else: 156 | form = self.change_password_form(user) 157 | 158 | fieldsets = [(None, {'fields': form.base_fields.keys()})] 159 | adminForm = admin.helpers.AdminForm(form, fieldsets, {}) 160 | 161 | return render_to_response(self.change_user_password_template or 'admin/auth/user/change_password.html', { 162 | 'title': _('Change password: %s') % escape(user.username), 163 | 'adminForm': adminForm, 164 | 'form': form, 165 | 'is_popup': '_popup' in request.REQUEST, 166 | 'add': True, 167 | 'change': False, 168 | 'has_delete_permission': False, 169 | 'has_change_permission': True, 170 | 'has_absolute_url': False, 171 | 'opts': self.model._meta, 172 | 'original': user, 173 | 'save_as': False, 174 | 'show_save': True, 175 | 'root_path': self.admin_site.root_path, 176 | }, context_instance=RequestContext(request)) 177 | 178 | def response_add(self, request, obj, post_url_continue='../%s/'): 179 | """ 180 | Determines the HttpResponse for the add_view stage. It mostly defers to 181 | its superclass implementation but is customized because the User model 182 | has a slightly different workflow. 183 | """ 184 | # We should allow further modification of the user just added i.e. the 185 | # 'Save' button should behave like the 'Save and continue editing' 186 | # button except in two scenarios: 187 | # * The user has pressed the 'Save and add another' button 188 | # * We are adding a user in a popup 189 | if '_addanother' not in request.POST and '_popup' not in request.POST: 190 | request.POST['_continue'] = 1 191 | return super(UserAdminBase, self).response_add(request, obj, post_url_continue) 192 | 193 | -------------------------------------------------------------------------------- /primate/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorl/django-primate/632935de77e48ef54ebabaf0bb94a471228959ef/primate/auth/__init__.py -------------------------------------------------------------------------------- /primate/auth/base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.contrib import auth 3 | from django.db import models 4 | from django.db.models.base import ModelBase 5 | from django.db.models.fields import Field 6 | from django.utils.encoding import smart_str 7 | from django.utils.translation import ugettext_lazy as _ 8 | from primate.auth.helpers import * 9 | 10 | 11 | UNUSABLE_PASSWORD = '!' # This will never be a valid hash 12 | 13 | 14 | class UserBaseMeta(ModelBase): 15 | """ 16 | Meta class for abstract class ``UserBase``. This class hides the model 17 | fields on allocation so that you can pick them up or not in a meta class 18 | that inherits this. 19 | """ 20 | base_fields = {} 21 | 22 | def __new__(cls, name, bases, attrs): 23 | for k, v in attrs.items(): 24 | if isinstance(v, Field): 25 | cls.base_fields[k] = attrs.pop(k) 26 | return ModelBase.__new__(cls, name, bases, attrs) 27 | 28 | 29 | class UserMeta(UserBaseMeta): 30 | """ 31 | This is what implementing User class need to use 32 | """ 33 | def __new__(cls, name, bases, attrs): 34 | # inject UserBase fields 35 | for k, v in cls.base_fields.items(): 36 | if k not in attrs: 37 | attrs[k] = cls.base_fields[k] 38 | return ModelBase.__new__(cls, 'User', bases, attrs) 39 | 40 | 41 | class UserManager(models.Manager): 42 | def create_user(self, username, email, password=None): 43 | """ 44 | Creates and saves a User with the given username, e-mail and password. 45 | """ 46 | now = datetime.datetime.now() 47 | 48 | # Normalize the address by lowercasing the domain part of the email 49 | # address. 50 | try: 51 | email_name, domain_part = email.strip().split('@', 1) 52 | except ValueError: 53 | pass 54 | else: 55 | email = '@'.join([email_name, domain_part.lower()]) 56 | 57 | user = self.model(username=username, email=email, is_staff=False, 58 | is_active=True, is_superuser=False, last_login=now, 59 | date_joined=now) 60 | 61 | user.set_password(password) 62 | user.save(using=self._db) 63 | return user 64 | 65 | def create_superuser(self, username, email, password): 66 | u = self.create_user(username, email, password) 67 | u.is_staff = True 68 | u.is_active = True 69 | u.is_superuser = True 70 | u.save(using=self._db) 71 | return u 72 | 73 | def make_random_password(self, length=10, allowed_chars='abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'): 74 | "Generates a random password with the given length and given allowed_chars" 75 | # Note that default value of allowed_chars does not have "I" or letters 76 | # that look like it -- just to avoid confusion. 77 | from random import choice 78 | return ''.join([choice(allowed_chars) for i in range(length)]) 79 | 80 | 81 | class UserBase(models.Model): 82 | """ 83 | Users within the Django authentication system are represented by this model. 84 | 85 | Username and password are required. Other fields are optional. 86 | """ 87 | __metaclass__ = UserBaseMeta 88 | username = models.CharField(_('username'), max_length=50, unique=True, help_text=_("Required. 30 characters or fewer. Letters, numbers and @/./+/-/_ characters")) 89 | name = models.CharField(_('name'), max_length=100, blank=True) 90 | email = models.EmailField(_('e-mail address'), blank=True, null=True, unique=True) 91 | password = models.CharField(_('password'), max_length=128) 92 | is_staff = models.BooleanField(_('staff status'), default=False, help_text=_("Designates whether the user can log into this admin site.")) 93 | is_active = models.BooleanField(_('active'), default=True, help_text=_("Designates whether this user should be treated as active. Unselect this instead of deleting accounts.")) 94 | is_superuser = models.BooleanField(_('superuser status'), default=False, help_text=_("Designates that this user has all permissions without explicitly assigning them.")) 95 | last_login = models.DateTimeField(_('last login'), default=datetime.datetime.now, editable=False) 96 | date_joined = models.DateTimeField(_('date joined'), default=datetime.datetime.now, editable=False) 97 | groups = models.ManyToManyField('auth.Group', verbose_name=_('groups'), blank=True, 98 | help_text=_("In addition to the permissions manually assigned, this user will also get all permissions granted to each group he/she is in.")) 99 | user_permissions = models.ManyToManyField('auth.Permission', verbose_name=_('user permissions'), blank=True) 100 | 101 | objects = UserManager() 102 | 103 | class Meta: 104 | abstract = True 105 | app_label = 'auth' 106 | verbose_name = _('user') 107 | verbose_name_plural = _('users') 108 | 109 | def __unicode__(self): 110 | return self.username 111 | 112 | def is_anonymous(self): 113 | """ 114 | Always returns False. This is a way of comparing User objects to 115 | anonymous users. 116 | """ 117 | return False 118 | 119 | def is_authenticated(self): 120 | """ 121 | Always return True. This is a way to tell if the user has been 122 | authenticated in templates. 123 | """ 124 | return True 125 | 126 | def get_full_name(self): 127 | return self.name 128 | 129 | def set_password(self, raw_password): 130 | if raw_password is None: 131 | self.set_unusable_password() 132 | else: 133 | import random 134 | algo = 'sha1' 135 | salt = get_hexdigest(algo, str(random.random()), str(random.random()))[:5] 136 | hsh = get_hexdigest(algo, salt, raw_password) 137 | self.password = '%s$%s$%s' % (algo, salt, hsh) 138 | 139 | def check_password(self, raw_password): 140 | """ 141 | Returns a boolean of whether the raw_password was correct. Handles 142 | encryption formats behind the scenes. 143 | """ 144 | # Backwards-compatibility check. Older passwords won't include the 145 | # algorithm or salt. 146 | if '$' not in self.password: 147 | is_correct = (self.password == get_hexdigest('md5', '', raw_password)) 148 | if is_correct: 149 | # Convert the password to the new, more secure format. 150 | self.set_password(raw_password) 151 | self.save() 152 | return is_correct 153 | return check_password(raw_password, self.password) 154 | 155 | def set_unusable_password(self): 156 | # Sets a value that will never be a valid hash 157 | self.password = UNUSABLE_PASSWORD 158 | 159 | def has_usable_password(self): 160 | if self.password is None \ 161 | or self.password == UNUSABLE_PASSWORD: 162 | return False 163 | else: 164 | return True 165 | 166 | def get_group_permissions(self, obj=None): 167 | """ 168 | Returns a list of permission strings that this user has through 169 | his/her groups. This method queries all available auth backends. 170 | If an object is passed in, only permissions matching this object 171 | are returned. 172 | """ 173 | permissions = set() 174 | for backend in auth.get_backends(): 175 | if hasattr(backend, "get_group_permissions"): 176 | if obj is not None: 177 | if backend.supports_object_permissions: 178 | permissions.update( 179 | backend.get_group_permissions(self, obj) 180 | ) 181 | else: 182 | permissions.update(backend.get_group_permissions(self)) 183 | return permissions 184 | 185 | def get_all_permissions(self, obj=None): 186 | return _user_get_all_permissions(self, obj) 187 | 188 | def has_perm(self, perm, obj=None): 189 | """ 190 | Returns True if the user has the specified permission. This method 191 | queries all available auth backends, but returns immediately if any 192 | backend returns True. Thus, a user who has permission from a single 193 | auth backend is assumed to have permission in general. If an object 194 | is provided, permissions for this specific object are checked. 195 | """ 196 | 197 | # Active superusers have all permissions. 198 | if self.is_active and self.is_superuser: 199 | return True 200 | 201 | # Otherwise we need to check the backends. 202 | return _user_has_perm(self, perm, obj) 203 | 204 | def has_perms(self, perm_list, obj=None): 205 | """ 206 | Returns True if the user has each of the specified permissions. 207 | If object is passed, it checks if the user has all required perms 208 | for this object. 209 | """ 210 | for perm in perm_list: 211 | if not self.has_perm(perm, obj): 212 | return False 213 | return True 214 | 215 | def has_module_perms(self, app_label): 216 | """ 217 | Returns True if the user has any permissions in the given app 218 | label. Uses pretty much the same logic as has_perm, above. 219 | """ 220 | # Active superusers have all permissions. 221 | if self.is_active and self.is_superuser: 222 | return True 223 | 224 | return _user_has_module_perms(self, app_label) 225 | 226 | def get_and_delete_messages(self): 227 | messages = [] 228 | for m in self.message_set.all(): 229 | messages.append(m.message) 230 | m.delete() 231 | return messages 232 | 233 | def email_user(self, subject, message, from_email=None): 234 | "Sends an e-mail to this User." 235 | from django.core.mail import send_mail 236 | send_mail(subject, message, from_email, [self.email]) 237 | 238 | def get_profile(self): 239 | return self 240 | 241 | def _get_message_set(self): 242 | import warnings 243 | warnings.warn('The user messaging API is deprecated. Please update' 244 | ' your code to use the new messages framework.', 245 | category=DeprecationWarning) 246 | return self._message_set 247 | message_set = property(_get_message_set) 248 | 249 | 250 | -------------------------------------------------------------------------------- /primate/auth/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib import auth 2 | from django import forms 3 | from django.utils.safestring import mark_safe 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | 7 | class PassWidget(forms.Widget): 8 | def render(self, name, value, attrs=None): 9 | return mark_safe(u'' % _('Change Password')) 10 | 11 | 12 | class UserChangeForm(auth.forms.UserChangeForm): 13 | def __init__(self, *args, **kwargs): 14 | super(UserChangeForm, self).__init__(*args, **kwargs) 15 | self.fields['password'].widget = PassWidget() 16 | 17 | -------------------------------------------------------------------------------- /primate/auth/helpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.contrib.auth.signals import user_logged_in 3 | from django.contrib import auth 4 | from django.utils.crypto import constant_time_compare 5 | from django.utils.encoding import smart_str 6 | from django.utils.hashcompat import md5_constructor, sha_constructor 7 | 8 | 9 | __all__ = ('SiteUserNotAvailable', 'get_hexdigest', 'check_password', 10 | 'update_last_login', '_user_get_all_permissions', '_user_has_perm', 11 | '_user_has_module_perms') 12 | 13 | 14 | class SiteUserNotAvailable(Exception): 15 | pass 16 | 17 | 18 | def get_hexdigest(algorithm, salt, raw_password): 19 | """ 20 | Returns a string of the hexdigest of the given plaintext password and salt 21 | using the given algorithm ('md5', 'sha1' or 'crypt'). 22 | """ 23 | raw_password, salt = smart_str(raw_password), smart_str(salt) 24 | if algorithm == 'crypt': 25 | try: 26 | import crypt 27 | except ImportError: 28 | raise ValueError('"crypt" password algorithm not supported in this ' 29 | 'environment') 30 | return crypt.crypt(raw_password, salt) 31 | 32 | if algorithm == 'md5': 33 | return md5_constructor(salt + raw_password).hexdigest() 34 | elif algorithm == 'sha1': 35 | return sha_constructor(salt + raw_password).hexdigest() 36 | raise ValueError("Got unknown password algorithm type in password.") 37 | 38 | 39 | def check_password(raw_password, enc_password): 40 | """ 41 | Returns a boolean of whether the raw_password was correct. Handles 42 | encryption formats behind the scenes. 43 | """ 44 | algo, salt, hsh = enc_password.split('$') 45 | return constant_time_compare(hsh, get_hexdigest(algo, salt, raw_password)) 46 | 47 | 48 | def update_last_login(sender, user, **kwargs): 49 | """ 50 | A signal receiver which updates the last_login date for 51 | the user logging in. 52 | """ 53 | user.last_login = datetime.datetime.now() 54 | user.save() 55 | user_logged_in.connect(update_last_login) 56 | 57 | 58 | # A few helper functions for common logic between User and AnonymousUser. 59 | def _user_get_all_permissions(user, obj): 60 | permissions = set() 61 | anon = user.is_anonymous() 62 | for backend in auth.get_backends(): 63 | if not anon or backend.supports_anonymous_user: 64 | if hasattr(backend, "get_all_permissions"): 65 | if obj is not None: 66 | if backend.supports_object_permissions: 67 | permissions.update( 68 | backend.get_all_permissions(user, obj) 69 | ) 70 | else: 71 | permissions.update(backend.get_all_permissions(user)) 72 | return permissions 73 | 74 | 75 | def _user_has_perm(user, perm, obj): 76 | anon = user.is_anonymous() 77 | active = user.is_active 78 | for backend in auth.get_backends(): 79 | if (not active and not anon and backend.supports_inactive_user) or \ 80 | (not anon or backend.supports_anonymous_user): 81 | if hasattr(backend, "has_perm"): 82 | if obj is not None: 83 | if (backend.supports_object_permissions and 84 | backend.has_perm(user, perm, obj)): 85 | return True 86 | else: 87 | if backend.has_perm(user, perm): 88 | return True 89 | return False 90 | 91 | 92 | def _user_has_module_perms(user, app_label): 93 | anon = user.is_anonymous() 94 | active = user.is_active 95 | for backend in auth.get_backends(): 96 | if (not active and not anon and backend.supports_inactive_user) or \ 97 | (not anon or backend.supports_anonymous_user): 98 | if hasattr(backend, "has_module_perms"): 99 | if backend.has_module_perms(user, app_label): 100 | return True 101 | return False 102 | 103 | -------------------------------------------------------------------------------- /primate/auth/mixins.py: -------------------------------------------------------------------------------- 1 | from django.utils.crypto import constant_time_compare as ctcmp 2 | from django.utils.encoding import smart_str 3 | 4 | 5 | class BcryptMixin(object): 6 | rounds = 12 7 | 8 | def check_password(self, raw_password): 9 | import bcrypt 10 | if self.password.startswith('bcrypt$'): 11 | hash_ = self.password[6:] # remove bcrypt prefix 12 | salt = hash_[:29] 13 | return ctcmp(hash_, bcrypt.hashpw(raw_password, salt)) 14 | if super(BcryptMixin, self).check_password(raw_password): 15 | # Convert the password to the new, more secure format. 16 | self.set_password(raw_password) 17 | self.save() 18 | return True 19 | return False 20 | 21 | def set_password(self, raw_password): 22 | import bcrypt 23 | if raw_password is None: 24 | self.set_unusable_password() 25 | else: 26 | raw_password = smart_str(raw_password) 27 | salt = bcrypt.gensalt(self.rounds) 28 | self.password = 'bcrypt%s' % bcrypt.hashpw(raw_password, salt) 29 | 30 | -------------------------------------------------------------------------------- /primate/auth/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db import models 3 | from django.db.models.manager import EmptyManager 4 | from django.utils.importlib import import_module 5 | from django.utils.translation import ugettext_lazy as _ 6 | from primate.auth.helpers import * 7 | 8 | 9 | def register_user_model(): 10 | from django.conf import settings 11 | if not getattr(settings, 'AUTH_USER_MODEL', False): 12 | raise SiteUserNotAvailable( 13 | 'You need to set AUTH_USER_MODEL in your project settings' 14 | ) 15 | try: 16 | mod_name, cls_name = settings.AUTH_USER_MODEL.rsplit('.', 1) 17 | mod = import_module(mod_name) 18 | except ImportError, e: 19 | raise SiteUserNotAvailable(e) 20 | try: 21 | getattr(mod, cls_name) 22 | except AttributeError, e: 23 | raise SiteUserNotAvailable(e) 24 | register_user_model() 25 | 26 | 27 | class PermissionManager(models.Manager): 28 | def get_by_natural_key(self, codename, app_label, model): 29 | return self.get( 30 | codename=codename, 31 | content_type=ContentType.objects.get_by_natural_key(app_label, model) 32 | ) 33 | 34 | 35 | class Permission(models.Model): 36 | """ 37 | The permissions system provides a way to assign permissions to specific 38 | users and groups of users. 39 | 40 | The permission system is used by the Django admin site, but may also be 41 | useful in your own code. The Django admin site uses permissions as follows: 42 | 43 | - The "add" permission limits the user's ability to view the "add" form 44 | and add an object. 45 | - The "change" permission limits a user's ability to view the change 46 | list, view the "change" form and change an object. 47 | - The "delete" permission limits the ability to delete an object. 48 | 49 | Permissions are set globally per type of object, not per specific object 50 | instance. It is possible to say "Mary may change news stories," but it's 51 | not currently possible to say "Mary may change news stories, but only the 52 | ones she created herself" or "Mary may only change news stories that have a 53 | certain status or publication date." 54 | 55 | Three basic permissions -- add, change and delete -- are automatically 56 | created for each Django model. 57 | """ 58 | 59 | name = models.CharField(_('name'), max_length=50) 60 | content_type = models.ForeignKey(ContentType) 61 | codename = models.CharField(_('codename'), max_length=100) 62 | objects = PermissionManager() 63 | 64 | class Meta: 65 | verbose_name = _('permission') 66 | verbose_name_plural = _('permissions') 67 | unique_together = (('content_type', 'codename'),) 68 | ordering = ('content_type__app_label', 'content_type__model', 'codename') 69 | 70 | def __unicode__(self): 71 | return u"%s | %s | %s" % ( 72 | unicode(self.content_type.app_label), 73 | unicode(self.content_type), 74 | unicode(self.name)) 75 | 76 | def natural_key(self): 77 | return (self.codename,) + self.content_type.natural_key() 78 | natural_key.dependencies = ['contenttypes.contenttype'] 79 | 80 | 81 | class Group(models.Model): 82 | """ 83 | Groups are a generic way of categorizing users to apply permissions, or 84 | some other label, to those users. A user can belong to any number of 85 | groups. 86 | 87 | A user in a group automatically has all the permissions granted to that 88 | group. For example, if the group Site editors has the permission 89 | can_edit_home_page, any user in that group will have that permission. 90 | 91 | Beyond permissions, groups are a convenient way to categorize users to 92 | apply some label, or extended functionality, to them. For example, you 93 | could create a group 'Special users', and you could write code that would 94 | do special things to those users -- such as giving them access to a 95 | members-only portion of your site, or sending them members-only e-mail 96 | messages. 97 | """ 98 | 99 | name = models.CharField(_('name'), max_length=80, unique=True) 100 | permissions = models.ManyToManyField(Permission, verbose_name=_('permissions'), blank=True) 101 | 102 | class Meta: 103 | verbose_name = _('group') 104 | verbose_name_plural = _('groups') 105 | 106 | def __unicode__(self): 107 | return self.name 108 | 109 | 110 | class Message(models.Model): 111 | """ 112 | The message system is a lightweight way to queue messages for given 113 | users. A message is associated with a User instance (so it is only 114 | applicable for registered users). There's no concept of expiration or 115 | timestamps. Messages are created by the Django admin after successful 116 | actions. For example, "The poll Foo was created successfully." is a 117 | message. 118 | """ 119 | 120 | user = models.ForeignKey('auth.User', related_name='_message_set') 121 | message = models.TextField(_('message')) 122 | 123 | def __unicode__(self): 124 | return self.message 125 | 126 | 127 | class AnonymousUser(object): 128 | id = None 129 | username = '' 130 | is_staff = False 131 | is_active = False 132 | is_superuser = False 133 | _groups = EmptyManager() 134 | _user_permissions = EmptyManager() 135 | 136 | def __init__(self): 137 | pass 138 | 139 | def __unicode__(self): 140 | return 'AnonymousUser' 141 | 142 | def __str__(self): 143 | return unicode(self).encode('utf-8') 144 | 145 | def __eq__(self, other): 146 | return isinstance(other, self.__class__) 147 | 148 | def __ne__(self, other): 149 | return not self.__eq__(other) 150 | 151 | def __hash__(self): 152 | return 1 # instances always return the same hash value 153 | 154 | def save(self): 155 | raise NotImplementedError 156 | 157 | def delete(self): 158 | raise NotImplementedError 159 | 160 | def set_password(self, raw_password): 161 | raise NotImplementedError 162 | 163 | def check_password(self, raw_password): 164 | raise NotImplementedError 165 | 166 | def _get_groups(self): 167 | return self._groups 168 | groups = property(_get_groups) 169 | 170 | def _get_user_permissions(self): 171 | return self._user_permissions 172 | user_permissions = property(_get_user_permissions) 173 | 174 | def get_group_permissions(self, obj=None): 175 | return set() 176 | 177 | def get_all_permissions(self, obj=None): 178 | return _user_get_all_permissions(self, obj=obj) 179 | 180 | def has_perm(self, perm, obj=None): 181 | return _user_has_perm(self, perm, obj=obj) 182 | 183 | def has_perms(self, perm_list, obj=None): 184 | for perm in perm_list: 185 | if not self.has_perm(perm, obj): 186 | return False 187 | return True 188 | 189 | def has_module_perms(self, module): 190 | return _user_has_module_perms(self, module) 191 | 192 | def get_and_delete_messages(self): 193 | return [] 194 | 195 | def is_anonymous(self): 196 | return True 197 | 198 | def is_authenticated(self): 199 | return False 200 | -------------------------------------------------------------------------------- /primate/models.py: -------------------------------------------------------------------------------- 1 | from primate.auth.base import UserBase, UserMeta 2 | from primate.auth.mixins import BcryptMixin 3 | 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='django-primate', 6 | version='0.1.1', 7 | description='A modular django user', 8 | long_description=open('README.rst').read(), 9 | author='Mikko Hellsing', 10 | author_email='mikko@aino.se', 11 | license='BSD', 12 | url='https://github.com/aino/django-primate', 13 | platforms='any', 14 | packages=find_packages(), 15 | zip_safe=False, 16 | classifiers=[ 17 | 'Development Status :: 3 - Alpha', 18 | 'Environment :: Web Environment', 19 | 'Intended Audience :: Developers', 20 | 'License :: OSI Approved :: BSD License', 21 | 'Operating System :: OS Independent', 22 | 'Programming Language :: Python', 23 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 24 | 'Framework :: Django', 25 | ], 26 | ) 27 | 28 | --------------------------------------------------------------------------------