New user flow:
1. Admin: python manage_passwords.py invite <username> → generates URL
2. User visits /setup/<token> → sets own password → logged in
3. User redirected to /setup/persona → fills name/emoji/description
4. persona_template.py generates all starter files → lands at /{user}/{persona}
Multiple personas:
- Header persona name is now a clickable dropdown listing all personas
- "New persona" link at bottom → /setup/persona (available to logged-in users)
- /api/personas endpoint returns persona list for current session user
New files:
- persona_template.py: generates IDENTITY/SOUL/PROTOCOLS/USER/HELP.md + data files
- routers/onboarding.py: /setup/{token}, /setup/persona GET+POST
- static/setup.html: two-step form (password → persona), emoji picker, mobile-friendly
Updated:
- auth_utils.py: create_invite(), validate_invite(), consume_invite()
- manage_passwords.py: invite command with URL output
- auth_middleware.py: /setup/* prefix is public (invite tokens need no auth)
- routers/ui.py: /api/personas endpoint; post-login redirect if no personas
- static/app.js: persona switcher dropdown with navigation + Add persona link
- static/style.css: .persona-switcher, .persona-dropdown, mobile adjustments
Mobile: login/setup pages are card-centered with responsive padding;
dropdown avoids edge-clipping on narrow screens; logout button stays visible.
All 80 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
"""
|
|
Authentication utilities — password hashing and JWT session tokens.
|
|
|
|
Passwords are stored as bcrypt hashes in home/{username}/auth.json.
|
|
Sessions are JWT cookies signed with JWT_SECRET from settings.
|
|
|
|
Usage:
|
|
set_password("scott", "mypassword") # admin setup
|
|
check_credentials("scott", "mypassword") # login validation
|
|
create_token("scott") # returns JWT string
|
|
decode_token(token) # returns username or raises
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import bcrypt
|
|
import jwt
|
|
|
|
from config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
COOKIE_NAME = "cortex_session"
|
|
ALGORITHM = "HS256"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Password helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _auth_path(username: str) -> Path:
|
|
return settings.home_root() / username / "auth.json"
|
|
|
|
|
|
def set_password(username: str, password: str) -> None:
|
|
"""Hash and store a password for a user. Creates auth.json if needed."""
|
|
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
_auth_path(username).write_text(json.dumps({"password_hash": hashed}) + "\n")
|
|
logger.info("password set for user: %s", username)
|
|
|
|
|
|
def check_credentials(username: str, password: str) -> bool:
|
|
"""Return True if username+password are valid, False otherwise."""
|
|
path = _auth_path(username)
|
|
if not path.exists():
|
|
return False
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
stored = data.get("password_hash", "").encode()
|
|
return bcrypt.checkpw(password.encode(), stored)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JWT helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_token(username: str) -> str:
|
|
"""Return a signed JWT encoding the username."""
|
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days)
|
|
payload = {"sub": username, "exp": expire}
|
|
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
|
|
|
|
|
def decode_token(token: str) -> str:
|
|
"""Decode a JWT and return the username. Raises jwt.InvalidTokenError on failure."""
|
|
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
|
return payload["sub"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invite tokens — one-time setup links for new users
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _invite_path(username: str) -> Path:
|
|
return settings.home_root() / username / "invite.json"
|
|
|
|
|
|
def create_invite(username: str, expire_hours: int = 72) -> str:
|
|
"""
|
|
Generate a one-time invite token for a user and save it to invite.json.
|
|
Returns the raw token string (embed in a URL).
|
|
"""
|
|
token = secrets.token_urlsafe(32)
|
|
expires = (datetime.now(timezone.utc) + timedelta(hours=expire_hours)).isoformat()
|
|
user_dir = settings.home_root() / username
|
|
user_dir.mkdir(parents=True, exist_ok=True)
|
|
_invite_path(username).write_text(
|
|
json.dumps({"token": token, "expires_at": expires, "used": False}) + "\n"
|
|
)
|
|
logger.info("invite created for user: %s (expires %s)", username, expires[:10])
|
|
return token
|
|
|
|
|
|
def validate_invite(token: str) -> str | None:
|
|
"""
|
|
Check an invite token across all users.
|
|
Returns the username if valid and unused, None otherwise.
|
|
"""
|
|
root = settings.home_root()
|
|
if not root.exists():
|
|
return None
|
|
for user_dir in root.iterdir():
|
|
if not user_dir.is_dir():
|
|
continue
|
|
inv_path = user_dir / "invite.json"
|
|
if not inv_path.exists():
|
|
continue
|
|
try:
|
|
data = json.loads(inv_path.read_text())
|
|
except Exception:
|
|
continue
|
|
if data.get("used"):
|
|
continue
|
|
if data.get("token") != token:
|
|
continue
|
|
expires = datetime.fromisoformat(data["expires_at"])
|
|
if datetime.now(timezone.utc) > expires:
|
|
continue
|
|
return user_dir.name
|
|
return None
|
|
|
|
|
|
def consume_invite(username: str) -> None:
|
|
"""Mark the invite token for a user as used."""
|
|
path = _invite_path(username)
|
|
if path.exists():
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
data["used"] = True
|
|
path.write_text(json.dumps(data) + "\n")
|
|
except Exception:
|
|
pass
|