feat: multi-user auth — per-user spaces, pbkdf2 passwords, session tokens, login page
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import getpass
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import tomllib
|
||||
from typing import Optional
|
||||
|
||||
import tomli_w
|
||||
|
||||
USERS_PATH = os.path.expanduser("~/.config/tueit-transcriber/users.toml")
|
||||
|
||||
# In-memory session store: token → username
|
||||
# Users must re-login after server restart — acceptable for a desktop app.
|
||||
_sessions: dict[str, str] = {}
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
|
||||
return f"{salt}:{key}"
|
||||
|
||||
|
||||
def _verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
salt, key = stored.split(":", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
new_key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
|
||||
return secrets.compare_digest(new_key, key)
|
||||
|
||||
|
||||
# ── User store ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def has_users() -> bool:
|
||||
return bool(_load_users())
|
||||
|
||||
|
||||
def _load_users() -> dict:
|
||||
if not os.path.exists(USERS_PATH):
|
||||
return {}
|
||||
with open(USERS_PATH, "rb") as f:
|
||||
return tomllib.load(f).get("users", {})
|
||||
|
||||
|
||||
def _save_users(users: dict):
|
||||
os.makedirs(os.path.dirname(USERS_PATH), exist_ok=True)
|
||||
with open(USERS_PATH, "wb") as f:
|
||||
tomli_w.dump({"users": users}, f)
|
||||
|
||||
|
||||
def create_user(username: str, password: str, output_dir: str, is_admin: bool = False):
|
||||
users = _load_users()
|
||||
users[username] = {
|
||||
"password_hash": _hash_password(password),
|
||||
"output_dir": output_dir,
|
||||
"is_admin": is_admin,
|
||||
}
|
||||
_save_users(users)
|
||||
|
||||
|
||||
# ── Session management ─────────────────────────────────────────────────────────
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[str]:
|
||||
"""Verify credentials. Returns a session token on success, None on failure."""
|
||||
users = _load_users()
|
||||
user = users.get(username)
|
||||
if not user:
|
||||
return None
|
||||
if not _verify_password(password, user["password_hash"]):
|
||||
return None
|
||||
token = secrets.token_urlsafe(32)
|
||||
_sessions[token] = username
|
||||
return token
|
||||
|
||||
|
||||
def get_user_for_token(token: str) -> Optional[dict]:
|
||||
"""Return user info dict for a valid token, or None."""
|
||||
username = _sessions.get(token)
|
||||
if not username:
|
||||
return None
|
||||
users = _load_users()
|
||||
user = users.get(username)
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"username": username,
|
||||
"output_dir": user["output_dir"],
|
||||
"is_admin": user.get("is_admin", False),
|
||||
}
|
||||
|
||||
|
||||
def invalidate_token(token: str):
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
# ── First-run setup wizard ─────────────────────────────────────────────────────
|
||||
|
||||
def setup_wizard():
|
||||
"""Interactive console setup. Runs when no users exist yet."""
|
||||
print("\n=== tüit Transkriptor — Ersteinrichtung ===\n")
|
||||
print("Bitte richte den ersten Nutzer ein (wird Administrator).\n")
|
||||
|
||||
while True:
|
||||
username = input("Benutzername: ").strip()
|
||||
if username:
|
||||
break
|
||||
print("Benutzername darf nicht leer sein.")
|
||||
|
||||
while True:
|
||||
password = getpass.getpass("Passwort: ")
|
||||
confirm = getpass.getpass("Passwort bestätigen: ")
|
||||
if password != confirm:
|
||||
print("Passwörter stimmen nicht überein.")
|
||||
continue
|
||||
if len(password) < 6:
|
||||
print("Passwort muss mindestens 6 Zeichen lang sein.")
|
||||
continue
|
||||
break
|
||||
|
||||
default_dir = os.path.expanduser(f"~/Transkripte/{username}")
|
||||
answer = input(f"Transkripte speichern unter [{default_dir}]: ").strip()
|
||||
output_dir = answer if answer else default_dir
|
||||
|
||||
create_user(username, password, output_dir, is_admin=True)
|
||||
|
||||
print(f"\nNutzer '{username}' wurde angelegt.")
|
||||
print(f"Transkripte werden gespeichert unter: {output_dir}")
|
||||
print("\nWeitere Nutzer können später über die Web-Oberfläche hinzugefügt werden.\n")
|
||||
Reference in New Issue
Block a user