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

1"""Admin password policy validation service.""" 

2 

3from __future__ import annotations 

4 

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 

13 

14logger = get_logger(__name__) 

15 

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) 

99 

100# Characters that satisfy the "special" requirement. 

101_SPECIAL_CHARS: frozenset[str] = frozenset(r"""!@#$%^&*()_+-=[]{}|;':",./<>?`~\\""") 

102 

103 

104@inject 

105class AdminPasswordPolicyService: 

106 """Password policy validation service for admin accounts. 

107 

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 """ 

111 

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. 

124 

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 

143 

144 # ------------------------------------------------------------------ 

145 # AdminPasswordPolicyServiceProtocol 

146 # ------------------------------------------------------------------ 

147 

148 def validate( 

149 self, 

150 password: str, 

151 email: str | None = None, 

152 ) -> AdminPasswordValidationResult: 

153 """Validate a password against all configured rules. 

154 

155 Checks ALL rules and returns every violation, not just the first one. 

156 

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. 

162 

163 Returns: 

164 AdminPasswordValidationResult with is_valid and the full 

165 violations list. 

166 """ 

167 violations: list[AdminPasswordViolation] = [] 

168 

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 ) 

179 

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 ) 

189 

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 ) 

198 

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 ) 

206 

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 ) 

214 

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 ) 

225 

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 ) 

236 

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 ) 

250 

251 logger.debug( 

252 "password_policy.validated", 

253 violation_count=len(violations), 

254 is_valid=len(violations) == 0, 

255 ) 

256 

257 return AdminPasswordValidationResult( 

258 is_valid=len(violations) == 0, 

259 violations=violations, 

260 ) 

261 

262 

263# Verify that the concrete class satisfies the protocol at import time. 

264_: AdminPasswordPolicyServiceProtocol = AdminPasswordPolicyService.__new__( 

265 AdminPasswordPolicyService 

266) 

267 

268__all__ = ["AdminPasswordPolicyService"]