Skip to content

Commit

Permalink
Merge pull request #5214 from akvo/4955-password-policy
Browse files Browse the repository at this point in the history
[#4955] Password policy
  • Loading branch information
MichaelAkvo authored Feb 28, 2023
2 parents f70a375 + a123c0a commit c6c4484
Show file tree
Hide file tree
Showing 39 changed files with 1,058 additions and 169 deletions.
Empty file.
30 changes: 30 additions & 0 deletions akvo/password_policy/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.contrib import admin

from akvo.password_policy.models import PolicyConfig, RegexRuleConfig


class RegexRuleConfigInline(admin.TabularInline):
model = RegexRuleConfig
extra = 1


class PolicyConfigAdmin(admin.ModelAdmin):
fieldsets = (
(None, {"fields": ("name", "expiration", "reuse", "min_length")}),
(
"Character requirements",
{
"fields": (
"letters",
"uppercases",
"numbers",
"symbols",
)
},
),
("Prohibited words", {"fields": ("no_common_password", "no_user_attributes")}),
)
inlines = (RegexRuleConfigInline,)


admin.site.register(PolicyConfig, PolicyConfigAdmin)
7 changes: 7 additions & 0 deletions akvo/password_policy/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class PasswordPolicyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "akvo.password_policy"
verbose_name = "Password policy"
85 changes: 85 additions & 0 deletions akvo/password_policy/builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from typing import Callable, List, Optional

from django.contrib.auth.models import AbstractBaseUser

from akvo.password_policy.core import ValidationRule
from akvo.password_policy.models import PolicyConfig
from akvo.password_policy.rules.character import CharacterRule
from akvo.password_policy.rules.common_password import CommonPasswordRule
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.user_attribute import UserAttributeRule

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


def build_min_length_rule(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.min_length:
return None
return LengthRule(min=config.min_length)


def build_letters_rule(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.letters:
return None
return CharacterRule.letters(min_length=config.letters)


def build_uppercase_rule(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.uppercases:
return None
return CharacterRule.uppercases(min_length=config.uppercases)


def build_numbers_rule(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.numbers:
return None
return CharacterRule.numbers(min_length=config.numbers)


def build_symbols_rule(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.symbols:
return None
return CharacterRule.symbols(min_length=config.symbols)


def build_no_common_password_rule(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.no_common_password:
return None
return CommonPasswordRule()


def build_no_user_attributes_rule(
config: PolicyConfig, user: AbstractBaseUser
) -> Optional[ValidationRule]:
if not config.no_user_attributes:
return None
return UserAttributeRule(user)


def build_regex_rules(config: PolicyConfig, *_) -> Optional[ValidationRule]:
if not config.regex_rules.count():
return None
return CompoundRule(
[IllegalRegexRule(pattern=r.pattern) for r in config.regex_rules.all()]
)


RULE_BUILDERS: List[RuleBuilder] = [
build_min_length_rule,
build_letters_rule,
build_uppercase_rule,
build_numbers_rule,
build_symbols_rule,
build_no_common_password_rule,
build_no_user_attributes_rule,
build_regex_rules,
]


def build_validation_rule(
config: PolicyConfig, user: AbstractBaseUser
) -> ValidationRule:
rules = [rule for rule in [build(config, user) for build in RULE_BUILDERS] if rule]
return CompoundRule(rules)
34 changes: 34 additions & 0 deletions akvo/password_policy/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional


@dataclass(frozen=True)
class ErrorItem:
code: str
context: Dict[str, int | str] = field(default_factory=dict)


@dataclass(frozen=True)
class ValidationResult:
errors: List[ErrorItem] = field(default_factory=list)

@classmethod
def error(
cls, code: str, context: Optional[Dict[str, int | str]] = None
) -> ValidationResult:
return cls(errors=[ErrorItem(code=code, context=context or {})])

def is_valid(self) -> bool:
return len(self.errors) == 0

def merge(self, other: ValidationResult) -> ValidationResult:
return ValidationResult(self.errors + other.errors)


class ValidationRule(ABC):
@abstractmethod
def validate(self, password: str) -> ValidationResult:
...
35 changes: 35 additions & 0 deletions akvo/password_policy/error_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.utils.translation import gettext_lazy as _

from akvo.password_policy.rules.character import CharacterRule
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.user_attribute import UserAttributeRule

ERROR_MESSAGES = {
CharacterRule.ERROR_CODE_LETTERS: _(
"Password must contain %(expected)s or more letters."
),
CharacterRule.ERROR_CODE_NUMBERS: _(
"Password must contain %(expected)s or more numbers."
),
CharacterRule.ERROR_CODE_UPPERCASES: _(
"Password must contain %(expected)d or more uppercase characters."
),
CharacterRule.ERROR_CODE_LOWERCASES: _(
"Password must contain %(expected)d or more lowercase characters."
),
CharacterRule.ERROR_CODE_SYMBOLS: _(
"Password must contain %(expected)d or more symbol characters."
),
CommonPasswordRule.ERROR_CODE: _("Password is too common."),
LengthRule.ERROR_CODE_MIN: _(
"Password must be %(expected)s or more characters in length."
),
IllegalRegexRule.ERROR_CODE: _("Password matches the illegal pattern '%(match)s'."),
UserAttributeRule.ERROR_CODE: _("Password is too similar to the '%(attribute)s'."),
}


def get_error_message(code: str) -> str:
return str(ERROR_MESSAGES.get(code, code))
72 changes: 72 additions & 0 deletions akvo/password_policy/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generated by Django 3.2.10 on 2023-01-07 21:11

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


class Migration(migrations.Migration):

initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="PolicyConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, unique=True)),
("expiration", models.DurationField(blank=True, null=True)),
(
"reuse",
models.PositiveSmallIntegerField(
blank=True, null=True, verbose_name="Reuse policy"
),
),
("min_length", models.PositiveSmallIntegerField(default=5)),
(
"letters",
models.PositiveSmallIntegerField(blank=True, default=1, null=True),
),
("uppercases", models.PositiveSmallIntegerField(blank=True, null=True)),
("numbers", models.PositiveSmallIntegerField(blank=True, null=True)),
("symbols", models.PositiveSmallIntegerField(blank=True, null=True)),
("no_common_password", models.BooleanField(default=False)),
("no_user_attributes", models.BooleanField(default=False)),
],
options={
"ordering": ["-id"],
},
),
migrations.CreateModel(
name="RegexRuleConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("pattern", models.CharField(max_length=255)),
(
"config",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="regex_rules",
to="password_policy.policyconfig",
),
),
],
),
]
Empty file.
26 changes: 26 additions & 0 deletions akvo/password_policy/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.db import models


class PolicyConfig(models.Model):
name = models.CharField(max_length=100, unique=True)
expiration = models.DurationField(blank=True, null=True)
reuse = models.PositiveSmallIntegerField(
blank=True, null=True, verbose_name="Reuse policy"
)
min_length = models.PositiveSmallIntegerField(default=5)
letters = models.PositiveSmallIntegerField(blank=True, null=True, default=1)
uppercases = models.PositiveSmallIntegerField(blank=True, null=True)
numbers = models.PositiveSmallIntegerField(blank=True, null=True)
symbols = models.PositiveSmallIntegerField(blank=True, null=True)
no_common_password = models.BooleanField(default=False)
no_user_attributes = models.BooleanField(default=False)

def __str__(self):
return self.name


class RegexRuleConfig(models.Model):
config = models.ForeignKey(
PolicyConfig, on_delete=models.CASCADE, related_name="regex_rules"
)
pattern = models.CharField(max_length=255)
Empty file.
45 changes: 45 additions & 0 deletions akvo/password_policy/rules/character.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import re

from akvo.password_policy.core import ValidationResult, ValidationRule


class CharacterRule(ValidationRule):
ERROR_CODE_LETTERS = "INSUFFICIENT_LETTER_CHARACTERS"
ERROR_CODE_UPPERCASES = "INSUFFICIENT_UPPERCASE_CHARACTERS"
ERROR_CODE_LOWERCASES = "INSUFFICIENT_LOWERCASE_CHARACTERS"
ERROR_CODE_NUMBERS = "INSUFFICIENT_NUMBER_CHARACTERS"
ERROR_CODE_SYMBOLS = "INSUFFICIENT_SYMBOL_CHARACTERS"

@classmethod
def letters(cls, min_length: int = 1):
return cls(cls.ERROR_CODE_LETTERS, r"[a-zA-Z]", min_length)

@classmethod
def uppercases(cls, min_length: int = 1):
return cls(cls.ERROR_CODE_UPPERCASES, r"[A-Z]", min_length)

@classmethod
def lowercase(cls, min_length: int = 1):
return cls(cls.ERROR_CODE_LOWERCASES, r"[a-z]", min_length)

@classmethod
def numbers(cls, min_length: int = 1):
return cls(cls.ERROR_CODE_NUMBERS, r"[0-9]", min_length)

@classmethod
def symbols(cls, min_length: int = 1):
return cls(cls.ERROR_CODE_SYMBOLS, r"\W", min_length)

def __init__(self, error_code: str, pattern: str, min_length: int = 1):
self.error_code = error_code
self.pattern = pattern
self.min_length = min_length

def validate(self, password: str) -> ValidationResult:
matches = re.findall(self.pattern, password)
match_length = len(matches)
if match_length < self.min_length:
return ValidationResult.error(
self.error_code, {"expected": self.min_length, "actual": match_length}
)
return ValidationResult()
15 changes: 15 additions & 0 deletions akvo/password_policy/rules/common_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.contrib.auth.password_validation import CommonPasswordValidator
from django.core.exceptions import ValidationError

from akvo.password_policy.core import ValidationResult, ValidationRule


class CommonPasswordRule(ValidationRule):
ERROR_CODE = "COMMON_PASSWORD_VIOLATION"

def validate(self, password: str) -> ValidationResult:
try:
CommonPasswordValidator().validate(password)
except ValidationError:
return ValidationResult.error(self.ERROR_CODE)
return ValidationResult()
14 changes: 14 additions & 0 deletions akvo/password_policy/rules/compound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import List

from akvo.password_policy.core import ValidationResult, ValidationRule


class CompoundRule(ValidationRule):
def __init__(self, rules: List[ValidationRule]):
self.rules = rules

def validate(self, password: str) -> ValidationResult:
result = ValidationResult()
for rule in self.rules:
result = result.merge(rule.validate(password))
return result
24 changes: 24 additions & 0 deletions akvo/password_policy/rules/length.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Optional

from akvo.password_policy.core import ValidationResult, ValidationRule


class LengthRule(ValidationRule):
ERROR_CODE_MIN = "LENGTH_TOO_SHORT"
ERROR_CODE_MAX = "LENGTH_TOO_LONG"

def __init__(self, min: int, max: Optional[int] = None):
self.min = min
self.max = max

def validate(self, password: str) -> ValidationResult:
password_length = len(password)
if password_length < self.min:
return ValidationResult.error(
self.ERROR_CODE_MIN, {"expected": self.min, "actual": password_length}
)
if self.max and password_length > self.max:
return ValidationResult.error(
self.ERROR_CODE_MAX, {"expected": self.max, "actual": password_length}
)
return ValidationResult()
Loading

0 comments on commit c6c4484

Please sign in to comment.