feat: self-service password reset via email (Phase 4)
- 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)
This commit is contained in:
+73
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user