Add one-time password recovery flow
This commit is contained in:
@@ -16,6 +16,7 @@ from app import database
|
||||
|
||||
PASSWORD_ITERATIONS = 310_000
|
||||
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
|
||||
PASSWORD_RESET_LIFETIME_SECONDS = 30 * 60
|
||||
LOGIN_FAILURE_LIMIT = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
REGISTRATION_LIMIT = 30
|
||||
@@ -102,6 +103,26 @@ def registration_retry_after(client_host: str, *, now: int | None = None) -> int
|
||||
)
|
||||
|
||||
|
||||
def password_reset_retry_after(
|
||||
email: str, client_host: str, *, now: int | None = None
|
||||
) -> int:
|
||||
current = int(time.time()) if now is None else now
|
||||
normalized = normalize_email(email)
|
||||
return max(
|
||||
_retry_after("password-reset-email", normalized, 3, 60 * 60, now=current),
|
||||
_retry_after("password-reset-client", client_host or "unknown", 10, 60 * 60, now=current),
|
||||
)
|
||||
|
||||
|
||||
def record_password_reset_attempt(
|
||||
email: str, client_host: str, *, now: int | None = None
|
||||
) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
normalized = normalize_email(email)
|
||||
_record_attempt("password-reset-email", normalized, 60 * 60, now=current)
|
||||
_record_attempt("password-reset-client", client_host or "unknown", 60 * 60, now=current)
|
||||
|
||||
|
||||
def record_registration_attempt(client_host: str, *, now: int | None = None) -> None:
|
||||
_record_attempt(
|
||||
"register-client", client_host or "unknown", REGISTRATION_WINDOW_SECONDS,
|
||||
@@ -197,6 +218,54 @@ def create_account(email: str, password: str) -> str:
|
||||
return user_id
|
||||
|
||||
|
||||
def issue_password_reset(email: str, *, now: int | None = None) -> str | None:
|
||||
normalized = normalize_email(email)
|
||||
current = int(time.time()) if now is None else now
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode("ascii")).hexdigest()
|
||||
with database._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT user_id FROM user_identities WHERE provider='password' AND provider_subject=?",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE expires_at <= ?", (current,))
|
||||
if row is None:
|
||||
return None
|
||||
connection.execute(
|
||||
"DELETE FROM password_reset_tokens WHERE user_id=?", (row["user_id"],)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES(?,?,?)",
|
||||
(token_hash, row["user_id"], current + PASSWORD_RESET_LIFETIME_SECONDS),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def reset_password(token: str, password: str, *, now: int | None = None) -> bool:
|
||||
if not 12 <= len(password) <= 256 or not token or len(token) > 256:
|
||||
return False
|
||||
current = int(time.time()) if now is None else now
|
||||
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
|
||||
with database._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT user_id FROM password_reset_tokens WHERE token_hash=? AND expires_at>?",
|
||||
(token_hash, current),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE token_hash=?", (token_hash,))
|
||||
return False
|
||||
user_id = row["user_id"]
|
||||
password_hash = _hash_password(password)
|
||||
connection.execute(
|
||||
"UPDATE user_identities SET password_hash=? WHERE user_id=? AND provider='password'",
|
||||
(password_hash, user_id),
|
||||
)
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE user_id=?", (user_id,))
|
||||
connection.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,))
|
||||
return True
|
||||
|
||||
|
||||
def authenticate(email: str, password: str) -> tuple[str, str] | None:
|
||||
normalized = normalize_email(email)
|
||||
with database._connect() as connection:
|
||||
|
||||
@@ -71,6 +71,15 @@ def initialize_database() -> None:
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
|
||||
ON auth_sessions(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_user
|
||||
ON password_reset_tokens(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
scope TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
|
||||
@@ -25,6 +25,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app import database
|
||||
from app import auth
|
||||
from app import password_reset_email
|
||||
from app.model_provider import get_model_provider
|
||||
from app import workspace
|
||||
from app import skills
|
||||
@@ -295,6 +296,15 @@ class PasswordCredentials(BaseModel):
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=254)
|
||||
|
||||
|
||||
class PasswordResetCompletion(BaseModel):
|
||||
token: str = Field(min_length=32, max_length=256)
|
||||
new_password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class WebReadRequest(BaseModel):
|
||||
url: str = Field(min_length=8, max_length=2048, description="رابط صفحة ويب عامة تريد تحليلها")
|
||||
question: str = Field(default="لخّص محتوى الصفحة وأهم نقاطها.", min_length=1, max_length=2000)
|
||||
@@ -1209,6 +1219,49 @@ def register_account(credentials: PasswordCredentials, request: Request) -> dict
|
||||
return _auth_response(user_id, email, "account")
|
||||
|
||||
|
||||
@app.post("/v1/auth/password-reset/request", status_code=202)
|
||||
def request_password_reset(payload: PasswordResetRequest, request: Request) -> dict[str, str]:
|
||||
try:
|
||||
email = auth.normalize_email(payload.email)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if not password_reset_email.smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="استعادة كلمة المرور غير مهيأة؛ أعد لاحقًا بعد إعداد SMTP.",
|
||||
)
|
||||
client_host = request.client.host if request.client is not None else "unknown"
|
||||
retry_after = auth.password_reset_retry_after(email, client_host)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="طلبات الاستعادة كثيرة؛ حاول بعد انتهاء المهلة.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
auth.record_password_reset_attempt(email, client_host)
|
||||
token = auth.issue_password_reset(email)
|
||||
try:
|
||||
password_reset_email.send_password_reset_email(email, token)
|
||||
except Exception as exc:
|
||||
# Do not log the destination address, token, SMTP transcript, or credentials.
|
||||
logger.warning("Password reset email delivery failed (%s)", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="تعذر إرسال رسالة الاستعادة الآن؛ حاول لاحقًا.",
|
||||
) from None
|
||||
return {
|
||||
"status": "accepted",
|
||||
"message": "إذا كان البريد مرتبطًا بحساب، فستصلك رسالة استعادة.",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/auth/password-reset/complete")
|
||||
def complete_password_reset(payload: PasswordResetCompletion) -> dict[str, str]:
|
||||
if not auth.reset_password(payload.token, payload.new_password):
|
||||
raise HTTPException(status_code=400, detail="رمز الاستعادة غير صالح أو منتهي الصلاحية.")
|
||||
return {"status": "password_reset"}
|
||||
|
||||
|
||||
@app.post("/v1/auth/login")
|
||||
def login_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""SMTP delivery for one-time account recovery messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
|
||||
def smtp_configured() -> bool:
|
||||
return bool(
|
||||
os.getenv("SOVEREIGNAI_SMTP_HOST", "").strip()
|
||||
and os.getenv("SOVEREIGNAI_SMTP_FROM", "").strip()
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_email(address: str, token: str | None) -> None:
|
||||
host = os.getenv("SOVEREIGNAI_SMTP_HOST", "").strip()
|
||||
sender = os.getenv("SOVEREIGNAI_SMTP_FROM", "").strip()
|
||||
if not host or not sender:
|
||||
raise RuntimeError("SMTP recovery email is not configured")
|
||||
security = os.getenv("SOVEREIGNAI_SMTP_SECURITY", "starttls").strip().casefold()
|
||||
if security not in {"starttls", "ssl"}:
|
||||
raise RuntimeError("SOVEREIGNAI_SMTP_SECURITY must be starttls or ssl")
|
||||
port = int(os.getenv("SOVEREIGNAI_SMTP_PORT", "465" if security == "ssl" else "587"))
|
||||
username = os.getenv("SOVEREIGNAI_SMTP_USERNAME", "")
|
||||
password = os.getenv("SOVEREIGNAI_SMTP_PASSWORD", "")
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = "استعادة كلمة مرور SovereignAI"
|
||||
message["From"] = sender
|
||||
message["To"] = address
|
||||
message.set_content(
|
||||
"إذا كان لهذا البريد حساب في SovereignAI، استخدم رمز الاستعادة التالي خلال 30 دقيقة.\n\n"
|
||||
f"{token if token is not None else 'لا يوجد رمز صالح لهذا الطلب.'}\n\n"
|
||||
"إذا لم تطلب الاستعادة، تجاهل هذه الرسالة."
|
||||
)
|
||||
|
||||
smtp_type = smtplib.SMTP_SSL if security == "ssl" else smtplib.SMTP
|
||||
client = (
|
||||
smtp_type(host, port, timeout=10, context=ssl.create_default_context())
|
||||
if security == "ssl"
|
||||
else smtp_type(host, port, timeout=10)
|
||||
)
|
||||
with client:
|
||||
if security == "starttls":
|
||||
client.starttls(context=ssl.create_default_context())
|
||||
if username:
|
||||
client.login(username, password)
|
||||
client.send_message(message)
|
||||
Reference in New Issue
Block a user