feat: add SRP authentication, improve security

- Replace RSA key exchange with SRP (Secure Remote Password)
- Password never transmitted over network
- Add unit tests for endpoints
- Fix datetime.UTC compatibility for Python < 3.11
- Fix logger.exception usage
- Update README with new auth flow diagram
This commit is contained in:
mirai
2026-01-02 23:09:00 +03:00
parent e3a3dd3f0f
commit 5cbe355660
26 changed files with 470 additions and 482 deletions
+81 -54
View File
@@ -1,65 +1,94 @@
from dataclasses import asdict
from uuid import uuid4
import json
import base64
import rsa
from sanic import Sanic, Request, response, Websocket
from sanic.response import HTTPResponse, json as json_response
from cryptography.fernet import Fernet
from .models import Message, UserSession
from .logger import logger
from .helpers import (
require_auth,
extract_pubkey,
get_client_ip,
get_param,
verify_password,
send_state,
utcnow,
)
async def get_key(request: Request, app: Sanic) -> HTTPResponse:
if err := require_auth(request, app):
return err
pubkey_bytes = extract_pubkey(request)
if not pubkey_bytes:
return response.text("Bad request: pubkey is required", status=400)
async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
"""SRP Step 1: клиент отправляет username + A"""
try:
public_key = rsa.PublicKey.load_pkcs1(pubkey_bytes)
if public_key.n.bit_length() < 2048:
raise ValueError("RSA key must be at least 2048 bits")
except Exception as e:
logger.warning(f"Invalid public key: {e}")
return response.text(f"Bad pubkey: {e}", status=400)
data = request.json or {}
username = data.get("username", "unknown")
client_public_b64 = data.get("A")
username = get_param(request, "username") or "unknown"
if not client_public_b64:
return response.json({"error": "Missing A"}, status=400)
if await app.ctx.session_store.username_exists(username):
return response.text(f"Username '{username}' is already taken", status=409)
client_public = base64.b64decode(client_public_b64)
session = UserSession(
user_id=str(uuid4()),
ip=get_client_ip(request),
username=get_param(request, "username") or "unknown",
fernet_key=app.ctx.fernet_key,
)
await app.ctx.session_store.add(session)
if app.ctx.session_store.username_exists(username):
return response.json({"error": "Username taken"}, status=409)
try:
encrypted_key = rsa.encrypt(app.ctx.fernet_key, public_key)
logger.info(f"Key exchange: user={session.username}, session={session.user_id}")
user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
return response.raw(
encrypted_key,
content_type="application/octet-stream",
headers={"X-User-Id": session.user_id},
logger.info(f"SRP init: {username} ({user_id[:8]}...)")
return response.json(
{
"user_id": user_id,
"B": base64.b64encode(B).decode(),
"salt": base64.b64encode(salt).decode(),
}
)
except Exception as e:
logger.error(f"Encryption failed: {e}")
return response.text("Key encryption failed", status=500)
except Exception:
logger.exception("SRP init failed")
return response.json({"error": "SRP init failed"}, status=500)
async def srp_verify(request: Request, app: Sanic) -> HTTPResponse:
"""SRP Step 2: клиент отправляет proof M"""
try:
data = request.json or {}
user_id = data.get("user_id")
client_proof_b64 = data.get("M")
username = data.get("username", "unknown")
if not user_id or not client_proof_b64:
return response.json({"error": "Missing user_id or M"}, status=400)
client_proof = base64.b64decode(client_proof_b64)
H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof)
fernet_key = base64.urlsafe_b64encode(session_key[:32])
session = UserSession(
user_id=user_id,
ip=get_client_ip(request),
username=username,
fernet_key=fernet_key,
)
app.ctx.session_store.add(session)
logger.info(f"SRP verified: {username} ({user_id[:8]}...)")
return response.json(
{
"H_AMK": base64.b64encode(H_AMK).decode(),
"session_key": base64.b64encode(fernet_key).decode(),
}
)
except ValueError as e:
logger.warning(f"SRP verify failed: {e}")
return response.json({"error": str(e)}, status=401)
except Exception:
logger.exception("SRP verify failed")
return response.json({"error": "SRP verify failed"}, status=500)
async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
@@ -69,17 +98,13 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
await ws.close(code=4002, reason="user_id required")
return
if not verify_password(request.args.get("password"), app.ctx.admin_password):
await ws.close(code=4001, reason="Unauthorized")
return
session = await app.ctx.session_store.get(user_id)
session = app.ctx.session_store.get(user_id)
if not session:
await ws.close(code=4002, reason="Invalid session")
return
manager = app.ctx.connection_manager
await manager.connect(user_id, ws)
await manager.connect(user_id, ws) # await добавлен
try:
await send_state(ws, app)
@@ -88,14 +113,14 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
if data is None:
break
await app.ctx.session_store.update_activity(user_id)
app.ctx.session_store.update_activity(user_id)
message = Message(
text=str(data),
user_ip=session.ip,
username=session.username,
)
await app.ctx.message_store.add(message)
app.ctx.message_store.add(message)
await manager.broadcast(
json.dumps(
@@ -106,10 +131,10 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
)
)
except Exception as e:
logger.error(f"WebSocket error for {user_id}: {e}")
except Exception:
logger.exception(f"WebSocket error for {user_id}")
finally:
await manager.disconnect(user_id)
await manager.disconnect(user_id) # await добавлен
await manager.broadcast(
json.dumps(
{
@@ -124,15 +149,17 @@ async def health(request: Request, app: Sanic) -> HTTPResponse:
return json_response(
{
"status": "ok",
"messages": await app.ctx.message_store.count(),
"users": await app.ctx.session_store.count(),
"messages": app.ctx.message_store.count(),
"users": app.ctx.session_store.count(),
"timestamp": utcnow().isoformat(),
}
)
async def clear_messages(request: Request, app: Sanic) -> HTTPResponse:
if err := require_auth(request, app):
return err
await app.ctx.message_store.clear()
user_id = request.args.get("user_id")
if not user_id or not app.ctx.session_store.get(user_id):
return response.json({"error": "Unauthorized"}, status=401)
app.ctx.message_store.clear()
return json_response({"status": "cleared"})