aboutsummaryrefslogtreecommitdiffstats
path: root/auth_backend.py
diff options
context:
space:
mode:
Diffstat (limited to 'auth_backend.py')
-rw-r--r--auth_backend.py57
1 files changed, 51 insertions, 6 deletions
diff --git a/auth_backend.py b/auth_backend.py
index a8ad782..817f8d4 100644
--- a/auth_backend.py
+++ b/auth_backend.py
@@ -9,11 +9,16 @@ import uuid
from pathlib import Path
from typing import Callable, Optional, Protocol
+from email_validator import EmailNotValidError, validate_email
+
# fixed challenge text used for passphrase verification.
# we store encrypted challenge output instead of storing passwords.
AUTH_CHALLENGE = "SHIM_AUTH_VALID"
-USERNAME_RE = re.compile(r"^[a-z0-9_.-]{2,64}$")
+HANDLE_RE = re.compile(r"^[a-z0-9_.-]{2,64}$")
+UUID_RE = re.compile(
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
+)
ConnectFn = Callable[[], sqlite3.Connection]
@@ -39,6 +44,30 @@ def normalize_username(username: str) -> str:
return username.strip().lower()
+def normalize_uuid(value: str) -> Optional[str]:
+ candidate = (value or "").strip().lower()
+ if not UUID_RE.fullmatch(candidate):
+ return None
+ return candidate
+
+
+def is_valid_email_username(value: str) -> bool:
+ if len(value) > 254:
+ return False
+ try:
+ validated = validate_email(
+ value,
+ check_deliverability=False,
+ allow_smtputf8=False,
+ allow_display_name=False,
+ )
+ except EmailNotValidError:
+ return False
+
+ # keep stored usernames predictable - only accept already normalized forms.
+ return validated.normalized == value
+
+
def looks_like_python_script(path: Path) -> bool:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
@@ -93,6 +122,10 @@ class LocalMojicryptAuthBackend:
return False, "username already exists"
def update_username(self, user_uuid: str, new_username: str) -> tuple[bool, str]:
+ normalized_user_uuid = normalize_uuid(user_uuid)
+ if normalized_user_uuid is None:
+ return False, "invalid user id"
+
normalized = normalize_username(new_username)
username_error = self._validate_username(normalized)
if username_error:
@@ -102,7 +135,7 @@ class LocalMojicryptAuthBackend:
with self.connect_db() as conn:
cursor = conn.execute(
"UPDATE users SET username = ? WHERE user_uuid = ?",
- (normalized, user_uuid),
+ (normalized, normalized_user_uuid),
)
if cursor.rowcount == 0:
return False, "user not found"
@@ -112,6 +145,10 @@ class LocalMojicryptAuthBackend:
return True, "username updated"
def update_password(self, user_uuid: str, new_password: str) -> tuple[bool, str]:
+ normalized_user_uuid = normalize_uuid(user_uuid)
+ if normalized_user_uuid is None:
+ return False, "invalid user id"
+
password_error = self._validate_password(new_password)
if password_error:
return False, password_error
@@ -124,7 +161,7 @@ class LocalMojicryptAuthBackend:
with self.connect_db() as conn:
cursor = conn.execute(
"UPDATE users SET encrypted_challenge = ? WHERE user_uuid = ?",
- (encrypted, user_uuid),
+ (encrypted, normalized_user_uuid),
)
if cursor.rowcount == 0:
return False, "user not found"
@@ -152,9 +189,17 @@ class LocalMojicryptAuthBackend:
return self._run_mojicrypt("encrypt", AUTH_CHALLENGE, password)
def _validate_username(self, username: str) -> Optional[str]:
- if not USERNAME_RE.fullmatch(username):
- return "username must be lowercase and use [a-z0-9_.-], 2-64 chars"
- return None
+ if len(username) < 2:
+ return "username must be at least 2 characters"
+ if not username.isascii():
+ return "username must use ascii characters only"
+ if HANDLE_RE.fullmatch(username):
+ return None
+ if is_valid_email_username(username):
+ return None
+ return (
+ "username must be a handle [a-z0-9_.-] (2-64 chars) or a valid email"
+ )
def _validate_password(self, password: str) -> Optional[str]:
if len(password) < 2: