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:
+81
-54
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user