From 1b4f0318e73ceba4e5182d1b087d930db31602f0 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 3 Jun 2026 16:03:08 +0200 Subject: [PATCH] feat: self-service password reset via email (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email column on users (migration-safe ALTER TABLE) - email_reset_tokens table (1h TTL, single-use) - smtp.py: send via STARTTLS, config from CLI/env vars - POST /api/auth/request-reset — sends reset link, always 200 (no email leak) - POST /api/auth/reset-password-token — consumes email token - GET/POST /api/me/email — users can register/update their email - reset-password page: email form primary, admin code form as toggle, token form shown automatically when ?token= is in URL - CLI: --smtp-host/port/user/password/from (BINCIO_SMTP_* env vars) --- bincio/auth/cli.py | 20 ++ bincio/auth/db.py | 81 +++++++- bincio/auth/models.py | 13 ++ bincio/auth/routers/auth.py | 67 +++++++ bincio/auth/smtp.py | 48 +++++ site/src/pages/reset-password/index.astro | 230 ++++++++++++++++------ 6 files changed, 393 insertions(+), 66 deletions(-) create mode 100644 bincio/auth/smtp.py diff --git a/bincio/auth/cli.py b/bincio/auth/cli.py index 346a3a5..441a555 100644 --- a/bincio/auth/cli.py +++ b/bincio/auth/cli.py @@ -98,6 +98,11 @@ def show_secret_cmd(data_dir: str) -> None: type=click.Path(), help="Path to RS256 PEM private key. Enables OIDC endpoints.") @click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER", help="OIDC issuer URL (e.g. https://bincio.org). Required when --oidc-private-key-file is set.") +@click.option("--smtp-host", default=None, envvar="BINCIO_SMTP_HOST", help="SMTP server hostname.") +@click.option("--smtp-port", default=587, envvar="BINCIO_SMTP_PORT", type=int, show_default=True) +@click.option("--smtp-user", default=None, envvar="BINCIO_SMTP_USER", help="SMTP login username.") +@click.option("--smtp-password", default=None, envvar="BINCIO_SMTP_PASSWORD", help="SMTP password / app password.") +@click.option("--smtp-from", default=None, envvar="BINCIO_SMTP_FROM", help="From address (defaults to --smtp-user).") def serve_cmd( data_dir: str, host: str, @@ -105,6 +110,11 @@ def serve_cmd( jwt_secret: str | None, oidc_private_key_file: str | None, oidc_issuer: str | None, + smtp_host: str | None, + smtp_port: int, + smtp_user: str | None, + smtp_password: str | None, + smtp_from: str | None, ) -> None: """Start the bincio-auth API server.""" import uvicorn @@ -140,11 +150,21 @@ def serve_cmd( if not deps.oidc_issuer: raise click.UsageError("--oidc-issuer is required when --oidc-private-key-file is set") + if smtp_host and smtp_user and smtp_password: + from bincio.auth import smtp as _smtp + _smtp.host = smtp_host + _smtp.port = smtp_port + _smtp.user = smtp_user + _smtp.password = smtp_password + _smtp.sender = smtp_from or smtp_user + console.print("[bold]bincio-auth[/bold]") console.print(f" Data: [cyan]{dd}[/cyan]") console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") if deps.oidc_issuer: console.print(f" OIDC: [cyan]{deps.oidc_issuer}[/cyan]") + if smtp_host: + console.print(f" SMTP: [cyan]{smtp_user}[/cyan] via [cyan]{smtp_host}:{smtp_port}[/cyan]") console.print() uvicorn.run(srv.app, host=host, port=port, log_level="info") diff --git a/bincio/auth/db.py b/bincio/auth/db.py index 9f46b8b..71c78f8 100644 --- a/bincio/auth/db.py +++ b/bincio/auth/db.py @@ -2,12 +2,13 @@ Schema ------ -users — registered accounts (handle, hashed password, access flags) -sessions — active login sessions (opaque token → handle, expiry) -invites — invite codes (who created, who used, grants_activity flag) -reset_codes — password reset tokens (admin-issued, 24 h TTL) -settings — instance-wide key/value config -user_prefs — per-user key/value preferences +users — registered accounts (handle, hashed password, access flags) +sessions — active login sessions (opaque token → handle, expiry) +invites — invite codes (who created, who used, grants_activity flag) +reset_codes — password reset tokens (admin-issued, 24 h TTL) +email_reset_tokens — self-service password reset tokens (emailed, 1 h TTL) +settings — instance-wide key/value config +user_prefs — per-user key/value preferences All timestamps are Unix integers (UTC). Passwords are hashed with bcrypt. @@ -97,16 +98,26 @@ CREATE TABLE IF NOT EXISTS oauth2_codes ( used_at INTEGER ); +CREATE TABLE IF NOT EXISTS email_reset_tokens ( + token TEXT PRIMARY KEY, + handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER +); + 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); CREATE INDEX IF NOT EXISTS oauth2_codes_client ON oauth2_codes(client_id); CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle); +CREATE INDEX IF NOT EXISTS email_reset_tokens_handle ON email_reset_tokens(handle); """ _SESSION_DAYS = 30 _INVITE_LENGTH = 8 -_RESET_CODE_TTL_S = 24 * 3600 # 24 hours +_RESET_CODE_TTL_S = 24 * 3600 # 24 hours +_EMAIL_RESET_TTL_S = 3600 # 1 hour # ── Data classes ────────────────────────────────────────────────────────────── @@ -145,10 +156,11 @@ def open_db(data_dir: Path) -> sqlite3.Connection: db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA foreign_keys=ON") db.executescript(_SCHEMA) - # Migration: add suspended column to pre-existing databases cols = {r[1] for r in db.execute("PRAGMA table_info(users)")} if "suspended" not in cols: db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0") + if "email" not in cols: + db.execute("ALTER TABLE users ADD COLUMN email TEXT DEFAULT NULL") db.commit() return db @@ -514,6 +526,59 @@ def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool: return True +# ── User email ─────────────────────────────────────────────────────────────── + +def get_user_by_email(db: sqlite3.Connection, email: str) -> User | None: + row = db.execute( + "SELECT * FROM users WHERE lower(email) = lower(?)", (email,) + ).fetchone() + if not row: + return None + return User( + handle=row["handle"], + display_name=row["display_name"], + is_admin=bool(row["is_admin"]), + wiki_access=bool(row["wiki_access"]), + activity_access=bool(row["activity_access"]), + suspended=bool(row["suspended"]), + created_at=row["created_at"], + ) + + +def set_user_email(db: sqlite3.Connection, handle: str, email: str | None) -> None: + db.execute("UPDATE users SET email = ? WHERE handle = ?", (email, handle)) + db.commit() + + +def get_user_email(db: sqlite3.Connection, handle: str) -> str | None: + row = db.execute("SELECT email FROM users WHERE handle = ?", (handle,)).fetchone() + return row["email"] if row else None + + +# ── Email reset tokens ──────────────────────────────────────────────────────── + +def create_email_reset_token(db: sqlite3.Connection, handle: str) -> str: + now = int(time.time()) + db.execute("DELETE FROM email_reset_tokens WHERE handle = ? AND used_at IS NULL", (handle,)) + token = secrets.token_urlsafe(32) + db.execute( + "INSERT INTO email_reset_tokens (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, handle, now, now + _EMAIL_RESET_TTL_S), + ) + db.commit() + return token + + +def get_email_reset_token(db: sqlite3.Connection, token: str) -> dict | None: + row = db.execute("SELECT * FROM email_reset_tokens WHERE token = ?", (token,)).fetchone() + return dict(row) if row else None + + +def use_email_reset_token(db: sqlite3.Connection, token: str) -> None: + db.execute("UPDATE email_reset_tokens SET used_at = ? WHERE token = ?", (int(time.time()), token)) + db.commit() + + # ── OAuth2 clients ──────────────────────────────────────────────────────────── import json as _json diff --git a/bincio/auth/models.py b/bincio/auth/models.py index 00a96e3..772c44f 100644 --- a/bincio/auth/models.py +++ b/bincio/auth/models.py @@ -41,5 +41,18 @@ class CreateInviteRequest(BaseModel): grants_activity: bool = Field(default=False) +class RequestResetRequest(BaseModel): + email: str + + +class ResetPasswordTokenRequest(BaseModel): + token: str + password: str = Field(..., min_length=8, description="New password (min 8 chars)") + + +class SetEmailRequest(BaseModel): + email: str + + class GenericResponse(BaseModel): ok: bool = True diff --git a/bincio/auth/routers/auth.py b/bincio/auth/routers/auth.py index 2a2eea3..08c2fd6 100644 --- a/bincio/auth/routers/auth.py +++ b/bincio/auth/routers/auth.py @@ -2,19 +2,28 @@ from __future__ import annotations +import time + from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi.responses import JSONResponse from bincio.auth import deps +from bincio.auth import smtp as _smtp from bincio.auth.db import ( authenticate, change_password, count_activity_users, count_wiki_users, + create_email_reset_token, create_user, + get_email_reset_token, get_invite, get_setting, get_user, + get_user_by_email, + get_user_email, + set_user_email, + use_email_reset_token, use_invite, use_reset_code, ) @@ -24,7 +33,10 @@ from bincio.auth.models import ( LoginResponse, RegisterRequest, RegisterResponse, + RequestResetRequest, ResetPasswordRequest, + ResetPasswordTokenRequest, + SetEmailRequest, ) router = APIRouter() @@ -111,6 +123,61 @@ async def reset_password(body: ResetPasswordRequest) -> JSONResponse: return JSONResponse({"ok": True}) +# ── Self-service password reset (email) ────────────────────────────────────── + +@router.post("/api/auth/request-reset", response_model=GenericResponse) +async def request_reset(body: RequestResetRequest, request: Request) -> JSONResponse: + ip = request.client.host if request.client else "unknown" + deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, + "Too many attempts. Try again later.") + + email = body.email.strip().lower() + db = deps._get_db() + user = get_user_by_email(db, email) + if user and not user.suspended and _smtp.configured(): + token = create_email_reset_token(db, user.handle) + issuer = deps.oidc_issuer.rstrip("/") if deps.oidc_issuer else "" + reset_url = f"{issuer}/reset-password/?token={token}" + try: + _smtp.send_reset_email(email, user.handle, reset_url) + except Exception: + pass # never reveal SMTP errors to the caller + + # Always return 200 so the response doesn't leak whether an email is registered + return JSONResponse({"ok": True}) + + +@router.post("/api/auth/reset-password-token", response_model=GenericResponse) +async def reset_password_via_token(body: ResetPasswordTokenRequest) -> JSONResponse: + if len(body.password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + db = deps._get_db() + rec = get_email_reset_token(db, body.token) + if not rec or rec["used_at"] is not None or rec["expires_at"] < int(time.time()): + raise HTTPException(400, "Invalid or expired reset link") + use_email_reset_token(db, body.token) + change_password(db, rec["handle"], body.password) + return JSONResponse({"ok": True}) + + +@router.post("/api/me/email", response_model=GenericResponse) +async def set_email( + body: SetEmailRequest, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + email = body.email.strip().lower() or None + set_user_email(deps._get_db(), user.handle, email) + return JSONResponse({"ok": True}) + + +@router.get("/api/me/email") +async def get_email(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + email = get_user_email(deps._get_db(), user.handle) + return JSONResponse({"email": email}) + + # ── Registration ────────────────────────────────────────────────────────────── @router.post("/api/register", response_model=RegisterResponse) diff --git a/bincio/auth/smtp.py b/bincio/auth/smtp.py new file mode 100644 index 0000000..cfabdd0 --- /dev/null +++ b/bincio/auth/smtp.py @@ -0,0 +1,48 @@ +"""SMTP email sending for bincio-auth. + +Config is set by the CLI before uvicorn starts (same pattern as deps.py). +""" + +from __future__ import annotations + +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +# ── Config (set by CLI) ─────────────────────────────────────────────────────── + +host: str = "" +port: int = 587 +user: str = "" +password: str = "" +sender: str = "" # "From" address; defaults to user + + +def configured() -> bool: + return bool(host and user and password) + + +def send_reset_email(to_email: str, handle: str, reset_url: str) -> None: + if not configured(): + raise RuntimeError("SMTP not configured") + + from_addr = sender or user + msg = MIMEMultipart("alternative") + msg["Subject"] = "Reset your Bincio password" + msg["From"] = f"Bincio <{from_addr}>" + msg["To"] = to_email + + body = ( + f"Hi @{handle},\n\n" + f"Someone requested a password reset for your Bincio account.\n" + f"Click the link below to set a new password — it expires in 1 hour:\n\n" + f" {reset_url}\n\n" + f"If you didn't request this, you can ignore this email.\n\n" + f"— Bincio" + ) + msg.attach(MIMEText(body, "plain")) + + with smtplib.SMTP(host, port, timeout=10) as s: + s.starttls() + s.login(user, password) + s.send_message(msg) diff --git a/site/src/pages/reset-password/index.astro b/site/src/pages/reset-password/index.astro index 36a2433..a98b29e 100644 --- a/site/src/pages/reset-password/index.astro +++ b/site/src/pages/reset-password/index.astro @@ -3,43 +3,99 @@ import Base from '../../layouts/Base.astro'; ---
-

Reset password

-

- Enter the reset code you received from the admin. -

-

- Don't have a code? Contact the instance admin — they can generate one from the admin panel. Codes expire after 24 hours. -

+

Reset password

-
-
- - -
-
- - -
-
- - -

At least 8 characters

-
- -
+

+ Enter your email address and we'll send you a reset link.

- + + +
+

Have an admin-issued code instead?

+ +
+
+ + + + + +

Back to sign in @@ -48,38 +104,47 @@ import Base from '../../layouts/Base.astro';