Files
web/backend/services/main/routes/account.py
T

802 lines
27 KiB
Python

from datetime import datetime
from collections import defaultdict, deque
import logging
import time
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
import uuid
import secrets
from user_agents import parse as parse_ua
from ..constants import OWNER_USERNAME
from ..dependencies import get_current_user, get_current_user_allow_suspended, get_db
from ..models import (
LoginRequest,
RegisterRequest,
ChangePasswordRequest,
VerifyPasswordRequest,
DeleteAccountRequest,
User,
CryptoPublicKey,
CryptoBackup,
DeviceSession,
)
from ..utils import create_token, get_password_hash, verify_password, get_client_ip
from ..validation import is_valid_password, is_valid_username, is_valid_display_name
from ..deleted_user import (
apply_deleted_user_db_fields,
deleted_user_api_fields,
is_deleted_user,
is_suspended_user,
)
import os
from ..security.audit import log_security
from ..security.profanity import contains_profanity
from ..security.rate_limit import rate_limit_per_ip
from ..key_lifecycle import destroy_message_keys_for_user
router = APIRouter()
_logger = logging.getLogger(__name__)
_SERVER_INSTANCE_ID: str | None = None
_INSTANCE_ID_FILE = Path(__file__).resolve().parent.parent / ".fromchat_instance_id"
def allocate_user_id(db: Session) -> int:
"""First registered user gets id 1; subsequent users get random unique ids."""
if db.query(User).count() == 0:
return 1
while True:
candidate = secrets.randbelow(2_147_483_646) + 2
if db.query(User).filter(User.id == candidate).first() is None:
return candidate
def get_server_instance_id() -> str:
"""Stable server fingerprint; UUID generated once and persisted next to the main service package."""
global _SERVER_INSTANCE_ID
if _SERVER_INSTANCE_ID is not None:
return _SERVER_INSTANCE_ID
path = _INSTANCE_ID_FILE
try:
if path.is_file():
text = path.read_text(encoding="utf-8").strip()
if text:
_SERVER_INSTANCE_ID = text
return _SERVER_INSTANCE_ID
except OSError as exc:
_logger.warning("Could not read instance id from %s: %s", path, exc)
iid = str(uuid.uuid4())
try:
path.write_text(iid + "\n", encoding="utf-8")
except OSError as exc:
_logger.warning(
"Could not persist instance id to %s (%s); using in-process id only.",
path,
exc,
)
_SERVER_INSTANCE_ID = iid
return iid
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
_FAILED_ATTEMPT_THRESHOLD = 5
_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque)
async def _broadcast_registered_user_count_task():
from ..db import SessionLocal
from .messaging import messagingManager
db = SessionLocal()
try:
await messagingManager.broadcast_registered_user_count(db)
finally:
db.close()
def _record_failed_login(identifier: str) -> bool:
now = time.time()
attempts = _failed_login_attempts[identifier]
attempts.append(now)
while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS:
attempts.popleft()
return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD
def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None)
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User, db: Session) -> dict:
from ..presence_service import presence_service
from ..verification_service import compute_verification_status, get_verified_users_data
if is_deleted_user(user):
return {
"id": user.id,
"admin": _is_admin(user),
**deleted_user_api_fields(user.id),
}
online, last_seen = presence_service.get_presence(user.id)
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(user, verified_users_data)
effective_last_seen = last_seen or user.last_seen or user.created_at
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": effective_last_seen.isoformat(),
"online": online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": _is_admin(user),
"verified": user.verified,
"verification_status": verification_status.value,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": False,
}
def convert_user_for_dm_conversation(user: User, db: Session) -> dict:
"""Minimal user payload for DM conversation list entries."""
from ..presence_service import presence_service
from ..verification_service import compute_verification_status, get_verified_users_data
if is_deleted_user(user):
return {
"id": user.id,
**deleted_user_api_fields(user.id),
}
online, last_seen = presence_service.get_presence(user.id)
verified_users_data = get_verified_users_data(db)
verification_status = compute_verification_status(user, verified_users_data)
effective_last_seen = last_seen or user.last_seen or user.created_at
payload = {
"id": user.id,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"deleted": False,
"verification_status": verification_status.value,
"online": online,
"last_seen": effective_last_seen.isoformat(),
}
if is_suspended_user(user):
payload["suspended"] = True
payload["suspension_reason"] = user.suspension_reason
return payload
@router.get("/instance_id")
def get_instance_id_public():
"""Public deploy fingerprint (used when the client changes server host/port)."""
return {"instance_id": get_server_instance_id()}
@router.get("/check_auth")
def check_auth(current_user: User = Depends(get_current_user)):
return {
"authenticated": True,
"username": current_user.username,
"admin": _is_admin(current_user)
}
@router.get("/check_username")
@rate_limit_per_ip("30/minute")
def check_username(request: Request, username: str, db: Session = Depends(get_db)):
u = username.strip()
if not is_valid_username(u):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username must be 3 to 20 characters and contain only English letters, digits, hyphens, and underscores",
)
exists = db.query(User).filter(User.username == u).first() is not None
return {"exists": exists}
@router.post("/login")
@rate_limit_per_ip("5/minute")
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
username = login_request.username.strip()
client_ip = get_client_ip(request)
raw_ua = request.headers.get("user-agent")
import logging
logging.getLogger("uvicorn.error").info("Login attempt start for username=%s ip=%s", username, client_ip)
user = db.query(User).filter(User.username == username).first()
logging.getLogger("uvicorn.error").info("Queried user from DB for username=%s -> %s", username, "FOUND" if user else "NOT FOUND")
if not user or not verify_password(login_request.password.strip(), user.password_hash):
log_security(
"login_failed",
severity="warning",
username=username,
ip=client_ip,
reason="invalid_credentials",
)
identifiers = [f"user:{username}"]
if client_ip:
identifiers.append(f"ip:{client_ip}")
suspicious = False
for identifier in identifiers:
if _record_failed_login(identifier):
suspicious = True
if suspicious:
total_failures = {
identifier: len(_failed_login_attempts.get(identifier, []))
for identifier in identifiers
}
log_security(
"auth_bruteforce_detected",
severity="warning",
username=username,
ip=client_ip,
failures=total_failures,
window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again in a few minutes.",
)
raise HTTPException(
status_code=401,
detail="Неверное имя пользователя или пароль"
)
# Create device session and embed into JWT
raw_ua = request.headers.get("user-agent")
device_name = request.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
db.commit()
logging.getLogger("uvicorn.error").info("Login DB commit complete for user_id=%s", user.id)
token = create_token(user.id, user.username, session_id)
identifiers = [f"user:{username}"]
if client_ip:
identifiers.append(f"ip:{client_ip}")
for identifier in identifiers:
_reset_failed_logins(identifier)
log_security(
"login_success",
username=user.username,
user_id=user.id,
ip=client_ip,
session_id=session_id,
device=device.device_type,
os=device.os_name,
browser=device.browser_name,
)
return {
"status": "success",
"message": "Login successful",
"token": token,
"user": convert_user(user, db)
}
@router.post("/register")
@rate_limit_per_ip("3/hour")
def register(
request: Request,
register_request: RegisterRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
username = register_request.username.strip()
display_name = register_request.display_name.strip()
password = register_request.password.strip()
confirm_password = register_request.confirm_password.strip()
client_ip = get_client_ip(request)
raw_ua = request.headers.get("user-agent")
# Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
# Validate input
if not is_valid_username(username):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
)
if contains_profanity(username):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя пользователя содержит запрещённые слова"
)
if not is_valid_display_name(display_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
)
if contains_profanity(display_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Отображаемое имя содержит запрещённые слова"
)
if not is_valid_password(password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
)
if password != confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пароли не совпадают"
)
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Это имя пользователя уже занято"
)
bio_text = (register_request.bio or "").strip() or None
if bio_text and len(bio_text) > 500:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Описание должно быть не длиннее 500 символов",
)
if bio_text and contains_profanity(bio_text):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Описание содержит запрещённые слова",
)
hashed_password = get_password_hash(password)
# Set verified=True for the owner (first user to register)
is_owner = not owner_exists and username == OWNER_USERNAME
new_user = User(
id=allocate_user_id(db),
username=username,
display_name=display_name,
password_hash=hashed_password,
bio=bio_text,
verified=is_owner
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Create initial device session
raw_ua = request.headers.get("user-agent")
device_name = request.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=new_user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
db.commit()
token = create_token(new_user.id, new_user.username, session_id)
os_name = ua.os.family or "Unknown OS"
if ua.os.version_string:
os_name = f"{os_name} {ua.os.version_string}"
browser_name = ua.browser.family or "Unknown browser"
if ua.browser.version_string:
browser_name = f"{browser_name} {ua.browser.version_string}"
user_agent_summary = f"{os_name}, {browser_name}"
log_security(
"registration_success",
username=new_user.username,
display_name=new_user.display_name,
user_id=new_user.id,
ip=client_ip,
user_agent=user_agent_summary,
owner=is_owner,
)
background_tasks.add_task(_broadcast_registered_user_count_task)
return {
"status": "success",
"message": "Регистрация прошла успешно",
"token": token,
"user": convert_user(new_user, db)
}
@router.get("/crypto/public-key")
def get_public_key(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
return {"publicKey": row.public_key_b64 if row else None}
@router.post("/crypto/public-key")
def set_public_key(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
pk = payload.get("publicKey")
if not pk:
raise HTTPException(status_code=400, detail="publicKey required")
if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10:
raise HTTPException(status_code=400, detail="Invalid publicKey format")
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
if row:
row.public_key_b64 = pk
else:
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
db.add(row)
db.commit()
return {"status": "ok"}
@router.get("/crypto/backup")
def get_backup(current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
return {"blob": row.blob_json if row else None}
@router.post("/crypto/backup")
def set_backup(payload: dict, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
blob = payload.get("blob")
if not blob:
raise HTTPException(status_code=400, detail="blob required")
if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit
raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB")
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
if row:
row.blob_json = blob
else:
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
db.add(row)
db.commit()
return {"status": "ok"}
@router.delete("/admin/user/{user_id}")
def delete_user_as_owner(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Only owner can delete users
if _is_admin(current_user):
raise HTTPException(status_code=403, detail="Only owner can perform this action")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the owner account via API
if _is_admin(user):
raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints
from models import Message # local import to avoid circular
db.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user)
db.commit()
log_security(
"admin_delete_user",
severity="warning",
actor=current_user.username,
actor_id=current_user.id,
target_username=user.username,
target_id=user.id,
)
return {"status": "success", "deleted_user_id": user_id}
def _revoke_device_session(db: Session, user_id: int, session_id: str) -> int:
"""Mark a device session revoked. Returns the number of rows updated."""
return (
db.query(DeviceSession)
.filter(
DeviceSession.user_id == user_id,
DeviceSession.session_id == session_id,
)
.update({DeviceSession.revoked: True}, synchronize_session=False)
)
@router.get("/logout")
def logout(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
session_id = getattr(request.state, "session_id", None)
if session_id:
updated = _revoke_device_session(db, current_user.id, session_id)
db.commit()
if updated == 0:
_logger.warning(
"logout: session_id=%s not found for user_id=%s",
session_id,
current_user.id,
)
else:
_logger.warning("logout: missing session_id for user_id=%s", current_user.id)
client_ip = get_client_ip(request)
log_security(
"logout",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
session_id=session_id,
)
return {
"status": "success",
"message": "Logged out successfully",
}
@router.post("/change-password")
@rate_limit_per_ip("5/hour")
def change_password(
request: Request,
password_request: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
# Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
# 400 (not 401): mobile client treats 401 as global auth failure and clears the session.
raise HTTPException(status_code=400, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
db.commit()
# Optionally revoke all other sessions, keeping the current one
if password_request.logoutAllExceptCurrent:
current_session_id = getattr(request.state, "session_id", None)
if not current_session_id:
raise HTTPException(status_code=401, detail="Invalid session")
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True}, synchronize_session=False)
db.commit()
client_ip = get_client_ip(request)
log_security(
"password_changed",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
logout_others=bool(password_request.logoutAllExceptCurrent),
)
return {"status": "success"}
def _verify_derived_password(user: User, password_derived: str) -> None:
if not verify_password(password_derived.strip(), user.password_hash):
raise HTTPException(status_code=400, detail="Wrong password")
@router.post("/verify-password")
@rate_limit_per_ip("10/minute")
def verify_password_endpoint(
request: Request,
body: VerifyPasswordRequest,
current_user: User = Depends(get_current_user),
):
# 400 (not 401): mobile client treats 401 as global auth failure and clears the session.
_verify_derived_password(current_user, body.passwordDerived)
client_ip = get_client_ip(request)
log_security(
"password_verified",
username=current_user.username,
user_id=current_user.id,
ip=client_ip,
)
return {"status": "success"}
@router.get("/users")
@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse
def list_users(request: Request, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username.asc()).all()
return {
"users": [
convert_user(u, db) for u in users if u.id != current_user.id
]
}
@router.get("/crypto/public-key/of/{user_id}")
@rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse
def get_public_key_of(
request: Request,
user_id: int,
current_user: User = Depends(get_current_user_allow_suspended),
db: Session = Depends(get_db),
):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
return {"publicKey": row.public_key_b64 if row else None}
@router.get("/users/search")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user_allow_suspended), db: Session = Depends(get_db)):
if len(q.strip()) < 2:
return {"users": []}
# Case-insensitive partial match on username
users = db.query(User).filter(
User.username.ilike(f"%{q.strip()}%"),
User.id != current_user.id # Exclude current user
).order_by(User.username.asc()).limit(20).all()
return {
"users": [convert_user(u, db) for u in users]
}
async def _delete_user_data(user: User, db: Session):
"""
Helper function to delete user data - marks user as deleted, clears sensitive data,
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
"""
user_id = user.id
from ..presence_service import presence_service
apply_deleted_user_db_fields(user)
presence_service.remove_user(user_id)
# Delete profile picture file if exists
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
try:
filename = user.profile_picture.split("/")[-1]
filepath = os.path.join("data/uploads/pfp", filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
# Log error but don't fail the request
pass
# Dynamic deletion of all non-whitelist data
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
try:
inspector = inspect(db.bind)
all_tables = inspector.get_table_names()
for table_name in all_tables:
if table_name in WHITELIST_TABLES or table_name == "user":
continue
# Check if table has user_id column
columns = inspector.get_columns(table_name)
has_user_id = any(col['name'] == 'user_id' for col in columns)
if has_user_id:
# Delete all records for this user
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
destroy_message_keys_for_user(db, user_id, commit=False)
db.commit()
except Exception as e:
# Log error and rollback
db.rollback()
raise HTTPException(status_code=500, detail="Failed to delete user data")
# Send WebSocket deletion message
try:
from .messaging import messagingManager
await messagingManager.send_deletion_to_user(user_id)
except Exception as e:
# Log error but don't fail the request
pass
try:
from .profile import broadcast_profile_update
await broadcast_profile_update(user, db)
except Exception:
pass
try:
from .messaging import messagingManager
await messagingManager.broadcast_registered_user_count(db)
except Exception:
pass
async def _delete_account_impl(
body: DeleteAccountRequest,
current_user: User,
db: Session,
) -> dict:
"""
Delete the current user's own account - preserves messages/DMs/reactions/files
"""
if _is_admin(current_user):
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
_verify_derived_password(current_user, body.passwordDerived)
await _delete_user_data(current_user, db)
log_security(
"self_delete_account",
severity="warning",
user_id=current_user.id,
username=current_user.username,
)
return {
"status": "success",
"message": "Account deleted successfully",
}
@router.post("/delete")
async def delete_account(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await _delete_account_impl(body, current_user, db)
@router.post("/account/delete")
async def delete_account_alias(
body: DeleteAccountRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return await _delete_account_impl(body, current_user, db)