Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Finish password expiration #224

Merged
merged 20 commits into from
Sep 19, 2016
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ version with these. Your code will need to be updated to continue working.

* BI: account deletion callbacks moved to hooksets
* BI: dropped Django 1.7 support
* BI: removed deprecated `ACCOUNT_USE_AUTH_AUTHENTICATE` setting with behavior matching its `True` value
* BI: removed deprecated `ACCOUNT_USE_AUTH_AUTHENTICATE` setting with behavior matching its `True` value
* BI: remove Python 3.2 from test compatibility matrix
* add Django v1.10 to test compatibility matrix
* added Turkish translations
* fixed migration with language codes to dynamically set
* add password expiration

## 1.3.0

Expand Down
7 changes: 4 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Features
- Email confirmation
- Signup tokens for private betas
- Password reset
- Password expiration
- Account management (update account settings and change password)
- Account deletion

Expand All @@ -57,7 +58,7 @@ Features
Requirements
--------------

* Django 1.8 or 1.9
* Django 1.8, 1.9, or 1.10
* Python 2.7, 3.3, 3.4 or 3.5
* django-appconf (included in ``install_requires``)
* pytz (included in ``install_requires``)
Expand All @@ -79,13 +80,13 @@ See this blog post http://blog.pinaxproject.com/2016/02/26/recap-february-pinax-

In case of any questions we recommend you join our Pinax Slack team (http://slack.pinaxproject.com) and ping us there instead of creating an issue on GitHub. Creating issues on GitHub is of course also valid but we are usually able to help you faster if you ping us in Slack.

We also highly recommend reading our Open Source and Self-Care blog post (http://blog.pinaxproject.com/2016/01/19/open-source-and-self-care/).
We also highly recommend reading our Open Source and Self-Care blog post (http://blog.pinaxproject.com/2016/01/19/open-source-and-self-care/).


Code of Conduct
-----------------

In order to foster a kind, inclusive, and harassment-free community, the Pinax Project has a code of conduct, which can be found here http://pinaxproject.com/pinax/code_of_conduct/.
In order to foster a kind, inclusive, and harassment-free community, the Pinax Project has a code of conduct, which can be found here http://pinaxproject.com/pinax/code_of_conduct/.
We ask you to treat everyone as a smart human programmer that shares an interest in Python, Django, and Pinax with you.


Expand Down
2 changes: 1 addition & 1 deletion account/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.0.0.dev1"
__version__ = "2.0.0.dev2"
24 changes: 23 additions & 1 deletion account/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

from django.contrib import admin

from account.models import Account, SignupCode, AccountDeletion, EmailAddress
from account.models import (
Account,
AccountDeletion,
EmailAddress,
PasswordExpiry,
PasswordHistory,
SignupCode,
)


class SignupCodeAdmin(admin.ModelAdmin):
Expand All @@ -29,7 +36,22 @@ class EmailAddressAdmin(AccountAdmin):
search_fields = ["email", "user__username"]


class PasswordExpiryAdmin(admin.ModelAdmin):

raw_id_fields = ["user"]


class PasswordHistoryAdmin(admin.ModelAdmin):

raw_id_fields = ["user"]
list_display = ["user", "timestamp"]
list_filter = ["user"]
ordering = ["user__username", "-timestamp"]


admin.site.register(Account, AccountAdmin)
admin.site.register(SignupCode, SignupCodeAdmin)
admin.site.register(AccountDeletion, AccountDeletionAdmin)
admin.site.register(EmailAddress, EmailAddressAdmin)
admin.site.register(PasswordExpiry, PasswordExpiryAdmin)
admin.site.register(PasswordHistory, PasswordHistoryAdmin)
1 change: 1 addition & 0 deletions account/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class AccountAppConf(AppConf):

OPEN_SIGNUP = True
LOGIN_URL = "account_login"
LOGOUT_URL = "account_logout"
SIGNUP_REDIRECT_URL = "/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
Expand Down
39 changes: 39 additions & 0 deletions account/management/commands/user_password_expiry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from django.contrib.auth import get_user_model
from django.core.management.base import LabelCommand

from account.conf import settings
from account.models import PasswordExpiry


class Command(LabelCommand):

help = "Create user-specific password expiration period."
label = "username"

def add_arguments(self, parser):
super(Command, self).add_arguments(parser)
parser.add_argument(
"-e", "--expire",
type=int,
nargs="?",
default=settings.ACCOUNT_PASSWORD_EXPIRY,
help="number of seconds until password expires"
)

def handle_label(self, username, **options):
User = get_user_model()
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return "User \"{}\" not found".format(username)

expire = options["expire"]

# Modify existing PasswordExpiry or create new if needed.
if not hasattr(user, "password_expiry"):
PasswordExpiry.objects.create(user=user, expiry=expire)
else:
user.password_expiry.expiry = expire
user.password_expiry.save()

return "User \"{}\" password expiration set to {} seconds".format(username, expire)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handle_label method works fine for one or a handful of users, but doesn't scale nicely for a large userbase or for all users in a system. Should we improve this mechanism or leave be until someone complains?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure of the particulars of the use case driving these changes, but I think it'd be useful to set an expiration for all users if

  • adding the middleware to an existing project using DUA and wanting to set a "baseline" expiration for all existing users
  • enforcing a reset for all users in response to some kind of security breach

I don't have a strong opinion on that functionality being required from the get-go, and perhaps a new issue to track that functionality is useful and it can be added later when required.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jwegner your first point is handled by the global setting ACCOUNT_PASSWORD_EXPIRY, which is used by default if password expiration is enabled and the user does not have their own PasswordExpiry instance. Good thought on the second case, but I'd prefer to track the idea separately.

45 changes: 45 additions & 0 deletions account/management/commands/user_password_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import datetime
import pytz

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

from account.models import PasswordHistory


class Command(BaseCommand):

help = "Create password history for all users without existing history."

def add_arguments(self, parser):
parser.add_argument(
"-d", "--days",
type=int,
nargs="?",
default=10,
help="age of current password (in days)"
)
parser.add_argument(
"-f", "--force",
action="store_true",
help="create new password history for all users, regardless of existing history"
)

def handle(self, *args, **options):
User = get_user_model()
users = User.objects.all()
if not options["force"]:
users = users.filter(password_history=None)

if not users:
return "No users found without password history"

days = options["days"]
timestamp = datetime.datetime.now(tz=pytz.UTC) - datetime.timedelta(days=days)

# Create new PasswordHistory on `timestamp`
PasswordHistory.objects.bulk_create(
[PasswordHistory(user=user, timestamp=timestamp) for user in users]
)

return "Password history set to {} for {} users".format(timestamp, len(users))
40 changes: 40 additions & 0 deletions account/middleware.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
from __future__ import unicode_literals

try:
from urllib.parse import urlparse, urlunparse
except ImportError: # python 2
from urlparse import urlparse, urlunparse

from django.contrib import messages
from django.core.urlresolvers import resolve, reverse
from django.http import HttpResponseRedirect, QueryDict
from django.utils import translation, timezone
from django.utils.cache import patch_vary_headers
from django.utils.translation import ugettext_lazy as _

from account import signals
from account.conf import settings
from account.models import Account
from account.utils import check_password_expired


class LocaleMiddleware(object):
Expand Down Expand Up @@ -51,3 +62,32 @@ def process_request(self, request):
if account:
tz = settings.TIME_ZONE if not account.timezone else account.timezone
timezone.activate(tz)


class ExpiredPasswordMiddleware(object):

def process_request(self, request):
if request.user.is_authenticated() and not request.user.is_staff:
next_url = resolve(request.path).url_name
# Authenticated users must be allowed to access
# "change password" page and "log out" page.
# even if password is expired.
if next_url not in [settings.ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL,
settings.ACCOUNT_LOGOUT_URL,
]:
if check_password_expired(request.user):
signals.password_expired.send(sender=self, user=request.user)
messages.add_message(
request,
messages.WARNING,
_("Your password has expired. Please save a new password.")
)
redirect_field_name = "next" # fragile!
Copy link
Contributor Author

@grahamu grahamu Sep 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This redirect field name reference ("next") is quite fragile because if the project uses a different string this will break. Note that the redirect field name in classes protected by LoginRequiredMixin and methods protected by login_required default to "next" but can be overridden by class attribute or by invocation parameter. However this code is middleware with no way to set/reset the redirect field name.

To resolve this fragility I suggest using an account global, something like ACCOUNT_REDIRECT_FIELD_NAME.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with removing fragility and standardizing that field name.

We might consider relying on / aliasing from django.contrib.auth.REDIRECT_FIELD_NAME, but still allowing the the overrides on the existing classes/methods above.

So REDIRECT_FIELD_NAME is the default unless overridden.


change_password_url = reverse(settings.ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL)
url_bits = list(urlparse(change_password_url))
querystring = QueryDict(url_bits[4], mutable=True)
querystring[redirect_field_name] = next_url
url_bits[4] = querystring.urlencode(safe="/")

return HttpResponseRedirect(urlunparse(url_bits))
5 changes: 3 additions & 2 deletions account/migrations/0003_passwordexpiry_passwordhistory.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-09-01 17:50
# Generated by Django 1.10.1 on 2016-09-13 08:55
from __future__ import unicode_literals

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):
Expand All @@ -28,7 +29,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=255)),
('timestamp', models.DateTimeField(auto_now=True)),
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auto_now=True does not work for timestamp field because we need to set the timestamp explicitly when creating PasswordHistory via management command.

('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_history', to=settings.AUTH_USER_MODEL)),
],
),
Expand Down
8 changes: 6 additions & 2 deletions account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def user_post_save(sender, **kwargs):
"""

# Disable post_save during manage.py loaddata
if kwargs.get('raw', False):
if kwargs.get("raw", False):
return False

user, created = kwargs["instance"], kwargs["created"]
Expand Down Expand Up @@ -396,9 +396,13 @@ class PasswordHistory(models.Model):
"""
Contains single password history for user.
"""
class Meta:
verbose_name = _("password history")
verbose_name_plural = _("password histories")

user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="password_history")
password = models.CharField(max_length=255) # encrypted password
timestamp = models.DateTimeField(auto_now=True)
timestamp = models.DateTimeField(default=timezone.now) # password creation time


class PasswordExpiry(models.Model):
Expand Down
1 change: 1 addition & 0 deletions account/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
email_confirmed = django.dispatch.Signal(providing_args=["email_address"])
email_confirmation_sent = django.dispatch.Signal(providing_args=["confirmation"])
password_changed = django.dispatch.Signal(providing_args=["user"])
password_expired = django.dispatch.Signal(providing_args=["user"])
1 change: 1 addition & 0 deletions account/tests/templates/account/settings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# empty for now
Loading