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';
---
- 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. -
+