-
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 #5214 from akvo/4955-password-policy
[#4955] Password policy
- Loading branch information
Showing
39 changed files
with
1,058 additions
and
169 deletions.
There are no files selected for viewing
Empty file.
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,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) |
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,7 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class PasswordPolicyConfig(AppConfig): | ||
default_auto_field = "django.db.models.BigAutoField" | ||
name = "akvo.password_policy" | ||
verbose_name = "Password policy" |
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,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) |
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,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: | ||
... |
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 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)) |
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,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.
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.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.
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,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() |
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,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() |
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 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 |
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,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() |
Oops, something went wrong.