mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 15:13:55 +08:00
240 lines
9.6 KiB
Python
240 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.parse
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import Request
|
|
from sqlalchemy import text
|
|
|
|
from app.core.crypto import bcrypt_hash, sm2_encrypt_c1c3c2
|
|
from app.core.errors import AppError
|
|
from app.core.redis import JavaRedisCodec
|
|
from app.core.security import AuthUser
|
|
from app.repositories.security import SecurityRepository
|
|
from app.routers.security import security_router
|
|
from app.schemas.security import (
|
|
LoginRequest,
|
|
PasswordChangeRequest,
|
|
RetrievePasswordRequest,
|
|
SmsVerificationRequest,
|
|
)
|
|
from app.services.security import AliyunSmsSender, CaptchaService, SecurityService
|
|
from tests.domain_support import FakeRedis, sqlite_session
|
|
|
|
SECURITY_SCHEMA = [
|
|
"CREATE TABLE sys_params (param_code TEXT PRIMARY KEY, param_value TEXT)",
|
|
"CREATE TABLE sys_user ("
|
|
"id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT, super_admin INTEGER, status INTEGER, "
|
|
"creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
|
|
"CREATE TABLE sys_user_token ("
|
|
"id INTEGER PRIMARY KEY, user_id INTEGER UNIQUE, token TEXT, expire_date DATETIME, "
|
|
"update_date DATETIME, create_date DATETIME)",
|
|
"CREATE TABLE sys_dict_type (id INTEGER PRIMARY KEY, dict_type TEXT)",
|
|
"CREATE TABLE sys_dict_data ("
|
|
"id INTEGER PRIMARY KEY, dict_type_id INTEGER, dict_label TEXT, dict_value TEXT, sort INTEGER)",
|
|
]
|
|
SM2_VECTOR = json.loads((Path(__file__).parent / "fixtures" / "sm2-c1c3c2-golden.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _request() -> Request:
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/xiaozhi/user/login",
|
|
"headers": [(b"user-agent", b"Manager-Web"), (b"x-forwarded-for", b"192.0.2.10")],
|
|
"client": ("127.0.0.1", 52341),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sm2_login_reuses_token_and_password_change_expires_it() -> None:
|
|
public_key, private_key = SM2_VECTOR["publicKey"], SM2_VECTOR["privateKey"]
|
|
redis = FakeRedis()
|
|
async with sqlite_session(SECURITY_SCHEMA) as session:
|
|
await session.execute(
|
|
text("INSERT INTO sys_params VALUES ('server.private_key', :private_key)"),
|
|
{"private_key": private_key},
|
|
)
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO sys_user "
|
|
"(id, username, password, super_admin, status) VALUES (1, 'alice', :password, 1, 1)"
|
|
),
|
|
{"password": bcrypt_hash("StrongPass1", rounds=4)},
|
|
)
|
|
await session.commit()
|
|
|
|
captcha = CaptchaService(redis) # type: ignore[arg-type]
|
|
service = SecurityService(
|
|
SecurityRepository(session),
|
|
redis=redis, # type: ignore[arg-type]
|
|
captcha=captcha,
|
|
)
|
|
await redis.set("sys:captcha:first", JavaRedisCodec.encode("Ab12C"), ex=300)
|
|
first = await service.login(
|
|
LoginRequest(
|
|
username="alice",
|
|
password=sm2_encrypt_c1c3c2(public_key, "Ab12CStrongPass1"),
|
|
captcha_id="first",
|
|
),
|
|
_request(),
|
|
)
|
|
assert len(first["token"]) == 32
|
|
assert len(first["clientHash"]) == 32
|
|
assert await redis.get("sys:captcha:first") is None
|
|
|
|
await redis.set("sys:captcha:second", JavaRedisCodec.encode("Z9x8Y"), ex=300)
|
|
second = await service.login(
|
|
LoginRequest(
|
|
username="alice",
|
|
password=sm2_encrypt_c1c3c2(public_key, "Z9x8YStrongPass1"),
|
|
captcha_id="second",
|
|
),
|
|
_request(),
|
|
)
|
|
assert second["token"] == first["token"]
|
|
|
|
auth = AuthUser(1, "alice", 1, 1, first["token"], {})
|
|
await service.change_password(
|
|
auth,
|
|
PasswordChangeRequest(password="StrongPass1", new_password="NewStrong2"), # noqa: S106
|
|
)
|
|
expiry = await session.scalar(text("SELECT expire_date FROM sys_user_token WHERE user_id = 1"))
|
|
assert expiry is not None
|
|
parsed_expiry = datetime.fromisoformat(expiry) if isinstance(expiry, str) else expiry
|
|
assert parsed_expiry < datetime.now()
|
|
|
|
await redis.set("sys:captcha:bad", JavaRedisCodec.encode("right"), ex=300)
|
|
with pytest.raises(AppError) as captured:
|
|
await service.login(
|
|
LoginRequest(
|
|
username="alice",
|
|
password=sm2_encrypt_c1c3c2(public_key, "wrongNewStrong2"),
|
|
captcha_id="bad",
|
|
),
|
|
_request(),
|
|
)
|
|
assert captured.value.code == 10067
|
|
assert await redis.get("sys:captcha:bad") is None
|
|
|
|
with pytest.raises(AppError) as missing_password:
|
|
await service.login(LoginRequest(username="alice"), _request())
|
|
assert missing_password.value.code == 10130
|
|
|
|
with pytest.raises(AppError) as manual_validation:
|
|
await service.change_password(auth, PasswordChangeRequest(), "en-US")
|
|
assert manual_validation.value.code == 500
|
|
assert manual_validation.value.message == "The password cannot be empty"
|
|
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO sys_params VALUES "
|
|
"('server.enable_mobile_register', 'true'), ('server.public_key', 'unused')"
|
|
)
|
|
)
|
|
await session.commit()
|
|
with pytest.raises(AppError) as retrieve_validation:
|
|
await service.retrieve_password(RetrievePasswordRequest(), "de-DE")
|
|
assert retrieve_validation.value.code == 500
|
|
assert retrieve_validation.value.message == "Das Passwort darf nicht leer sein"
|
|
|
|
|
|
class _RecordingSmsSender:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, str]] = []
|
|
|
|
async def send_verification_code(self, phone: str | None, code: str) -> None:
|
|
assert phone is not None
|
|
self.calls.append((phone, code))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sms_rate_limit_cache_ttl_and_aliyun_rpc_shape() -> None:
|
|
redis = FakeRedis()
|
|
sender = _RecordingSmsSender()
|
|
async with sqlite_session(SECURITY_SCHEMA) as session:
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO sys_params VALUES "
|
|
"('server.enable_mobile_register', 'true'),"
|
|
"('server.sms_max_send_count', '2'),"
|
|
"('aliyun.sms.access_key_id', 'test-id'),"
|
|
"('aliyun.sms.access_key_secret', 'test-secret'),"
|
|
"('aliyun.sms.sign_name', 'test-sign'),"
|
|
"('aliyun.sms.sms_code_template_code', 'SMS_123')"
|
|
)
|
|
)
|
|
await session.commit()
|
|
await redis.set("sys:captcha:sms-flow", JavaRedisCodec.encode("A1b2C"), ex=300)
|
|
service = SecurityService(
|
|
SecurityRepository(session),
|
|
redis=redis, # type: ignore[arg-type]
|
|
captcha=CaptchaService(redis), # type: ignore[arg-type]
|
|
sms_sender=sender,
|
|
)
|
|
with pytest.raises(AppError) as missing_sms_fields:
|
|
await service.send_sms_verification(SmsVerificationRequest())
|
|
assert missing_sms_fields.value.code == 10067
|
|
|
|
dto = SmsVerificationRequest(phone="+8613800138000", captcha="a1B2c", captcha_id="sms-flow")
|
|
await service.send_sms_verification(dto)
|
|
|
|
assert len(sender.calls) == 1
|
|
assert sender.calls[0][0] == "+8613800138000"
|
|
assert sender.calls[0][1].isdigit() and len(sender.calls[0][1]) == 6
|
|
cached_code = JavaRedisCodec.decode(await redis.get("sys:captcha:sms:Validate:Code:+8613800138000"))
|
|
assert cached_code == sender.calls[0][1]
|
|
assert 0 < await redis.ttl("sms:Validate:Code:+8613800138000:today_count") <= 86400
|
|
|
|
with pytest.raises(AppError) as captured:
|
|
await service.send_sms_verification(dto)
|
|
assert captured.value.code == 10060
|
|
|
|
recorded: dict[str, Any] = {}
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
recorded["params"] = dict(urllib.parse.parse_qsl((await request.aread()).decode()))
|
|
return httpx.Response(200, json={"Code": "OK"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
|
aliyun = AliyunSmsSender(
|
|
SecurityRepository(session),
|
|
redis=redis, # type: ignore[arg-type]
|
|
client=client,
|
|
)
|
|
await aliyun.send_verification_code("+8613800138000", "123456")
|
|
params = recorded["params"]
|
|
assert params["Action"] == "SendSms"
|
|
assert params["TemplateParam"] == '{"code":"123456"}'
|
|
assert params["Signature"]
|
|
|
|
await session.execute(
|
|
text(
|
|
"UPDATE sys_params SET param_value = '' "
|
|
"WHERE param_code IN ('aliyun.sms.access_key_id', 'aliyun.sms.access_key_secret')"
|
|
)
|
|
)
|
|
await session.commit()
|
|
blank_credentials = AliyunSmsSender(
|
|
SecurityRepository(session),
|
|
redis=FakeRedis(), # type: ignore[arg-type]
|
|
)
|
|
with pytest.raises(AppError) as connection_error:
|
|
await blank_credentials.send_verification_code("+8613800138000", "123456")
|
|
assert connection_error.value.code == 10056
|
|
|
|
|
|
def test_security_router_exposes_all_login_controller_routes_and_ping() -> None:
|
|
routes = {(next(iter(route.methods)), route.path) for route in security_router.routes}
|
|
assert len(routes) == 9
|
|
assert ("POST", "/user/login") in routes
|
|
assert ("GET", "/user/captcha") in routes
|
|
assert ("GET", "/api/ping") in routes
|