Chepuhagram/srv/app/api/endpoints/auth.py

635 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, Form, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.core import security
from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from app.core.security import get_current_user
import pyotp
import qrcode
import base64
from io import BytesIO
from fastapi.responses import StreamingResponse
from typing import Optional
from datetime import datetime, timezone
from app.core.security import _parse_device_name
from app.websocket.connection_manager import manager
from app.services.notification_service import send_system_notification
async def get_db():
async with models.AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
authRouter = APIRouter(prefix="/auth", tags=[])
# Helper для создания сессии при логинах
async def _register_new_login_session(user_id: int, request: Request, db: AsyncSession, device_name: Optional[str] = None):
if not device_name:
device_name = request.headers.get(
"X-Device-Name") or _parse_device_name(request)
ip_address = request.client.host if request.client else "0.0.0.0"
new_session = models.Session(
user_id=user_id,
session_token="",
device_name=device_name,
ip_address=ip_address,
created_at=datetime.now(timezone.utc),
last_active=datetime.now(timezone.utc)
)
db.add(new_session)
await db.commit()
await db.refresh(new_session)
return new_session
@authRouter.post("/register")
async def register(username: str, password: str, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
if current_user.id != 1:
raise HTTPException(status_code=403, detail='Forbidden')
if len(password.encode('utf-8')) > 72:
raise HTTPException(
status_code=400, detail="Пароль слишком длинный (макс. 72 байта)")
result = await db.execute(select(models.User).where(models.User.username == username))
if result.scalars().first():
raise HTTPException(
status_code=400, detail="Пользователь уже существует")
hashed_pwd = security.get_password_hash(password)
new_user = models.User(username=username, hashed_password=hashed_pwd)
db.add(new_user)
await db.commit()
return {"status": "ok", "message": "User created", "id": new_user.id}
@authRouter.post("/hash")
async def register_hash(password: str):
if len(password.encode('utf-8')) > 72:
raise HTTPException(
status_code=400, detail="Пароль слишком длинный (макс. 72 байта)")
hashed_pwd = security.get_password_hash(password)
return {"password": hashed_pwd}
@authRouter.post("/login")
async def login(data: schemas.LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(models.User).where(models.User.username == data.username))
user = result.scalars().first()
if not user or not security.verify_password(data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
if user.totp_secret:
if not data.totp_code:
raise HTTPException(status_code=400, detail="TOTP код требуется")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.totp_code):
raise HTTPException(status_code=400, detail="Неверный TOTP код")
user_id = user.id
explicit_device_name = getattr(
data, "device_name", None) or request.headers.get("X-Device-Name")
new_session = await _register_new_login_session(user_id, request, db, device_name=explicit_device_name)
access_token = security.create_access_token(
data={"sub": str(user_id)}, session_id=new_session.id)
refresh_token = security.create_refresh_token(
data={"sub": str(user_id)}, session_id=new_session.id)
new_session.session_token = access_token
await db.commit()
device_name = _parse_device_name(request)
ip_address = request.client.host if request.client else "0.0.0.0"
current_time = datetime.now(timezone.utc).strftime("%H:%M:%S")
security_alert_text = (
f"Обнаружен новый вход в ваш аккаунт.\n\n"
f"Устройство: {device_name}\n"
f"IP-адрес: {ip_address}\n"
f"Время: {current_time}\n\n"
f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию."
)
await send_system_notification(
db=db,
receiver_id=user_id,
plain_text=security_alert_text
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user_id": user_id
}
@authRouter.post("/login-oauth")
async def login_oauth(request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
totp_code = form_data.client_secret if form_data.client_secret else None
result = await db.execute(select(models.User).where(models.User.username == form_data.username))
user = result.scalars().first()
if not user or not security.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
if user.totp_secret:
if not totp_code:
raise HTTPException(status_code=400, detail="TOTP код требуется")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(totp_code):
raise HTTPException(status_code=400, detail="Неверный TOTP код")
# Логируем сессию в базу данных
oauth_device_name = request.headers.get("X-Device-Name")
new_session = await _register_new_login_session(user.id, request, db, device_name=oauth_device_name)
access_token = security.create_access_token(
data={"sub": str(user.id)}, session_id=new_session.id)
refresh_token = security.create_refresh_token(
data={"sub": str(user.id)}, session_id=new_session.id)
new_session.session_token = access_token
await db.commit()
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user_id": user.id
}
@authRouter.post("/totp/enable")
async def enable_totp(current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(models.User).where(models.User.id == current_user.id))
user = result.scalars().first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
secret = pyotp.random_base32()
user.totp_temp_secret = secret
await db.commit()
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user.username, issuer_name="Chepuhagram")
img = qrcode.make(uri)
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
qr_data_url = f"data:image/png;base64,{qr_base64}"
return {"secret": secret, "qr_code": qr_data_url}
@authRouter.post("/totp/verify")
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(models.User).where(models.User.id == current_user.id))
user = result.scalars().first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
if not user.totp_temp_secret:
raise HTTPException(status_code=400, detail="TOTP не включен")
try:
totp = pyotp.TOTP(user.totp_temp_secret)
code_str = str(data.code).strip()
is_valid = totp.verify(code_str)
if is_valid:
user.totp_secret = user.totp_temp_secret
user.totp_temp_secret = None
await db.commit()
return {"status": "ok"}
else:
raise HTTPException(status_code=400, detail="Неверный код")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Ошибка верификации: {str(e)}")
@authRouter.post("/totp/disable")
async def disable_totp(current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(models.User).where(models.User.id == current_user.id))
user = result.scalars().first()
if user:
user.totp_secret = None
await db.commit()
return {"status": "ok"}
@authRouter.post("/refresh")
async def refresh_token(data: schemas.RefreshRequest, request: Request, db: AsyncSession = Depends(get_db)):
try:
# 1. Декодируем переданный REFRESH токен
payload = jwt.decode(data.refresh_token, security.SECRET_KEY, algorithms=[
security.ALGORITHM])
user_id = str(payload.get("sub"))
# ДОСТАЕМ SESSION_ID ПРЯМО ИЗ ТОКЕНА (для новых клиентов)
session_id = payload.get("session_id")
if user_id is None:
raise HTTPException(
status_code=401, detail="Invalid token payload")
session = None
# 2. Ищем сессию в базе
if session_id:
# === СТРОГИЙ РЕЖИМ (Новые клиенты) ===
# Ищем мгновенно по ID сессии
session_res = await db.execute(
select(models.Session).where(
models.Session.id == int(session_id))
)
session = session_res.scalars().first()
else:
# === РЕЖИМ СОВМЕСТИМОСТИ (Старые клиенты) ===
# Пытаемся вытащить старый access_token из заголовка, СНАЧАЛА объявляя переменную
auth_header = request.headers.get("authorization", "")
old_access_token = auth_header.split(
" ")[1] if "Bearer " in auth_header else None
if old_access_token:
session_res = await db.execute(
select(models.Session).where(
models.Session.session_token == old_access_token)
)
session = session_res.scalars().first()
# 3. ЕСЛИ СЕССИИ НЕТ — ВЫКИДЫВАЕМ НА ЭКРАН ЛОГИНА!
if not session:
raise HTTPException(
status_code=401,
detail="Сессия была отозвана или устарела. Требуется повторная авторизация."
)
# 4. Генерируем новые токены, ОБЯЗАТЕЛЬНО ПРОШИВАЯ В НИХ ID СЕССИИ
new_access_token = security.create_access_token(
data={"sub": user_id}, session_id=session.id
)
new_refresh_token = security.create_refresh_token(
data={"sub": user_id}, session_id=session.id
)
# 5. Обновляем время жизни текущей сессии
# Оставляем чисто для совместимости со старыми клиентами
session.session_token = new_access_token
session.last_active = datetime.now(timezone.utc)
await db.commit()
return {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer"
}
except JWTError:
raise HTTPException(
status_code=401, detail="Refresh token expired or invalid")
# --- СИСТЕМА УПРАВЛЕНИЯ СЕССИЯМИ ---
# 1. Списочный эндпоинт активных устройств
@authRouter.get("/sessions")
async def get_active_sessions(
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_header = request.headers.get("authorization", "")
current_token = auth_header.split(
" ")[1] if "Bearer " in auth_header else ""
# Достаем ID текущей сессии из токена
current_session_id = None
if current_token:
try:
payload = jwt.decode(current_token, security.SECRET_KEY, algorithms=[
security.ALGORITHM])
current_session_id = payload.get("session_id")
except:
pass
result = await db.execute(
select(models.Session)
.where(models.Session.user_id == current_user.id)
.order_by(models.Session.last_active.desc())
)
sessions = result.scalars().all()
return [
{
"id": s.id,
"device_name": s.device_name,
"ip_address": s.ip_address,
"created_at": s.created_at.replace(tzinfo=timezone.utc).isoformat() if s.created_at else None,
"last_active": s.last_active.replace(tzinfo=timezone.utc).isoformat() if s.last_active else None,
"is_current": (s.id == current_session_id) if current_session_id else (s.session_token == current_token)
}
for s in sessions
]
# 2. Завершить все сессии, кроме текущей (как в Telegram)
@authRouter.delete("/sessions/clear-others")
async def revoke_all_other_sessions(
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_header = request.headers.get("authorization", "")
current_token = auth_header.split(
" ")[1] if "Bearer " in auth_header else ""
# Достаем ID текущей сессии из токена
try:
payload = jwt.decode(current_token, security.SECRET_KEY,
algorithms=[security.ALGORITHM])
current_session_id = payload.get("session_id")
except:
raise HTTPException(status_code=401, detail="Невалидный токен")
if not current_session_id:
raise HTTPException(
status_code=400, detail="Невозможно определить текущую сессию (старый клиент)")
# 1. Находим все ЧУЖИЕ сессии пользователя
result = await db.execute(
select(models.Session)
.where(
(models.Session.user_id == current_user.id) &
(models.Session.id != int(current_session_id)) # Исключаем ТЕКУЩИЙ ID
)
)
other_sessions = result.scalars().all()
# 2. Удаляем их из базы и МГНОВЕННО убиваем их вебсокеты
for session in other_sessions:
await db.delete(session)
await manager.kill_session_socket(user_id=current_user.id, session_id=session.id)
await db.commit()
return {"status": "ok", "message": f"Завершено других сессий: {len(other_sessions)}"}
# 3. Отзыв (удаление) конкретной сессии по ID
@authRouter.delete("/sessions/{session_id}")
async def revoke_session(
session_id: int,
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_header = request.headers.get("authorization", "")
current_token = auth_header.split(
" ")[1] if "Bearer " in auth_header else ""
# Достаем ID текущей сессии
try:
payload = jwt.decode(current_token, security.SECRET_KEY,
algorithms=[security.ALGORITHM])
current_session_id = payload.get("session_id")
except:
current_session_id = None
result = await db.execute(
select(models.Session)
.where((models.Session.id == session_id) & (models.Session.user_id == current_user.id))
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="Сессия не найдена")
# Безопасность: нельзя удалить сессию, с которой прямо сейчас сделан этот запрос
if current_session_id and session.id == current_session_id:
raise HTTPException(
status_code=400, detail="Нельзя отозвать текущую сессию. Используйте Выход из аккаунта (Logout)."
)
# Фолбек для старых клиентов:
elif not current_session_id and session.session_token == current_token:
raise HTTPException(
status_code=400, detail="Нельзя отозвать текущую сессию."
)
await db.delete(session)
await db.commit()
# Убиваем сокет именно этого устройства
await manager.kill_session_socket(user_id=current_user.id, session_id=session_id)
return {"status": "ok", "message": "Сессия успешно отозвана"}
@authRouter.post("/logout")
async def logout(
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# 1. Извлекаем токен из заголовка Authorization
auth_header = request.headers.get("authorization", "")
current_token = auth_header.split(
" ")[1] if "Bearer " in auth_header else ""
if not current_token:
raise HTTPException(status_code=401, detail="Токен не найден")
# 2. Декодируем токен, чтобы узнать session_id текущего устройства
try:
payload = jwt.decode(current_token, security.SECRET_KEY,
algorithms=[security.ALGORITHM])
current_session_id = payload.get("session_id")
except jwt.JWTError:
raise HTTPException(
status_code=401, detail="Невалидный или истекший токен")
if not current_session_id:
raise HTTPException(
status_code=400,
detail="Невозможно завершить сессию: старая версия приложения без session_id"
)
# 3. Ищем сессию в базе данных
result = await db.execute(
select(models.Session).where(
(models.Session.id == int(current_session_id)) &
(models.Session.user_id == current_user.id)
)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="Сессия уже не существует")
# 4. Мгновенно обрываем WebSocket-соединение для ЭТОЙ сессии
# (Клиент сразу получит событие закрытия сокета и запустит локальную очистку)
await manager.kill_session_socket(user_id=current_user.id, session_id=session.id)
# 5. Удаляем сессию из базы данных
await db.delete(session)
await db.commit()
return {"status": "ok", "message": "Сессия успешно завершена, устройство разлогинено"}
@authRouter.post("/setup-account")
async def setup_account(data: schemas.SetupAccount, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
user_to_update = await db.merge(current_user)
user_to_update.first_name = data.first_name
user_to_update.last_name = data.last_name
user_to_update.public_key = data.public_key
user_to_update.encrypted_private_key = data.encrypted_private_key
await db.commit()
await db.refresh(user_to_update)
return {"status": "ok", "message": "Account setup completed"}
@authRouter.post("/update-fcm")
async def update_fcm(token: str, request: Request, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
user_to_update = await db.merge(current_user)
user_to_update.fcm_token = token
await db.commit()
await db.refresh(user_to_update)
# 1. Извлекаем токен из заголовка Authorization
auth_header = request.headers.get("authorization", "")
current_token = auth_header.split(
" ")[1] if "Bearer " in auth_header else ""
if not current_token:
raise HTTPException(status_code=401, detail="Токен не найден")
# 2. Декодируем токен, чтобы узнать session_id текущего устройства
try:
payload = jwt.decode(current_token, security.SECRET_KEY,
algorithms=[security.ALGORITHM])
current_session_id = payload.get("session_id")
except jwt.JWTError:
raise HTTPException(
status_code=401, detail="Невалидный или истекший токен")
if not current_session_id:
return {"status": "ok"}
# 3. Ищем сессию в базе данных
result = await db.execute(
select(models.Session).where(
(models.Session.id == int(current_session_id)) &
(models.Session.user_id == current_user.id)
)
)
session = result.scalars().first()
session.fcm_token = token
await db.commit()
await db.refresh(session)
return {"status": "ok"}
@authRouter.post("/qr/approve")
async def qr_approve(
data: schemas.QRApproveRequest,
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# 1. Проверяем, есть ли активное вебсокет-соединение для этой комнаты (room_id)
if data.room_id not in manager.qr_connections:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Сессия сопряжения не найдена или истекла"
)
# 2. Регистрируем новую сессию для сопряженного устройства
device_name = data.device_name or "Новое устройство (QR)"
new_session = await _register_new_login_session(
user_id=current_user.id,
request=request,
db=db,
device_name=device_name
)
# 3. Генерируем JWT-токены авторизации для новой сессии
access_token = security.create_access_token(
data={"sub": str(current_user.id)},
session_id=new_session.id
)
refresh_token = security.create_refresh_token(
data={"sub": str(current_user.id)},
session_id=new_session.id
)
new_session.session_token = access_token
await db.commit()
# 4. Отправляем все необходимые данные новому устройству через WebSocket
payload = {
"type": "qr_authorized",
"access_token": access_token,
"refresh_token": refresh_token,
"phone_temp_pub": data.phone_temp_pub,
"enc_private_key_payload": data.enc_private_key_payload,
"user_id": current_user.id
}
# Отправляем сообщение
sent = await manager.send_qr_message(data.room_id, payload)
# Закрываем сокет комнаты сопряжения
if sent:
ws = manager.qr_connections.get(data.room_id)
if ws:
try:
await ws.close()
except Exception:
pass
manager.disconnect_qr(data.room_id)
# Отправляем системное оповещение на телефон о новом входе
current_time = datetime.now(timezone.utc).strftime("%H:%M:%S")
ip_address = request.client.host if request.client else "0.0.0.0"
security_alert_text = (
f"Обнаружен новый вход в ваш аккаунт через QR-код.\n\n"
f"Устройство: {device_name}\n"
f"IP-адрес: {ip_address}\n"
f"Время: {current_time}\n\n"
f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию."
)
await send_system_notification(
db=db,
receiver_id=current_user.id,
plain_text=security_alert_text
)
return {"status": "ok", "message": "Вход через QR-код успешно одобрен"}