diff options
Diffstat (limited to 'auth_backend.py')
| -rw-r--r-- | auth_backend.py | 57 |
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: |
