Skip to content

Commit 377162e

Browse files
Merge pull request #74 from Promptly-Technologies-LLC/70-fix-password-validation-regex
70 fix password validation regex
2 parents cc89a50 + 0cab180 commit 377162e

File tree

6 files changed

+213
-83
lines changed

6 files changed

+213
-83
lines changed

main.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException
99
from sqlmodel import Session
1010
from routers import authentication, organization, role, user
11-
from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
12-
from utils.models import User, Organization
11+
from utils.auth import (
12+
HTML_PASSWORD_PATTERN,
13+
get_user_with_relations,
14+
get_optional_user,
15+
NeedsNewTokens,
16+
get_user_from_reset_token,
17+
PasswordValidationError,
18+
AuthenticationError
19+
)
20+
from utils.models import User
1321
from utils.db import get_session, set_up_db
1422
from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES
1523

@@ -174,6 +182,8 @@ async def read_register(
174182
):
175183
if params["user"]:
176184
return RedirectResponse(url="/dashboard", status_code=302)
185+
186+
params["password_pattern"] = HTML_PASSWORD_PATTERN
177187
return templates.TemplateResponse(params["request"], "authentication/register.html", params)
178188

179189

@@ -219,6 +229,7 @@ async def read_reset_password(
219229

220230
params["email"] = email
221231
params["token"] = token
232+
params["password_pattern"] = HTML_PASSWORD_PATTERN
222233

223234
return templates.TemplateResponse(params["request"], "authentication/reset_password.html", params)
224235

templates/authentication/register.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@
2424
<!-- Password Input -->
2525
<div class="mb-3">
2626
<label for="password" class="form-label">Password</label>
27-
<!-- Make sure 9g,X*88w[6"W and ^94cPSf2^)z2^,& pass validation -->
2827
<input type="password" class="form-control" id="password" name="password"
29-
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/]{8,}"
28+
pattern="{{ password_pattern }}"
3029
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
3130
placeholder="Enter your password" required
3231
autocomplete="new-password">

templates/authentication/reset_password.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<div class="mb-3">
1818
<label for="new_password" class="form-label">New Password</label>
1919
<input type="password" class="form-control" id="new_password" name="new_password"
20-
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~]{8,}"
20+
pattern="{{ password_pattern }}"
2121
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
2222
required autocomplete="new-password">
2323
<div class="invalid-feedback">

tests/test_auth.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import re
2+
import string
3+
import random
4+
from datetime import timedelta
5+
from urllib.parse import urlparse, parse_qs
6+
from starlette.datastructures import URLPath
7+
from main import app
8+
from utils.auth import (
9+
create_access_token,
10+
create_refresh_token,
11+
verify_password,
12+
get_password_hash,
13+
validate_token,
14+
generate_password_reset_url,
15+
COMPILED_PASSWORD_PATTERN,
16+
convert_python_regex_to_html
17+
)
18+
19+
20+
def test_convert_python_regex_to_html():
21+
PYTHON_SPECIAL_CHARS = r"(?=.*[\[\]\\@$!%*?&{}<>.,'#\-_=+\(\):;|~/\^])"
22+
HTML_EQUIVALENT = r"(?=.*[\[\]\\@$!%*?&\{\}\<\>\.\,\\'#\-_=\+\(\):;\|~\/\^])"
23+
24+
PYTHON_SPECIAL_CHARS = convert_python_regex_to_html(PYTHON_SPECIAL_CHARS)
25+
26+
assert PYTHON_SPECIAL_CHARS == HTML_EQUIVALENT
27+
28+
29+
def test_password_hashing():
30+
password = "Test123!@#"
31+
hashed = get_password_hash(password)
32+
assert verify_password(password, hashed)
33+
assert not verify_password("wrong_password", hashed)
34+
35+
36+
def test_token_creation_and_validation():
37+
data = {"sub": "[email protected]"}
38+
39+
# Test access token
40+
access_token = create_access_token(data)
41+
decoded = validate_token(access_token, "access")
42+
assert decoded is not None
43+
assert decoded["sub"] == data["sub"]
44+
assert decoded["type"] == "access"
45+
46+
# Test refresh token
47+
refresh_token = create_refresh_token(data)
48+
decoded = validate_token(refresh_token, "refresh")
49+
assert decoded is not None
50+
assert decoded["sub"] == data["sub"]
51+
assert decoded["type"] == "refresh"
52+
53+
54+
def test_expired_token():
55+
data = {"sub": "[email protected]"}
56+
expired_delta = timedelta(minutes=-10)
57+
expired_token = create_access_token(data, expired_delta)
58+
decoded = validate_token(expired_token, "access")
59+
assert decoded is None
60+
61+
62+
def test_invalid_token_type():
63+
data = {"sub": "[email protected]"}
64+
access_token = create_access_token(data)
65+
decoded = validate_token(access_token, "refresh")
66+
assert decoded is None
67+
68+
def test_password_reset_url_generation():
69+
"""
70+
Tests that the password reset URL is correctly formatted and contains
71+
the required query parameters.
72+
"""
73+
test_email = "[email protected]"
74+
test_token = "abc123"
75+
76+
url = generate_password_reset_url(test_email, test_token)
77+
78+
# Parse the URL
79+
parsed = urlparse(url)
80+
query_params = parse_qs(parsed.query)
81+
82+
# Get the actual path from the FastAPI app
83+
reset_password_path: URLPath = app.url_path_for("reset_password")
84+
85+
# Verify URL path
86+
assert parsed.path == str(reset_password_path)
87+
88+
# Verify query parameters
89+
assert "email" in query_params
90+
assert "token" in query_params
91+
assert query_params["email"][0] == test_email
92+
assert query_params["token"][0] == test_token
93+
94+
def test_password_pattern():
95+
"""
96+
Tests that the password pattern is correctly defined. to require at least
97+
one uppercase letter, one lowercase letter, one digit, and one special
98+
character, and at least 8 characters long. Allowed special characters are:
99+
!@#$%^&*()_+-=[]{}|;:,.<>?
100+
"""
101+
special_characters = "!@#$%^&*()_+-=[]{}|;:,.<>?"
102+
uppercase_letters = string.ascii_uppercase
103+
lowercase_letters = string.ascii_lowercase
104+
digits = string.digits
105+
106+
required_elements = {
107+
"special": special_characters,
108+
"uppercase": uppercase_letters,
109+
"lowercase": lowercase_letters,
110+
"digit": digits
111+
}
112+
113+
# Valid password tests
114+
for element in required_elements:
115+
for c in required_elements[element]:
116+
password = c + "test"
117+
for other_element in required_elements:
118+
if other_element != element:
119+
password += random.choice(required_elements[other_element])
120+
# Randomize the order of the characters in the string
121+
password = ''.join(random.sample(password, len(password)))
122+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is not None, f"Password {password} does not match the pattern"
123+
124+
# Invalid password tests
125+
126+
# Empty password
127+
password = ""
128+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
129+
130+
# Too short
131+
password = "aA1!aA1"
132+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
133+
134+
# No uppercase letter
135+
password = "a1!" * 3
136+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
137+
138+
# No lowercase letter
139+
password = "A1!" * 3
140+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
141+
142+
# No digit
143+
password = "aA!" * 3
144+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None
145+
146+
# No special character
147+
password = "aA1" * 3
148+
assert re.match(COMPILED_PASSWORD_PATTERN, password) is None

tests/test_authentication.py

Lines changed: 0 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@
1212
from utils.models import User, PasswordResetToken
1313
from utils.auth import (
1414
create_access_token,
15-
create_refresh_token,
1615
verify_password,
17-
get_password_hash,
1816
validate_token,
19-
generate_password_reset_url
2017
)
2118
from .conftest import SetupError
2219

@@ -41,49 +38,6 @@ def mock_resend_send(mock_email_response):
4138
yield mock
4239

4340

44-
# --- Authentication Helper Function Tests ---
45-
46-
47-
def test_password_hashing():
48-
password = "Test123!@#"
49-
hashed = get_password_hash(password)
50-
assert verify_password(password, hashed)
51-
assert not verify_password("wrong_password", hashed)
52-
53-
54-
def test_token_creation_and_validation():
55-
data = {"sub": "[email protected]"}
56-
57-
# Test access token
58-
access_token = create_access_token(data)
59-
decoded = validate_token(access_token, "access")
60-
assert decoded is not None
61-
assert decoded["sub"] == data["sub"]
62-
assert decoded["type"] == "access"
63-
64-
# Test refresh token
65-
refresh_token = create_refresh_token(data)
66-
decoded = validate_token(refresh_token, "refresh")
67-
assert decoded is not None
68-
assert decoded["sub"] == data["sub"]
69-
assert decoded["type"] == "refresh"
70-
71-
72-
def test_expired_token():
73-
data = {"sub": "[email protected]"}
74-
expired_delta = timedelta(minutes=-10)
75-
expired_token = create_access_token(data, expired_delta)
76-
decoded = validate_token(expired_token, "access")
77-
assert decoded is None
78-
79-
80-
def test_invalid_token_type():
81-
data = {"sub": "[email protected]"}
82-
access_token = create_access_token(data)
83-
decoded = validate_token(access_token, "refresh")
84-
assert decoded is None
85-
86-
8741
# --- API Endpoint Tests ---
8842

8943

@@ -272,33 +226,6 @@ def test_password_reset_with_invalid_token(unauth_client: TestClient, test_user:
272226
assert response.status_code == 400
273227

274228

275-
def test_password_reset_url_generation(unauth_client: TestClient):
276-
"""
277-
Tests that the password reset URL is correctly formatted and contains
278-
the required query parameters.
279-
"""
280-
test_email = "[email protected]"
281-
test_token = "abc123"
282-
283-
url = generate_password_reset_url(test_email, test_token)
284-
285-
# Parse the URL
286-
parsed = urlparse(url)
287-
query_params = parse_qs(parsed.query)
288-
289-
# Get the actual path from the FastAPI app
290-
reset_password_path: URLPath = app.url_path_for("reset_password")
291-
292-
# Verify URL path
293-
assert parsed.path == str(reset_password_path)
294-
295-
# Verify query parameters
296-
assert "email" in query_params
297-
assert "token" in query_params
298-
assert query_params["email"][0] == test_email
299-
assert query_params["token"][0] == test_token
300-
301-
302229
def test_password_reset_email_url(unauth_client: TestClient, session: Session, test_user: User, mock_resend_send):
303230
"""
304231
Tests that the password reset email contains a properly formatted reset URL.

utils/auth.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,54 @@
3434
ALGORITHM = "HS256"
3535
ACCESS_TOKEN_EXPIRE_MINUTES = 30
3636
REFRESH_TOKEN_EXPIRE_DAYS = 30
37+
PASSWORD_PATTERN_COMPONENTS = [
38+
r"(?=.*\d)", # At least one digit
39+
r"(?=.*[a-z])", # At least one lowercase letter
40+
r"(?=.*[A-Z])", # At least one uppercase letter
41+
r"(?=.*[\[\]\\@$!%*?&{}<>.,'#\-_=+\(\):;|~/\^])", # At least one special character
42+
r".{8,}" # At least 8 characters long
43+
]
44+
COMPILED_PASSWORD_PATTERN = re.compile(r"".join(PASSWORD_PATTERN_COMPONENTS))
45+
46+
47+
def convert_python_regex_to_html(regex: str) -> str:
48+
"""
49+
Replace each special character with its escaped version only when inside character classes.
50+
Ensures that the single quote "'" is doubly escaped.
51+
"""
52+
# Map each special char to its escaped form
53+
special_map = {
54+
'{': r'\{',
55+
'}': r'\}',
56+
'<': r'\<',
57+
'>': r'\>',
58+
'.': r'\.',
59+
'+': r'\+',
60+
'|': r'\|',
61+
',': r'\,',
62+
"'": r"\\'", # doubly escaped single quote
63+
"/": r"\/",
64+
}
65+
66+
# Regex to match the entire character class [ ... ]
67+
pattern = r"\[((?:\\.|[^\]])*)\]"
68+
69+
def replacer(match: re.Match) -> str:
70+
"""
71+
For the matched character class, replace all special characters inside it.
72+
"""
73+
inside = match.group(1) # the contents inside [ ... ]
74+
for ch, escaped in special_map.items():
75+
inside = inside.replace(ch, escaped)
76+
return f"[{inside}]"
77+
78+
# Use re.sub with a function to ensure we only replace inside the character class
79+
return re.sub(pattern, replacer, regex)
80+
81+
82+
HTML_PASSWORD_PATTERN = "".join(
83+
convert_python_regex_to_html(component) for component in PASSWORD_PATTERN_COMPONENTS
84+
)
3785

3886

3987
# --- Custom Exceptions ---
@@ -105,11 +153,8 @@ def validate_password_strength(v: str) -> str:
105153
- At least 8 characters long
106154
"""
107155
logger.debug(f"Validating password for {field_name}")
108-
pattern = re.compile(
109-
r"(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&{}<>.,\\'#\-_=+\(\)\[\]:;|~/])[A-Za-z\d@$!%*?&{}<>.,\\'#\-_=+\(\)\[\]:;|~/]{8,}")
110-
if not pattern.match(v):
111-
logger.debug(f"Password for {
112-
field_name} does not satisfy the security policy")
156+
if not COMPILED_PASSWORD_PATTERN.match(v):
157+
logger.debug(f"Password for {field_name} does not satisfy the security policy")
113158
raise PasswordValidationError(
114159
field=field_name,
115160
message=f"{field_name} does not satisfy the security policy"

0 commit comments

Comments
 (0)