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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user