From 3e1d9f7d22bac04a1d1c0a9edffb7bd44cf7ba4a Mon Sep 17 00:00:00 2001 From: Marc Aymerich Date: Wed, 11 May 2016 12:56:10 +0000 Subject: [PATCH] Added support for raw hash password edditing --- TODO.md | 4 + orchestra/admin/forms.py | 62 ++++++++++++---- orchestra/admin/options.py | 35 ++++++--- .../project_template/project_name/settings.py | 5 ++ orchestra/contrib/accounts/models.py | 6 +- orchestra/contrib/databases/forms.py | 3 +- orchestra/contrib/mailer/actions.py | 5 +- orchestra/contrib/orchestration/helpers.py | 15 +++- orchestra/contrib/orchestration/managers.py | 73 +++++++++++++++++++ .../contrib/orchestration/middlewares.py | 6 +- orchestra/contrib/payments/actions.py | 5 ++ orchestra/contrib/payments/admin.py | 1 - orchestra/forms/options.py | 3 +- orchestra/models/utils.py | 9 +++ .../admin/orchestra/change_password.html | 8 +- 15 files changed, 200 insertions(+), 40 deletions(-) create mode 100644 orchestra/contrib/orchestration/managers.py diff --git a/TODO.md b/TODO.md index 39a3827f..28461b3c 100644 --- a/TODO.md +++ b/TODO.md @@ -460,3 +460,7 @@ with open(file) as handler: # change filter By PHP version: by detail + +# Mark transaction process as executed should not override higher transaction states + +# Show password and set password management commands -sam -A|--all --systemuser --account --mailbox vs raw passwords on forms diff --git a/orchestra/admin/forms.py b/orchestra/admin/forms.py index 3cc06866..90fa72e9 100644 --- a/orchestra/admin/forms.py +++ b/orchestra/admin/forms.py @@ -3,6 +3,7 @@ from functools import partial from django import forms from django.contrib.admin import helpers +from django.contrib.auth.hashers import identify_hasher from django.core import validators from django.forms.models import modelformset_factory, BaseModelFormSet from django.template import Template, Context @@ -84,8 +85,11 @@ class AdminPasswordChangeForm(forms.Form): error_messages = { 'password_mismatch': _("The two password fields didn't match."), 'password_missing': _("No password has been provided."), + 'bad_hash': _("Invalid password format or unknown hashing algorithm."), } required_css_class = 'required' + password = forms.CharField(label=_("Password"), required=False, + widget=forms.TextInput(attrs={'size':'120'})) password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput, required=False, validators=[validate_password]) password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput, @@ -93,9 +97,14 @@ class AdminPasswordChangeForm(forms.Form): def __init__(self, user, *args, **kwargs): self.related = kwargs.pop('related', []) + self.raw = kwargs.pop('raw', False) self.user = user - super(AdminPasswordChangeForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + self.password_provided = False for ix, rel in enumerate(self.related): + self.fields['password_%i' % ix] = forms.CharField(label=_("Password"), required=False, + widget=forms.TextInput(attrs={'size':'120'})) + setattr(self, 'clean_password_%i' % ix, partial(self.clean_password, ix=ix)) self.fields['password1_%i' % ix] = forms.CharField(label=_("Password"), widget=forms.PasswordInput, required=False) self.fields['password2_%i' % ix] = forms.CharField(label=_("Password (again)"), @@ -108,35 +117,53 @@ class AdminPasswordChangeForm(forms.Form): password1 = self.cleaned_data.get('password1%s' % ix) password2 = self.cleaned_data.get('password2%s' % ix) if password1 and password2: + self.password_provided = True if password1 != password2: raise forms.ValidationError( self.error_messages['password_mismatch'], code='password_mismatch', ) elif password1 or password2: + self.password_provided = True raise forms.ValidationError( self.error_messages['password_mismatch'], code='password_mismatch', ) return password2 + def clean_password(self, ix=''): + if ix != '': + ix = '_%i' % ix + password = self.cleaned_data.get('password%s' % ix) + if password: + self.password_provided = True + try: + hasher = identify_hasher(password) + except ValueError: + raise forms.ValidationError( + self.error_messages['bad_hash'], + code='bad_hash', + ) + return password + def clean(self): - cleaned_data = super(AdminPasswordChangeForm, self).clean() - for data in cleaned_data.values(): - if data: - return - raise forms.ValidationError( - self.error_messages['password_missing'], - code='password_missing', - ) + if not self.password_provided: + raise forms.ValidationError( + self.error_messages['password_missing'], + code='password_missing', + ) def save(self, commit=True): """ Saves the new password. """ - password = self.cleaned_data["password1"] + field_name = 'password' if self.raw else 'password1' + password = self.cleaned_data[field_name] if password: - self.user.set_password(password) + if self.raw: + self.password = password + else: + self.user.set_password(password) if commit: try: self.user.save(update_fields=['password']) @@ -144,16 +171,19 @@ class AdminPasswordChangeForm(forms.Form): # password is not a field but an attribute self.user.save() # Trigger the backend for ix, rel in enumerate(self.related): - password = self.cleaned_data['password1_%s' % ix] + password = self.cleaned_data['%s_%s' % (field_name, ix)] if password: - set_password = getattr(rel, 'set_password') - set_password(password) + if raw: + rel.password = password + else: + set_password = getattr(rel, 'set_password') + set_password(password) if commit: rel.save(update_fields=['password']) return self.user def _get_changed_data(self): - data = super(AdminPasswordChangeForm, self).changed_data + data = super().changed_data for name in self.fields.keys(): if name not in data: return [] @@ -173,7 +203,7 @@ class SendEmailForm(forms.Form): widget=forms.Textarea(attrs={'cols': 118, 'rows': 15})) def __init__(self, *args, **kwargs): - super(SendEmailForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) initial = kwargs.get('initial') if 'to' in initial: self.fields['to'].widget = SpanWidget(original=initial['to']) diff --git a/orchestra/admin/options.py b/orchestra/admin/options.py index 50361e18..3bce46f9 100644 --- a/orchestra/admin/options.py +++ b/orchestra/admin/options.py @@ -7,7 +7,7 @@ from django.contrib.admin.options import IS_POPUP_VAR from django.contrib.admin.utils import unquote from django.contrib.auth import update_session_auth_hash from django.core.exceptions import PermissionDenied -from django.http import HttpResponseRedirect, Http404 +from django.http import HttpResponseRedirect, Http404, HttpResponse from django.forms.models import BaseInlineFormSet from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse @@ -17,9 +17,12 @@ from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ from django.views.decorators.debug import sensitive_post_parameters +from orchestra.models.utils import has_db_field + from ..utils.python import random_ascii, pairwise from .forms import AdminPasswordChangeForm +#, AdminRawPasswordChangeForm #from django.contrib.auth.forms import AdminPasswordChangeForm from .utils import action_to_view @@ -240,8 +243,11 @@ class ChangePasswordAdminMixin(object): return [ url(r'^(\d+)/password/$', self.admin_site.admin_view(self.change_password), - name='%s_%s_change_password' % info) - ] + super(ChangePasswordAdminMixin, self).get_urls() + name='%s_%s_change_password' % info), + url(r'^(\d+)/hash/$', + self.admin_site.admin_view(self.show_hash), + name='%s_%s_show_hash' % info) + ] + super().get_urls() def get_change_password_username(self, obj): return str(obj) @@ -252,7 +258,10 @@ class ChangePasswordAdminMixin(object): raise PermissionDenied # TODO use this insetad of self.get_object(), in other places obj = get_object_or_404(self.get_queryset(request), pk=id) - + raw = request.GET.get('raw', '0') == '1' + can_raw = has_db_field(obj, 'password') + if raw and not can_raw: + raise TypeError("%s has no password db field for raw password edditing." % obj) related = [] for obj_name_attr in ('username', 'name', 'hostname'): try: @@ -268,12 +277,12 @@ class ChangePasswordAdminMixin(object): else: account = obj if account.username == obj_name: - for rel in account.get_related_passwords(): + for rel in account.get_related_passwords(db_field=raw): if not isinstance(obj, type(rel)): related.append(rel) if request.method == 'POST': - form = self.change_password_form(obj, request.POST, related=related) + form = self.change_password_form(obj, request.POST, related=related, raw=raw) if form.is_valid(): form.save() change_message = self.construct_change_message(request, form, None) @@ -283,18 +292,18 @@ class ChangePasswordAdminMixin(object): update_session_auth_hash(request, form.user) # This is safe return HttpResponseRedirect('..') else: - form = self.change_password_form(obj, related=related) + form = self.change_password_form(obj, related=related, raw=raw) fieldsets = [ (obj._meta.verbose_name.capitalize(), { 'classes': ('wide',), - 'fields': ('password1', 'password2') + 'fields': ('password',) if raw else ('password1', 'password2'), }), ] for ix, rel in enumerate(related): fieldsets.append((rel._meta.verbose_name.capitalize(), { 'classes': ('wide',), - 'fields': ('password1_%i' % ix, 'password2_%i' % ix) + 'fields': ('password_%i' % ix,) if raw else ('password1_%i' % ix, 'password2_%i' % ix) })) obj_username = self.get_change_password_username(obj) @@ -302,6 +311,8 @@ class ChangePasswordAdminMixin(object): context = { 'title': _('Change password: %s') % obj_username, 'adminform': adminForm, + 'raw': raw, + 'can_raw': can_raw, 'errors': admin.helpers.AdminErrorList(form, []), 'form_url': form_url, 'is_popup': (IS_POPUP_VAR in request.POST or @@ -322,3 +333,9 @@ class ChangePasswordAdminMixin(object): return TemplateResponse(request, self.change_user_password_template, context, current_app=self.admin_site.name) + + def show_hash(self, request, id): + if not request.user.is_superuser: + raise PermissionDenied + obj = get_object_or_404(self.get_queryset(request), pk=id) + return HttpResponse(obj.password) diff --git a/orchestra/conf/project_template/project_name/settings.py b/orchestra/conf/project_template/project_name/settings.py index 6a7cfe23..dc683c45 100644 --- a/orchestra/conf/project_template/project_name/settings.py +++ b/orchestra/conf/project_template/project_name/settings.py @@ -246,3 +246,8 @@ PASSLIB_CONFIG = ( "superuser__django_pbkdf2_sha256__default_rounds = 15000\n" "superuser__sha512_crypt__default_rounds = 120000\n" ) + + +SHELL_PLUS_PRE_IMPORTS = ( + ('orchestra.contrib.orchestration.managers', ('orchestrate',)), +) diff --git a/orchestra/contrib/accounts/models.py b/orchestra/contrib/accounts/models.py index 54e163a8..1272becf 100644 --- a/orchestra/contrib/accounts/models.py +++ b/orchestra/contrib/accounts/models.py @@ -10,6 +10,7 @@ from django.utils.translation import ugettext_lazy as _ #from orchestra.contrib.orchestration.middlewares import OperationsMiddleware #from orchestra.contrib.orchestration import Operation from orchestra.core import services +from orchestra.models.utils import has_db_field from orchestra.utils.mail import send_email_template from . import settings @@ -158,7 +159,7 @@ class Account(auth.AbstractBaseUser): return True return auth._user_has_module_perms(self, app_label) - def get_related_passwords(self): + def get_related_passwords(self, db_field=False): related = [ self.main_systemuser, ] @@ -173,5 +174,8 @@ class Account(auth.AbstractBaseUser): rel = model.objects.get(account=self, **kwargs) except model.DoesNotExist: continue + if db_field: + if not has_db_field(rel, 'password'): + continue related.append(rel) return related diff --git a/orchestra/contrib/databases/forms.py b/orchestra/contrib/databases/forms.py index 75dc3a3c..83e2895d 100644 --- a/orchestra/contrib/databases/forms.py +++ b/orchestra/contrib/databases/forms.py @@ -112,7 +112,8 @@ class DatabaseUserChangeForm(forms.ModelForm): password = ReadOnlySQLPasswordHashField(label=_("Password"), help_text=_("Raw passwords are not stored, so there is no way to see " "this user's password, but you can change the password " - "using this form.")) + "using this form. " + "Show hash.")) class Meta: model = DatabaseUser diff --git a/orchestra/contrib/mailer/actions.py b/orchestra/contrib/mailer/actions.py index ad94cd16..d4a50f58 100644 --- a/orchestra/contrib/mailer/actions.py +++ b/orchestra/contrib/mailer/actions.py @@ -3,7 +3,6 @@ from django.shortcuts import redirect def last(modeladmin, request, queryset): - last_id = queryset.order_by('id').values_list('id', flat=True).first() - url = reverse('admin:mailer_message_change', args=(last_id,)) - print(url) + last = queryset.model.objects.latest('id') + url = reverse('admin:mailer_message_change', args=(last.pk,)) return redirect(url) diff --git a/orchestra/contrib/orchestration/helpers.py b/orchestra/contrib/orchestration/helpers.py index 5300b73a..a3aa6d64 100644 --- a/orchestra/contrib/orchestration/helpers.py +++ b/orchestra/contrib/orchestration/helpers.py @@ -103,7 +103,8 @@ def get_backend_url(ids): return '' -def message_user(request, logs): +def get_messages(logs): + messages = [] total, successes, async = 0, 0, 0 ids = [] async_ids = [] @@ -140,7 +141,7 @@ def message_user(request, logs): msg += ', ' + str(async_msg) msg = msg.format(errors=errors, async=async, async_url=async_url, total=total, url=url, name=log.backend) - messages.error(request, mark_safe(msg + '.')) + messages.append(('error', msg + '.')) elif successes: if async_msg: if total == 1: @@ -160,7 +161,13 @@ def message_user(request, logs): total=total, url=url, async_url=async_url, async=async, successes=successes, name=log.backend ) - messages.success(request, mark_safe(msg + '.')) + messages.append(('success', msg + '.')) else: msg = async_msg.format(url=url, async_url=async_url, async=async, name=log.backend) - messages.success(request, mark_safe(msg + '.')) + messages.append(('success', msg + '.')) + return messages + + +def message_user(request, logs): + for func, msg in get_messages(logs): + getattr(messages, func)(request, mark_safe(msg)) diff --git a/orchestra/contrib/orchestration/managers.py b/orchestra/contrib/orchestration/managers.py new file mode 100644 index 00000000..c91847da --- /dev/null +++ b/orchestra/contrib/orchestration/managers.py @@ -0,0 +1,73 @@ +import sys +from threading import local + +from django.contrib.admin.models import LogEntry +from django.db.models.signals import pre_delete, post_save, m2m_changed +from django.dispatch import receiver +from django.utils.decorators import ContextDecorator + +from orchestra.utils.python import OrderedSet + +from . import manager, Operation, helpers +from .middlewares import OperationsMiddleware +from .models import BackendLog, BackendOperation + + +@receiver(post_save, dispatch_uid='orchestration.post_save_manager_collector') +def post_save_collector(sender, *args, **kwargs): + if sender not in (BackendLog, BackendOperation, LogEntry): + instance = kwargs.get('instance') + orchestrate.collect(Operation.SAVE, **kwargs) + + +@receiver(pre_delete, dispatch_uid='orchestration.pre_delete_manager_collector') +def pre_delete_collector(sender, *args, **kwargs): + if sender not in (BackendLog, BackendOperation, LogEntry): + orchestrate.collect(Operation.DELETE, **kwargs) + + +@receiver(m2m_changed, dispatch_uid='orchestration.m2m_manager_collector') +def m2m_collector(sender, *args, **kwargs): + # m2m relations without intermediary models are shit. Model.post_save is not sent and + # by the time related.post_save is sent rel objects are not accessible via RelatedManager.all() + if kwargs.pop('action') == 'post_add' and kwargs['pk_set']: + orchestrate.collect(Operation.SAVE, **kwargs) + + +class orchestrate(ContextDecorator): + thread_locals = local() + thread_locals.pending_operations = None + thread_locals.route_cache = None + + @classmethod + def collect(cls, action, **kwargs): + """ Collects all pending operations derived from model signals """ + if cls.thread_locals.pending_operations is None: + # No active orchestrate context manager + return + kwargs['operations'] = cls.thread_locals.pending_operations + kwargs['route_cache'] = cls.thread_locals.route_cache + instance = kwargs.pop('instance') + manager.collect(instance, action, **kwargs) + + def __enter__(self): + cls = type(self) + self.old_pending_operations = cls.thread_locals.pending_operations + cls.thread_locals.pending_operations = OrderedSet() + self.old_route_cache = cls.thread_locals.route_cache + cls.thread_locals.route_cache = {} + + def __exit__(self, exc_type, exc_value, traceback): + cls = type(self) + if not exc_type: + operations = cls.thread_locals.pending_operations + if operations: + scripts, serialize = manager.generate(operations) + logs = manager.execute(scripts, serialize=serialize) + for t, msg in helpers.get_messages(logs): + if t == 'error': + sys.stderr.write('%s: %s\n' % (t, msg)) + else: + sys.stdout.write('%s: %s\n' % (t, msg)) + cls.thread_locals.pending_operations = self.old_pending_operations + cls.thread_locals.route_cache = self.old_route_cache diff --git a/orchestra/contrib/orchestration/middlewares.py b/orchestra/contrib/orchestration/middlewares.py index 86272342..eb53ec25 100644 --- a/orchestra/contrib/orchestration/middlewares.py +++ b/orchestra/contrib/orchestration/middlewares.py @@ -11,19 +11,19 @@ from orchestra.utils.python import OrderedSet from . import manager, Operation from .helpers import message_user -from .models import BackendLog +from .models import BackendLog, BackendOperation @receiver(post_save, dispatch_uid='orchestration.post_save_collector') def post_save_collector(sender, *args, **kwargs): - if sender not in (BackendLog, Operation, LogEntry): + if sender not in (BackendLog, BackendOperation, LogEntry): instance = kwargs.get('instance') OperationsMiddleware.collect(Operation.SAVE, **kwargs) @receiver(pre_delete, dispatch_uid='orchestration.pre_delete_collector') def pre_delete_collector(sender, *args, **kwargs): - if sender not in (BackendLog, Operation, LogEntry): + if sender not in (BackendLog, BackendOperation, LogEntry): OperationsMiddleware.collect(Operation.DELETE, **kwargs) diff --git a/orchestra/contrib/payments/actions.py b/orchestra/contrib/payments/actions.py index 43ccb2a1..ad73661f 100644 --- a/orchestra/contrib/payments/actions.py +++ b/orchestra/contrib/payments/actions.py @@ -213,6 +213,11 @@ def reissue(modeladmin, request, queryset): messages.error(request, _("One transaction should be selected.")) return trans = queryset[0] + if trans.state != trans.REJECTED: + messages.error(request, + _("Only rejected transactions can be reissued, " + "please reject current transaction if necessary.")) + return url = reverse('admin:payments_transaction_add') url += '?account=%i&bill=%i&source=%s&amount=%s¤cy=%s' % ( trans.bill.account_id, diff --git a/orchestra/contrib/payments/admin.py b/orchestra/contrib/payments/admin.py index 4c5a02e0..7814d1d7 100644 --- a/orchestra/contrib/payments/admin.py +++ b/orchestra/contrib/payments/admin.py @@ -99,7 +99,6 @@ class TransactionAdmin(SelectAccountAdminMixin, ExtendedModelAdmin): 'display_state', 'amount', 'currency', - 'process' ) }), ) diff --git a/orchestra/forms/options.py b/orchestra/forms/options.py index a7a5bbe6..4b3fbf40 100644 --- a/orchestra/forms/options.py +++ b/orchestra/forms/options.py @@ -62,7 +62,8 @@ class UserChangeForm(forms.ModelForm): password = auth_forms.ReadOnlyPasswordHashField(label=_("Password"), help_text=_("Raw passwords are not stored, so there is no way to see " "this user's password, but you can change it by " - "using this form.")) + "using this form. " + "Show hash.")) def clean_password(self): # Regardless of what the user provides, return the initial value. diff --git a/orchestra/models/utils.py b/orchestra/models/utils.py index 79aab558..e9514fe8 100644 --- a/orchestra/models/utils.py +++ b/orchestra/models/utils.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.exceptions import FieldDoesNotExist from django.apps import apps import importlib @@ -16,6 +17,14 @@ def get_model(label, import_module=True): return model +def has_db_field(obj, field_name): + try: + obj._meta.get_field(field_name) + except FieldDoesNotExist: + return False + return True + + def get_field_value(obj, field_name): names = field_name.split('__') rel = getattr(obj, names.pop(0)) diff --git a/orchestra/templates/admin/orchestra/change_password.html b/orchestra/templates/admin/orchestra/change_password.html index 9af7dbdb..bc18e4a6 100644 --- a/orchestra/templates/admin/orchestra/change_password.html +++ b/orchestra/templates/admin/orchestra/change_password.html @@ -8,7 +8,13 @@
{% if is_popup %}{% endif %} {% if to_field %}{% endif %} -

{% blocktrans with username=obj_username %}Enter a new password for the user {{ username }}, suggestion '{{ password }}'.{% endblocktrans %}

+

+{% if raw %} +{% blocktrans with username=obj_username %}Enter a new password hash for user {{ username }}. Switch to text password form.{% endblocktrans %} +{% elif can_raw %} +{% blocktrans with username=obj_username %}Enter a new password for user {{ username }}, suggestion '{{ password }}'. Switch to raw password form.{% endblocktrans %} +{% endif %} +

{% if errors %}