Added support for raw hash password edditing

This commit is contained in:
Marc Aymerich 2016-05-11 12:56:10 +00:00
parent e804a1d102
commit 3e1d9f7d22
15 changed files with 200 additions and 40 deletions

View File

@ -460,3 +460,7 @@ with open(file) as handler:
# change filter By PHP version: by detail # 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

View File

@ -3,6 +3,7 @@ from functools import partial
from django import forms from django import forms
from django.contrib.admin import helpers from django.contrib.admin import helpers
from django.contrib.auth.hashers import identify_hasher
from django.core import validators from django.core import validators
from django.forms.models import modelformset_factory, BaseModelFormSet from django.forms.models import modelformset_factory, BaseModelFormSet
from django.template import Template, Context from django.template import Template, Context
@ -84,8 +85,11 @@ class AdminPasswordChangeForm(forms.Form):
error_messages = { error_messages = {
'password_mismatch': _("The two password fields didn't match."), 'password_mismatch': _("The two password fields didn't match."),
'password_missing': _("No password has been provided."), 'password_missing': _("No password has been provided."),
'bad_hash': _("Invalid password format or unknown hashing algorithm."),
} }
required_css_class = 'required' 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, password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput,
required=False, validators=[validate_password]) required=False, validators=[validate_password])
password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput, password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput,
@ -93,9 +97,14 @@ class AdminPasswordChangeForm(forms.Form):
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.related = kwargs.pop('related', []) self.related = kwargs.pop('related', [])
self.raw = kwargs.pop('raw', False)
self.user = user self.user = user
super(AdminPasswordChangeForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.password_provided = False
for ix, rel in enumerate(self.related): 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"), self.fields['password1_%i' % ix] = forms.CharField(label=_("Password"),
widget=forms.PasswordInput, required=False) widget=forms.PasswordInput, required=False)
self.fields['password2_%i' % ix] = forms.CharField(label=_("Password (again)"), 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) password1 = self.cleaned_data.get('password1%s' % ix)
password2 = self.cleaned_data.get('password2%s' % ix) password2 = self.cleaned_data.get('password2%s' % ix)
if password1 and password2: if password1 and password2:
self.password_provided = True
if password1 != password2: if password1 != password2:
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['password_mismatch'], self.error_messages['password_mismatch'],
code='password_mismatch', code='password_mismatch',
) )
elif password1 or password2: elif password1 or password2:
self.password_provided = True
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['password_mismatch'], self.error_messages['password_mismatch'],
code='password_mismatch', code='password_mismatch',
) )
return password2 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): def clean(self):
cleaned_data = super(AdminPasswordChangeForm, self).clean() if not self.password_provided:
for data in cleaned_data.values(): raise forms.ValidationError(
if data: self.error_messages['password_missing'],
return code='password_missing',
raise forms.ValidationError( )
self.error_messages['password_missing'],
code='password_missing',
)
def save(self, commit=True): def save(self, commit=True):
""" """
Saves the new password. 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: if password:
self.user.set_password(password) if self.raw:
self.password = password
else:
self.user.set_password(password)
if commit: if commit:
try: try:
self.user.save(update_fields=['password']) self.user.save(update_fields=['password'])
@ -144,16 +171,19 @@ class AdminPasswordChangeForm(forms.Form):
# password is not a field but an attribute # password is not a field but an attribute
self.user.save() # Trigger the backend self.user.save() # Trigger the backend
for ix, rel in enumerate(self.related): 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: if password:
set_password = getattr(rel, 'set_password') if raw:
set_password(password) rel.password = password
else:
set_password = getattr(rel, 'set_password')
set_password(password)
if commit: if commit:
rel.save(update_fields=['password']) rel.save(update_fields=['password'])
return self.user return self.user
def _get_changed_data(self): def _get_changed_data(self):
data = super(AdminPasswordChangeForm, self).changed_data data = super().changed_data
for name in self.fields.keys(): for name in self.fields.keys():
if name not in data: if name not in data:
return [] return []
@ -173,7 +203,7 @@ class SendEmailForm(forms.Form):
widget=forms.Textarea(attrs={'cols': 118, 'rows': 15})) widget=forms.Textarea(attrs={'cols': 118, 'rows': 15}))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SendEmailForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
initial = kwargs.get('initial') initial = kwargs.get('initial')
if 'to' in initial: if 'to' in initial:
self.fields['to'].widget = SpanWidget(original=initial['to']) self.fields['to'].widget = SpanWidget(original=initial['to'])

View File

@ -7,7 +7,7 @@ from django.contrib.admin.options import IS_POPUP_VAR
from django.contrib.admin.utils import unquote from django.contrib.admin.utils import unquote
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.core.exceptions import PermissionDenied 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.forms.models import BaseInlineFormSet
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse 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.utils.translation import ugettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters 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 ..utils.python import random_ascii, pairwise
from .forms import AdminPasswordChangeForm from .forms import AdminPasswordChangeForm
#, AdminRawPasswordChangeForm
#from django.contrib.auth.forms import AdminPasswordChangeForm #from django.contrib.auth.forms import AdminPasswordChangeForm
from .utils import action_to_view from .utils import action_to_view
@ -240,8 +243,11 @@ class ChangePasswordAdminMixin(object):
return [ return [
url(r'^(\d+)/password/$', url(r'^(\d+)/password/$',
self.admin_site.admin_view(self.change_password), self.admin_site.admin_view(self.change_password),
name='%s_%s_change_password' % info) name='%s_%s_change_password' % info),
] + super(ChangePasswordAdminMixin, self).get_urls() 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): def get_change_password_username(self, obj):
return str(obj) return str(obj)
@ -252,7 +258,10 @@ class ChangePasswordAdminMixin(object):
raise PermissionDenied raise PermissionDenied
# TODO use this insetad of self.get_object(), in other places # TODO use this insetad of self.get_object(), in other places
obj = get_object_or_404(self.get_queryset(request), pk=id) 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 = [] related = []
for obj_name_attr in ('username', 'name', 'hostname'): for obj_name_attr in ('username', 'name', 'hostname'):
try: try:
@ -268,12 +277,12 @@ class ChangePasswordAdminMixin(object):
else: else:
account = obj account = obj
if account.username == obj_name: 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)): if not isinstance(obj, type(rel)):
related.append(rel) related.append(rel)
if request.method == 'POST': 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(): if form.is_valid():
form.save() form.save()
change_message = self.construct_change_message(request, form, None) 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 update_session_auth_hash(request, form.user) # This is safe
return HttpResponseRedirect('..') return HttpResponseRedirect('..')
else: else:
form = self.change_password_form(obj, related=related) form = self.change_password_form(obj, related=related, raw=raw)
fieldsets = [ fieldsets = [
(obj._meta.verbose_name.capitalize(), { (obj._meta.verbose_name.capitalize(), {
'classes': ('wide',), 'classes': ('wide',),
'fields': ('password1', 'password2') 'fields': ('password',) if raw else ('password1', 'password2'),
}), }),
] ]
for ix, rel in enumerate(related): for ix, rel in enumerate(related):
fieldsets.append((rel._meta.verbose_name.capitalize(), { fieldsets.append((rel._meta.verbose_name.capitalize(), {
'classes': ('wide',), '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) obj_username = self.get_change_password_username(obj)
@ -302,6 +311,8 @@ class ChangePasswordAdminMixin(object):
context = { context = {
'title': _('Change password: %s') % obj_username, 'title': _('Change password: %s') % obj_username,
'adminform': adminForm, 'adminform': adminForm,
'raw': raw,
'can_raw': can_raw,
'errors': admin.helpers.AdminErrorList(form, []), 'errors': admin.helpers.AdminErrorList(form, []),
'form_url': form_url, 'form_url': form_url,
'is_popup': (IS_POPUP_VAR in request.POST or 'is_popup': (IS_POPUP_VAR in request.POST or
@ -322,3 +333,9 @@ class ChangePasswordAdminMixin(object):
return TemplateResponse(request, return TemplateResponse(request,
self.change_user_password_template, self.change_user_password_template,
context, current_app=self.admin_site.name) 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)

View File

@ -246,3 +246,8 @@ PASSLIB_CONFIG = (
"superuser__django_pbkdf2_sha256__default_rounds = 15000\n" "superuser__django_pbkdf2_sha256__default_rounds = 15000\n"
"superuser__sha512_crypt__default_rounds = 120000\n" "superuser__sha512_crypt__default_rounds = 120000\n"
) )
SHELL_PLUS_PRE_IMPORTS = (
('orchestra.contrib.orchestration.managers', ('orchestrate',)),
)

View File

@ -10,6 +10,7 @@ from django.utils.translation import ugettext_lazy as _
#from orchestra.contrib.orchestration.middlewares import OperationsMiddleware #from orchestra.contrib.orchestration.middlewares import OperationsMiddleware
#from orchestra.contrib.orchestration import Operation #from orchestra.contrib.orchestration import Operation
from orchestra.core import services from orchestra.core import services
from orchestra.models.utils import has_db_field
from orchestra.utils.mail import send_email_template from orchestra.utils.mail import send_email_template
from . import settings from . import settings
@ -158,7 +159,7 @@ class Account(auth.AbstractBaseUser):
return True return True
return auth._user_has_module_perms(self, app_label) return auth._user_has_module_perms(self, app_label)
def get_related_passwords(self): def get_related_passwords(self, db_field=False):
related = [ related = [
self.main_systemuser, self.main_systemuser,
] ]
@ -173,5 +174,8 @@ class Account(auth.AbstractBaseUser):
rel = model.objects.get(account=self, **kwargs) rel = model.objects.get(account=self, **kwargs)
except model.DoesNotExist: except model.DoesNotExist:
continue continue
if db_field:
if not has_db_field(rel, 'password'):
continue
related.append(rel) related.append(rel)
return related return related

View File

@ -112,7 +112,8 @@ class DatabaseUserChangeForm(forms.ModelForm):
password = ReadOnlySQLPasswordHashField(label=_("Password"), password = ReadOnlySQLPasswordHashField(label=_("Password"),
help_text=_("Raw passwords are not stored, so there is no way to see " help_text=_("Raw passwords are not stored, so there is no way to see "
"this user's password, but you can change the password " "this user's password, but you can change the password "
"using <a href=\"../password/\">this form</a>.")) "using <a href='../password/'>this form</a>. "
"<a onclick='return showAddAnotherPopup(this);' href='../hash/'>Show hash</a>."))
class Meta: class Meta:
model = DatabaseUser model = DatabaseUser

View File

@ -3,7 +3,6 @@ from django.shortcuts import redirect
def last(modeladmin, request, queryset): def last(modeladmin, request, queryset):
last_id = queryset.order_by('id').values_list('id', flat=True).first() last = queryset.model.objects.latest('id')
url = reverse('admin:mailer_message_change', args=(last_id,)) url = reverse('admin:mailer_message_change', args=(last.pk,))
print(url)
return redirect(url) return redirect(url)

View File

@ -103,7 +103,8 @@ def get_backend_url(ids):
return '' return ''
def message_user(request, logs): def get_messages(logs):
messages = []
total, successes, async = 0, 0, 0 total, successes, async = 0, 0, 0
ids = [] ids = []
async_ids = [] async_ids = []
@ -140,7 +141,7 @@ def message_user(request, logs):
msg += ', ' + str(async_msg) msg += ', ' + str(async_msg)
msg = msg.format(errors=errors, async=async, async_url=async_url, total=total, url=url, msg = msg.format(errors=errors, async=async, async_url=async_url, total=total, url=url,
name=log.backend) name=log.backend)
messages.error(request, mark_safe(msg + '.')) messages.append(('error', msg + '.'))
elif successes: elif successes:
if async_msg: if async_msg:
if total == 1: if total == 1:
@ -160,7 +161,13 @@ def message_user(request, logs):
total=total, url=url, async_url=async_url, async=async, successes=successes, total=total, url=url, async_url=async_url, async=async, successes=successes,
name=log.backend name=log.backend
) )
messages.success(request, mark_safe(msg + '.')) messages.append(('success', msg + '.'))
else: else:
msg = async_msg.format(url=url, async_url=async_url, async=async, name=log.backend) 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))

View File

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

View File

@ -11,19 +11,19 @@ from orchestra.utils.python import OrderedSet
from . import manager, Operation from . import manager, Operation
from .helpers import message_user from .helpers import message_user
from .models import BackendLog from .models import BackendLog, BackendOperation
@receiver(post_save, dispatch_uid='orchestration.post_save_collector') @receiver(post_save, dispatch_uid='orchestration.post_save_collector')
def post_save_collector(sender, *args, **kwargs): 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') instance = kwargs.get('instance')
OperationsMiddleware.collect(Operation.SAVE, **kwargs) OperationsMiddleware.collect(Operation.SAVE, **kwargs)
@receiver(pre_delete, dispatch_uid='orchestration.pre_delete_collector') @receiver(pre_delete, dispatch_uid='orchestration.pre_delete_collector')
def pre_delete_collector(sender, *args, **kwargs): 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) OperationsMiddleware.collect(Operation.DELETE, **kwargs)

View File

@ -213,6 +213,11 @@ def reissue(modeladmin, request, queryset):
messages.error(request, _("One transaction should be selected.")) messages.error(request, _("One transaction should be selected."))
return return
trans = queryset[0] 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 = reverse('admin:payments_transaction_add')
url += '?account=%i&bill=%i&source=%s&amount=%s&currency=%s' % ( url += '?account=%i&bill=%i&source=%s&amount=%s&currency=%s' % (
trans.bill.account_id, trans.bill.account_id,

View File

@ -99,7 +99,6 @@ class TransactionAdmin(SelectAccountAdminMixin, ExtendedModelAdmin):
'display_state', 'display_state',
'amount', 'amount',
'currency', 'currency',
'process'
) )
}), }),
) )

View File

@ -62,7 +62,8 @@ class UserChangeForm(forms.ModelForm):
password = auth_forms.ReadOnlyPasswordHashField(label=_("Password"), password = auth_forms.ReadOnlyPasswordHashField(label=_("Password"),
help_text=_("Raw passwords are not stored, so there is no way to see " help_text=_("Raw passwords are not stored, so there is no way to see "
"this user's password, but you can change it by " "this user's password, but you can change it by "
"using <a href=\"../password/\">this form</a>.")) "using <a href='../password/'>this form</a>. "
"<a onclick='return showAddAnotherPopup(this);' href='../hash/'>Show hash</a>."))
def clean_password(self): def clean_password(self):
# Regardless of what the user provides, return the initial value. # Regardless of what the user provides, return the initial value.

View File

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldDoesNotExist
from django.apps import apps from django.apps import apps
import importlib import importlib
@ -16,6 +17,14 @@ def get_model(label, import_module=True):
return model 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): def get_field_value(obj, field_name):
names = field_name.split('__') names = field_name.split('__')
rel = getattr(obj, names.pop(0)) rel = getattr(obj, names.pop(0))

View File

@ -8,7 +8,13 @@
<div> <div>
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1" />{% endif %} {% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1" />{% endif %}
{% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}" />{% endif %} {% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}" />{% endif %}
<p>{% blocktrans with username=obj_username %}Enter a new password for the user <strong>{{ username }}</strong>, suggestion '{{ password }}'.{% endblocktrans %}</p> <p>
{% if raw %}
{% blocktrans with username=obj_username %}Enter a new password hash for user <strong>{{ username }}</strong>. Switch to <a href="./?raw=0">text password form</a>.{% endblocktrans %}
{% elif can_raw %}
{% blocktrans with username=obj_username %}Enter a new password for user <strong>{{ username }}</strong>, suggestion '{{ password }}'. Switch to <a href="./?raw=1">raw password form</a>.{% endblocktrans %}
{% endif %}
</p>
{% if errors %} {% if errors %}
<p class="errornote"> <p class="errornote">