Skip to content

Commit

Permalink
Merge pull request #5303 from akvo/4955-password-history
Browse files Browse the repository at this point in the history
[#4955] password history
  • Loading branch information
zuhdil authored Aug 11, 2023
2 parents 40f652f + 82c4401 commit da007ae
Show file tree
Hide file tree
Showing 18 changed files with 459 additions and 19 deletions.
11 changes: 11 additions & 0 deletions akvo/password_policy/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from akvo.password_policy.rules.compound import CompoundRule
from akvo.password_policy.rules.length import LengthRule
from akvo.password_policy.rules.regex import IllegalRegexRule
from akvo.password_policy.rules.reuse_limit import ReuseLimitRule
from akvo.password_policy.rules.user_attribute import UserAttributeRule
from akvo.password_policy.services import PasswordHistoryService

RuleBuilder = Callable[[PolicyConfig, AbstractBaseUser], Optional[ValidationRule]]

Expand Down Expand Up @@ -50,6 +52,14 @@ def build_no_common_password_rule(config: PolicyConfig, *_) -> Optional[Validati
return CommonPasswordRule()


def build_reuse_limit_rule(
config: PolicyConfig, user: AbstractBaseUser
) -> Optional[ValidationRule]:
if not config.reuse:
return None
return ReuseLimitRule(PasswordHistoryService(user, config))


def build_no_user_attributes_rule(
config: PolicyConfig, user: AbstractBaseUser
) -> Optional[ValidationRule]:
Expand All @@ -73,6 +83,7 @@ def build_regex_rules(config: PolicyConfig, *_) -> Optional[ValidationRule]:
build_numbers_rule,
build_symbols_rule,
build_no_common_password_rule,
build_reuse_limit_rule,
build_no_user_attributes_rule,
build_regex_rules,
]
Expand Down
2 changes: 2 additions & 0 deletions akvo/password_policy/error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from akvo.password_policy.rules.common_password import CommonPasswordRule
from akvo.password_policy.rules.length import LengthRule
from akvo.password_policy.rules.regex import IllegalRegexRule
from akvo.password_policy.rules.reuse_limit import ReuseLimitRule
from akvo.password_policy.rules.user_attribute import UserAttributeRule

ERROR_MESSAGES = {
Expand All @@ -27,6 +28,7 @@
"Password must be %(expected)s or more characters in length."
),
IllegalRegexRule.ERROR_CODE: _("Password matches the illegal pattern '%(match)s'."),
ReuseLimitRule.ERROR_CODE: _("Password matches one of %(limit)s previous passwords."),
UserAttributeRule.ERROR_CODE: _("Password is too similar to the '%(attribute)s'."),
}

Expand Down
32 changes: 32 additions & 0 deletions akvo/password_policy/migrations/0002_auto_20230809_1256.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 3.2.18 on 2023-08-09 10:56

from django.db import migrations, models


def migrate_expiration_days(apps, _):
PolicyConfig = apps.get_model('password_policy', 'PolicyConfig')
for config in PolicyConfig.objects.all():
if not config.expiration:
continue
config.expiration_days = config.expiration.seconds
config.save()


class Migration(migrations.Migration):

dependencies = [
('password_policy', '0001_initial'),
]

operations = [
migrations.AlterModelOptions(
name='policyconfig',
options={},
),
migrations.AddField(
model_name='policyconfig',
name='expiration_days',
field=models.PositiveSmallIntegerField(blank=True, help_text='Maximum password age (days). Set empty for never expires', null=True),
),
migrations.RunPython(migrate_expiration_days),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-08-09 11:07

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('password_policy', '0002_auto_20230809_1256'),
]

operations = [
migrations.RemoveField(
model_name='policyconfig',
name='expiration',
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-08-09 11:08

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('password_policy', '0003_remove_policyconfig_expiration'),
]

operations = [
migrations.RenameField(
model_name='policyconfig',
old_name='expiration_days',
new_name='expiration',
),
]
25 changes: 25 additions & 0 deletions akvo/password_policy/migrations/0005_passwordhistory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 3.2.18 on 2023-08-10 04:13

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


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('password_policy', '0004_rename_expiration_days_policyconfig_expiration'),
]

operations = [
migrations.CreateModel(
name='PasswordHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
13 changes: 12 additions & 1 deletion akvo/password_policy/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from django.db import models
from django.conf import settings


class PolicyConfig(models.Model):
name = models.CharField(max_length=100, unique=True)
expiration = models.DurationField(blank=True, null=True)
expiration = models.PositiveSmallIntegerField(
blank=True,
null=True,
help_text="Maximum password age (days). Set empty for never expires",
)
reuse = models.PositiveSmallIntegerField(
blank=True, null=True, verbose_name="Reuse policy"
)
Expand All @@ -24,3 +29,9 @@ class RegexRuleConfig(models.Model):
PolicyConfig, on_delete=models.CASCADE, related_name="regex_rules"
)
pattern = models.CharField(max_length=255)


class PasswordHistory(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
password = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
14 changes: 14 additions & 0 deletions akvo/password_policy/rules/reuse_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from akvo.password_policy.core import ValidationResult, ValidationRule
from akvo.password_policy.services import PasswordHistoryService


class ReuseLimitRule(ValidationRule):
ERROR_CODE = "REUSE_LIMIT_VIOLATION"

def __init__(self, history: PasswordHistoryService):
self.history = history

def validate(self, password: str) -> ValidationResult:
if self.history.contains(password):
return ValidationResult.error(self.ERROR_CODE, {"limit": self.history.reuse_limit})
return ValidationResult()
56 changes: 56 additions & 0 deletions akvo/password_policy/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from datetime import timedelta
from typing import Optional
from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractUser
from django.db.models import QuerySet
from django.utils.timezone import now
from akvo.password_policy.models import PasswordHistory, PolicyConfig


class PasswordHistoryService:
def __init__(self, user: AbstractUser, config: Optional[PolicyConfig] = None):
self.user = user
self.config = config

@property
def reuse_limit(self) -> int:
return self.config.reuse if self.config else 0

def is_expired(self) -> bool:
if not self.config:
return False
if not self.config.expiration:
return False
expired_at = now() - timedelta(days=self.config.expiration)
latest = self.latest()
if not latest:
return False
return latest.created_at < expired_at

def contains(self, password: str) -> bool:
if not self.reuse_limit:
return False
entries = self._queryset()
for entry in entries[:self.reuse_limit]:
if check_password(password, entry.password):
return True
return False

def push(self, password: str):
PasswordHistory.objects.create(user=self.user, password=make_password(password))
self._remove_excess()

def latest(self) -> Optional[PasswordHistory]:
return self._queryset().order_by("-created_at").first()

def _queryset(self) -> QuerySet[PasswordHistory]:
return PasswordHistory.objects.filter(user=self.user)

def _remove_excess(self):
entries = self._queryset()
# Keep at least 1 for the user's current password
offset = self.reuse_limit if self.reuse_limit else 1
if entries.count() <= offset:
return
cursor = entries[offset:offset + 1].get()
entries.filter(created_at__lte=cursor.created_at).delete()
35 changes: 35 additions & 0 deletions akvo/password_policy/tests/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import cast
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser

from akvo.password_policy.models import PolicyConfig
from akvo.password_policy.services import PasswordHistoryService

User = get_user_model()


class PasswordHistoryServiceTestBuilder:
def __init__(self):
self.user = None
self.config = None
self.no_config = False

def with_user(self, user: AbstractUser):
self.user = user
return self

def with_config(self, config: PolicyConfig):
self.config = config
return self

def with_no_config(self):
self.no_config = True
return self

def build(self):
if not self.user:
self.user = cast(AbstractUser, User(username="test"))
if not self.config and not self.no_config:
self.config = PolicyConfig(expiration=1, reuse=2)
self.user.save()
return PasswordHistoryService(self.user, self.config)
26 changes: 26 additions & 0 deletions akvo/password_policy/tests/rules/test_reuse_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from akvo.password_policy.rules.reuse_limit import ReuseLimitRule
from akvo.password_policy.tests.helper import PasswordHistoryServiceTestBuilder

from akvo.password_policy.tests.mixin import ValidationResultMixin

User = get_user_model()


class ReuseLimitRuleTestCase(TestCase, ValidationResultMixin):
def setUp(self):
super().setUp()
self.ctx = PasswordHistoryServiceTestBuilder()
self.history = self.ctx.build()
self.rule = ReuseLimitRule(self.history)

def test_invalid(self):
self.history.push("test")
result = self.rule.validate("test")
self.assertValidationError(result.errors[0], ReuseLimitRule.ERROR_CODE)

def test_valid(self):
self.history.push("password 1")
result = self.rule.validate("password 2")
self.assertTrue(result.is_valid())
7 changes: 7 additions & 0 deletions akvo/password_policy/tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from akvo.password_policy.rules.compound import CompoundRule
from akvo.password_policy.rules.length import LengthRule
from akvo.password_policy.rules.regex import IllegalRegexRule
from akvo.password_policy.rules.reuse_limit import ReuseLimitRule
from akvo.password_policy.rules.user_attribute import UserAttributeRule

User = get_user_model()
Expand Down Expand Up @@ -57,6 +58,12 @@ def test_no_common_password(self):
self.assertEqual(1, len(validator.rules))
self.assertIsInstance(validator.rules[0], CommonPasswordRule)

def test_reuse_limit_rule(self):
config = PolicyConfig(min_length=0, letters=0, reuse=1)
validator = build_validation_rule(config, self.user)
self.assertEqual(1, len(validator.rules))
self.assertIsInstance(validator.rules[0], ReuseLimitRule)

def test_user_attributes_rule(self):
config = PolicyConfig(min_length=0, letters=0, no_user_attributes=True)
validator = build_validation_rule(config, self.user)
Expand Down
Loading

0 comments on commit da007ae

Please sign in to comment.