Coverage for src / lexigram / admin / auth / services / password_policy_service.py: 50%
44 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Admin password policy validation service."""
3from __future__ import annotations
5from lexigram.admin.auth.protocols import AdminPasswordPolicyServiceProtocol
6from lexigram.admin.auth.types import (
7 AdminPasswordRule,
8 AdminPasswordValidationResult,
9 AdminPasswordViolation,
10)
11from lexigram.di.decorators import inject
12from lexigram.logging import get_logger
14logger = get_logger(__name__)
16# Top 500 most common passwords (abbreviated for embedded list).
17# A full production deployment should embed the NIST top-10 000 list.
18_COMMON_PASSWORDS: frozenset[str] = frozenset(
19 {
20 "password",
21 "password1",
22 "password123",
23 "123456",
24 "123456789",
25 "12345678",
26 "1234567890",
27 "qwerty",
28 "qwerty123",
29 "abc123",
30 "letmein",
31 "monkey",
32 "dragon",
33 "master",
34 "sunshine",
35 "princess",
36 "welcome",
37 "shadow",
38 "superman",
39 "michael",
40 "football",
41 "baseball",
42 "iloveyou",
43 "trustno1",
44 "hunter2",
45 "admin",
46 "admin123",
47 "administrator",
48 "root",
49 "toor",
50 "passw0rd",
51 "p@ssword",
52 "p@ssw0rd",
53 "pass@word",
54 "test",
55 "test123",
56 "demo",
57 "demo123",
58 "guest",
59 "guest123",
60 "login",
61 "login123",
62 "changeme",
63 "change_me",
64 "default",
65 "secret",
66 "secret123",
67 "temp",
68 "temp123",
69 "temporary",
70 "letmein1",
71 "letmein123",
72 "qwertyuiop",
73 "asdfghjkl",
74 "zxcvbnm",
75 "1q2w3e4r",
76 "1q2w3e",
77 "11111111",
78 "22222222",
79 "33333333",
80 "00000000",
81 "111111111",
82 "password2",
83 "Password1",
84 "Password1!",
85 "P@ssword1",
86 "Admin@123",
87 "Welcome1",
88 "Welcome@1",
89 "Hello123",
90 "Summer2023",
91 "Winter2023",
92 "Spring2023",
93 "Autumn2023",
94 "January1",
95 "February1",
96 "March2023",
97 }
98)
100# Characters that satisfy the "special" requirement.
101_SPECIAL_CHARS: frozenset[str] = frozenset(r"""!@#$%^&*()_+-=[]{}|;':",./<>?`~\\""")
104@inject
105class AdminPasswordPolicyService:
106 """Password policy validation service for admin accounts.
108 Validates passwords against configurable rules following NIST SP 800-63B
109 guidelines. Returns ALL violations in a single call, not just the first.
110 """
112 def __init__(
113 self,
114 min_length: int = 12,
115 max_length: int = 128,
116 require_uppercase: bool = True,
117 require_lowercase: bool = True,
118 require_digit: bool = True,
119 require_special: bool = True,
120 reject_common_passwords: bool = True,
121 reject_containing_email: bool = True,
122 ) -> None:
123 """Initialize with policy configuration.
125 Args:
126 min_length: Minimum password length (default 12).
127 max_length: Maximum password length (default 128).
128 require_uppercase: Require at least one uppercase letter.
129 require_lowercase: Require at least one lowercase letter.
130 require_digit: Require at least one digit.
131 require_special: Require at least one special character.
132 reject_common_passwords: Reject passwords in the common list.
133 reject_containing_email: Reject passwords that contain the email.
134 """
135 self._min_length = min_length
136 self._max_length = max_length
137 self._require_uppercase = require_uppercase
138 self._require_lowercase = require_lowercase
139 self._require_digit = require_digit
140 self._require_special = require_special
141 self._reject_common = reject_common_passwords
142 self._reject_email = reject_containing_email
144 # ------------------------------------------------------------------
145 # AdminPasswordPolicyServiceProtocol
146 # ------------------------------------------------------------------
148 def validate(
149 self,
150 password: str,
151 email: str | None = None,
152 ) -> AdminPasswordValidationResult:
153 """Validate a password against all configured rules.
155 Checks ALL rules and returns every violation, not just the first one.
157 Args:
158 password: Plain-text password to validate.
159 email: Optional email — if provided and reject_containing_email
160 is True, checks whether the password contains the email
161 local-part.
163 Returns:
164 AdminPasswordValidationResult with is_valid and the full
165 violations list.
166 """
167 violations: list[AdminPasswordViolation] = []
169 # --- Length ---
170 if len(password) < self._min_length:
171 violations.append(
172 AdminPasswordViolation(
173 rule=AdminPasswordRule.TOO_SHORT,
174 message=(
175 f"Password must be at least {self._min_length} characters."
176 ),
177 )
178 )
180 if len(password) > self._max_length:
181 violations.append(
182 AdminPasswordViolation(
183 rule=AdminPasswordRule.TOO_LONG,
184 message=(
185 f"Password must not exceed {self._max_length} characters."
186 ),
187 )
188 )
190 # --- Character class requirements ---
191 if self._require_uppercase and not any(c.isupper() for c in password):
192 violations.append(
193 AdminPasswordViolation(
194 rule=AdminPasswordRule.MISSING_UPPERCASE,
195 message="Password must contain at least one uppercase letter.",
196 )
197 )
199 if self._require_lowercase and not any(c.islower() for c in password):
200 violations.append(
201 AdminPasswordViolation(
202 rule=AdminPasswordRule.MISSING_LOWERCASE,
203 message="Password must contain at least one lowercase letter.",
204 )
205 )
207 if self._require_digit and not any(c.isdigit() for c in password):
208 violations.append(
209 AdminPasswordViolation(
210 rule=AdminPasswordRule.MISSING_DIGIT,
211 message="Password must contain at least one digit.",
212 )
213 )
215 if self._require_special and not any(c in _SPECIAL_CHARS for c in password):
216 violations.append(
217 AdminPasswordViolation(
218 rule=AdminPasswordRule.MISSING_SPECIAL,
219 message=(
220 "Password must contain at least one special character"
221 " (!@#$%^&* etc.)."
222 ),
223 )
224 )
226 # --- Common-password check ---
227 if self._reject_common and password.lower() in _COMMON_PASSWORDS:
228 violations.append(
229 AdminPasswordViolation(
230 rule=AdminPasswordRule.COMMON_PASSWORD,
231 message=(
232 "Password is too common. Please choose a more unique password."
233 ),
234 )
235 )
237 # --- Email-containment check ---
238 if self._reject_email and email:
239 email_lower = email.lower()
240 local_part = (
241 email_lower.split("@")[0] if "@" in email_lower else email_lower
242 )
243 if len(local_part) >= 4 and local_part in password.lower():
244 violations.append(
245 AdminPasswordViolation(
246 rule=AdminPasswordRule.CONTAINS_EMAIL,
247 message="Password must not contain your email address.",
248 )
249 )
251 logger.debug(
252 "password_policy.validated",
253 violation_count=len(violations),
254 is_valid=len(violations) == 0,
255 )
257 return AdminPasswordValidationResult(
258 is_valid=len(violations) == 0,
259 violations=violations,
260 )
263# Verify that the concrete class satisfies the protocol at import time.
264_: AdminPasswordPolicyServiceProtocol = AdminPasswordPolicyService.__new__(
265 AdminPasswordPolicyService
266)
268__all__ = ["AdminPasswordPolicyService"]