diff --git a/bincio/serve/db.py b/bincio/serve/db.py index 4485a01..9a901cf 100644 --- a/bincio/serve/db.py +++ b/bincio/serve/db.py @@ -45,6 +45,15 @@ CREATE TABLE IF NOT EXISTS invites ( used_at INTEGER ); +CREATE TABLE IF NOT EXISTS reset_codes ( + code TEXT PRIMARY KEY, + handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, + created_by TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER +); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -52,10 +61,12 @@ CREATE TABLE IF NOT EXISTS settings ( CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle); CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by); +CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle); """ _SESSION_DAYS = 30 _INVITE_LENGTH = 8 +_RESET_CODE_TTL_S = 24 * 3600 # 24 hours # ── Data classes ────────────────────────────────────────────────────────────── @@ -143,6 +154,13 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional ) +def change_password(db: sqlite3.Connection, handle: str, new_password: str) -> None: + """Replace the password hash for a user.""" + new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode() + db.execute("UPDATE users SET password_hash = ? WHERE handle = ?", (new_hash, handle)) + db.commit() + + def list_users(db: sqlite3.Connection) -> list[User]: rows = db.execute("SELECT * FROM users ORDER BY created_at").fetchall() return [User(handle=r["handle"], display_name=r["display_name"], @@ -317,3 +335,54 @@ def get_invite(db: sqlite3.Connection, code: str) -> Optional[Invite]: created_at=row["created_at"], used_at=row["used_at"], ) + + +# ── Password reset codes ────────────────────────────────────────────────────── + +def create_reset_code(db: sqlite3.Connection, handle: str, created_by: str) -> str: + """Generate a password reset code for a user (admin only, out-of-band delivery). + + Any previous unused codes for this handle are invalidated first. + Returns the new code. + """ + now = int(time.time()) + # Invalidate existing unused codes for this handle + db.execute( + "DELETE FROM reset_codes WHERE handle = ? AND used_at IS NULL", + (handle,), + ) + code = secrets.token_urlsafe(_INVITE_LENGTH)[:_INVITE_LENGTH].upper() + db.execute( + "INSERT INTO reset_codes (code, handle, created_by, created_at, expires_at) " + "VALUES (?, ?, ?, ?, ?)", + (code, handle, created_by, now, now + _RESET_CODE_TTL_S), + ) + db.commit() + return code + + +def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool: + """Validate a reset code for the given handle and mark it used. + + Returns False if the code is invalid, already used, expired, or + belongs to a different handle. + """ + now = int(time.time()) + row = db.execute( + "SELECT handle, expires_at, used_at FROM reset_codes WHERE code = ?", + (code,), + ).fetchone() + if not row: + return False + if row["handle"] != handle: + return False + if row["used_at"] is not None: + return False + if row["expires_at"] < now: + return False + db.execute( + "UPDATE reset_codes SET used_at = ? WHERE code = ?", + (now, code), + ) + db.commit() + return True diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 2c86469..c8ffe24 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -346,6 +346,25 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe return resp +@app.post("/api/auth/reset-password") +async def reset_password(request: Request) -> JSONResponse: + """Validate a reset code and set a new password. Public endpoint.""" + from bincio.serve.db import use_reset_code, change_password + body = await request.json() + handle = (body.get("handle") or "").strip().lower() + code = (body.get("code") or "").strip().upper() + new_pw = body.get("password") or "" + if not handle or not code or not new_pw: + raise HTTPException(400, "handle, code, and password are required") + if len(new_pw) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + db = _get_db() + if not use_reset_code(db, code, handle): + raise HTTPException(400, "Invalid or expired reset code") + change_password(db, handle, new_pw) + return JSONResponse({"ok": True}) + + # ── Registration ────────────────────────────────────────────────────────────── @app.post("/api/register") @@ -503,6 +522,21 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS }) +@app.post("/api/admin/users/{handle}/reset-password-code") +async def admin_reset_password_code( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Generate a one-time password reset code for a user. Admin only.""" + from bincio.serve.db import create_reset_code + admin = _require_admin(bincio_session) + db = _get_db() + if not get_user(db, handle): + raise HTTPException(404, f"User '{handle}' not found") + code = create_reset_code(db, handle, admin.handle) + return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24}) + + @app.post("/api/admin/users/{handle}/rebuild") async def admin_rebuild( handle: str, diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 1789631..3a977ec 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -122,6 +122,11 @@ import Base from '../../layouts/Base.astro'; data-handle="${u.handle}" title="Re-run merge_all and trigger a site rebuild" >Rebuild + Reset pwd ('.pwreset-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const h = btn.dataset.handle!; + btn.disabled = true; + btn.textContent = '…'; + try { + const r = await fetch(`/api/admin/users/${h}/reset-password-code`, { + method: 'POST', + credentials: 'include', + }); + const d = await r.json(); + if (r.ok) { + btn.textContent = d.code; + btn.title = `Code for ${h} — valid 24 h. Click to copy.`; + btn.classList.add('text-yellow-300', 'font-mono'); + btn.addEventListener('click', () => navigator.clipboard.writeText(d.code), { once: true }); + } else { + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + btn.disabled = false; + } + } catch { + btn.textContent = 'Error'; + btn.classList.add('text-red-400'); + btn.disabled = false; + } + }); + }); + tbodyEl.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', () => { pendingHandle = btn.dataset.handle!; diff --git a/site/src/pages/login/index.astro b/site/src/pages/login/index.astro index 599e1d1..2779abf 100644 --- a/site/src/pages/login/index.astro +++ b/site/src/pages/login/index.astro @@ -33,6 +33,9 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; Have an invite? Create account + + Forgot password? + )} diff --git a/site/src/pages/reset-password/index.astro b/site/src/pages/reset-password/index.astro new file mode 100644 index 0000000..8051850 --- /dev/null +++ b/site/src/pages/reset-password/index.astro @@ -0,0 +1,86 @@ +--- +import Base from '../../layouts/Base.astro'; +--- + + + Reset password + Enter the reset code you received from the admin. + + + + Reset code + + + + Handle + + + + New password + + At least 8 characters + + + Password updated. Sign in + + Set new password + + + + + Back to sign in + + + + +
Have an invite? Create account
+ Forgot password? +
Enter the reset code you received from the admin.
At least 8 characters
Password updated. Sign in
+ Back to sign in +