Skip to content

Commit 0cab180

Browse files
Programmatically generate HTML regex from Python regex
1 parent cd0baab commit 0cab180

File tree

5 files changed

+75
-23
lines changed

5 files changed

+75
-23
lines changed

main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
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 PASSWORD_PATTERN, get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
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+
)
1220
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
@@ -175,7 +183,7 @@ async def read_register(
175183
if params["user"]:
176184
return RedirectResponse(url="/dashboard", status_code=302)
177185

178-
params["password_pattern"] = PASSWORD_PATTERN
186+
params["password_pattern"] = HTML_PASSWORD_PATTERN
179187
return templates.TemplateResponse(params["request"], "authentication/register.html", params)
180188

181189

@@ -221,6 +229,7 @@ async def read_reset_password(
221229

222230
params["email"] = email
223231
params["token"] = token
232+
params["password_pattern"] = HTML_PASSWORD_PATTERN
224233

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

templates/authentication/register.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
<div class="mb-3">
2626
<label for="password" class="form-label">Password</label>
2727
<input type="password" class="form-control" id="password" name="password"
28-
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/]).{8,}"
28+
pattern="{{ password_pattern }}"
2929
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
3030
placeholder="Enter your password" required
3131
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: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@
1212
get_password_hash,
1313
validate_token,
1414
generate_password_reset_url,
15-
COMPILED_PASSWORD_PATTERN
15+
COMPILED_PASSWORD_PATTERN,
16+
convert_python_regex_to_html
1617
)
1718

1819

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+
1929
def test_password_hashing():
2030
password = "Test123!@#"
2131
hashed = get_password_hash(password)
@@ -104,11 +114,11 @@ def test_password_pattern():
104114
for element in required_elements:
105115
for c in required_elements[element]:
106116
password = c + "test"
107-
for element in required_elements:
108-
if element != element:
109-
password += random.choice(required_elements[element])
110-
# Randomize the order of the characters in the string
111-
password = ''.join(random.sample(password, len(password)))
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)))
112122
assert re.match(COMPILED_PASSWORD_PATTERN, password) is not None, f"Password {password} does not match the pattern"
113123

114124
# Invalid password tests

utils/auth.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,54 @@
3434
ALGORITHM = "HS256"
3535
ACCESS_TOKEN_EXPIRE_MINUTES = 30
3636
REFRESH_TOKEN_EXPIRE_DAYS = 30
37-
PASSWORD_PATTERN = (
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-
HTML_PASSWORD_PATTERN = (
45-
r"(?=.*\d)" # At least one digit
46-
r"(?=.*[a-z])" # At least one lowercase letter
47-
r"(?=.*[A-Z])" # At least one uppercase letter
48-
r"(?=.*[\\@$!%*?&\{\}\<\>\.\,\\'#\-_=\+\(\)\[\]:;\|~\/])" # At least one special character
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
4942
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
5084
)
51-
COMPILED_PASSWORD_PATTERN = re.compile(HTML_PASSWORD_PATTERN)
5285

5386

5487
# --- Custom Exceptions ---

0 commit comments

Comments
 (0)