-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #5303 from akvo/4955-password-history
[#4955] password history
- Loading branch information
Showing
18 changed files
with
459 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
akvo/password_policy/migrations/0002_auto_20230809_1256.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
] |
17 changes: 17 additions & 0 deletions
17
akvo/password_policy/migrations/0003_remove_policyconfig_expiration.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
), | ||
] |
18 changes: 18 additions & 0 deletions
18
akvo/password_policy/migrations/0004_rename_expiration_days_policyconfig_expiration.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)), | ||
], | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.