add password reset via admin-generated one-time code

db.py: reset_codes table (code, handle, created_by, created_at,
expires_at, used_at); create_reset_code() invalidates any prior unused
code for the same handle; use_reset_code() validates handle match,
expiry (24 h), and single-use; change_password() updates the hash.

server.py: POST /api/admin/users/{handle}/reset-password-code (admin)
returns a code; POST /api/auth/reset-password (public) validates the
code + handle and sets the new password.

Admin page: "Reset pwd" button per user — shows the code inline on
click (monospace, click-to-copy).
/reset-password/ page: handle + code + new password form.
Login page: "Forgot password?" link.
This commit is contained in:
Davide Scaini
2026-04-14 21:58:50 +02:00
parent d2ba96c26a
commit 13643479ef
5 changed files with 226 additions and 0 deletions
+34
View File
@@ -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,