aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-04-03 02:27:29 -0400
committerkj_sh6042026-04-03 02:27:29 -0400
commitf22c51507eb59cf843f1baca2ad7626fd351f33d (patch)
tree8d62965d408e8b629405ebe8b31a8714ba98bd8e
parentd6458885e00788e1dec779b6807a79646aac6ed6 (diff)
refactor: email usernames
-rw-r--r--auth_backend.py42
-rw-r--r--shim_app.py19
2 files changed, 47 insertions, 14 deletions
diff --git a/auth_backend.py b/auth_backend.py
index 3902d1e..00a5dbc 100644
--- a/auth_backend.py
+++ b/auth_backend.py
@@ -13,7 +13,9 @@ from typing import Callable, Optional, Protocol
# 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}$")
+EMAIL_LOCAL_RE = re.compile(r"^[a-z0-9!#$%&'*+/=?^_`{|}~.-]{1,64}$")
+EMAIL_DOMAIN_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
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}$"
)
@@ -49,6 +51,30 @@ def normalize_uuid(value: str) -> Optional[str]:
return candidate
+def is_valid_email_username(value: str) -> bool:
+ if len(value) > 254:
+ return False
+ if "@" not in value or value.count("@") != 1:
+ return False
+
+ local_part, domain = value.split("@", 1)
+ if not local_part or not domain:
+ return False
+ if local_part.startswith(".") or local_part.endswith(".") or ".." in local_part:
+ return False
+ if not EMAIL_LOCAL_RE.fullmatch(local_part):
+ return False
+
+ labels = domain.split(".")
+ if len(labels) < 2:
+ return False
+ if any(not label for label in labels):
+ return False
+ if len(labels[-1]) < 2:
+ return False
+ return all(EMAIL_DOMAIN_LABEL_RE.fullmatch(label) for label in labels)
+
+
def looks_like_python_script(path: Path) -> bool:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
@@ -170,9 +196,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:
diff --git a/shim_app.py b/shim_app.py
index 875e21b..6137f15 100644
--- a/shim_app.py
+++ b/shim_app.py
@@ -165,14 +165,13 @@ SHELL_TEMPLATE = """<!doctype html>
<header class="topbar">
<h1>{{ app_name }}</h1>
<nav>
- <a href="{{ url_for('dashboard') }}">dashboard</a>
{% if current_user %}
- <span class="quiet">signed in as {{ current_user['username'] }} ({{ current_user['role'] }})</span>
+ <span class="quiet">{{ current_user['username'] }}{% if current_user['role'] == 'admin' %} ({{ current_user['role'] }}){% endif %}</span>
<form method="post" action="{{ url_for('logout') }}" class="inline">
<button type="submit">logout</button>
</form>
{% else %}
- <a href="{{ url_for('login') }}">login</a>
+ <a href="https://youtu.be/XGxIE1hr0w4" target="_blank">🦄</a>
{% endif %}
</nav>
</header>
@@ -197,8 +196,8 @@ SETUP_BODY_TEMPLATE = """
<p>first startup detected. <br><br> create the initial admin account to continue.</p>
<form method="post" action="{{ url_for('setup_submit') }}" class="stack" autocomplete="off">
<label>
- username
- <input name="username" type="text" required minlength="2" maxlength="64" pattern="[a-z0-9_.-]+" placeholder="admin" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" data-unlock-readonly="1" readonly>
+ username or email
+ <input name="username" type="text" required minlength="2" maxlength="254" placeholder="admin username" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" data-unlock-readonly="1" readonly>
</label>
<label>
password
@@ -219,8 +218,8 @@ LOGIN_BODY_TEMPLATE = """
<p>a no-frills, "hackfoo" static site hosting solution</p>
<form method="post" action="{{ url_for('login_submit') }}" class="stack" autocomplete="off">
<label>
- username
- <input name="username" type="text" required minlength="2" maxlength="64" pattern="[a-z0-9_.-]+" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" data-unlock-readonly="1" readonly>
+ username or email
+ <input name="username" type="text" required minlength="2" maxlength="254" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" data-unlock-readonly="1" readonly>
</label>
<label>
password
@@ -323,8 +322,8 @@ DASHBOARD_BODY_TEMPLATE = """
<h2>create user</h2>
<form method="post" action="{{ url_for('admin_create_user') }}" class="stack" autocomplete="off">
<label>
- username
- <input type="text" name="username" required minlength="2" maxlength="64" pattern="[a-z0-9_.-]+" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" data-unlock-readonly="1" readonly>
+ username or email
+ <input type="text" name="username" required minlength="2" maxlength="254" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" data-unlock-readonly="1" readonly>
</label>
<label>
password
@@ -362,7 +361,7 @@ DASHBOARD_BODY_TEMPLATE = """
<td>{{ user['created_at'] }}</td>
<td>
<form method="post" action="{{ url_for('admin_update_user_username', user_id=user['id']) }}" class="stack" autocomplete="off">
- <input type="text" name="username" value="{{ user['username'] }}" required minlength="2" maxlength="64" pattern="[a-z0-9_.-]+" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" style="all: revert;">
+ <input type="text" name="username" value="{{ user['username'] }}" required minlength="2" maxlength="254" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" style="all: revert;">
<button type="submit" style="all: revert;">rename user</button>
</form>
<br>