mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
487 lines
20 KiB
Python
487 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import json
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import string
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Protocol, cast
|
|
|
|
import httpx
|
|
from fastapi import Request
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from redis.asyncio import Redis
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.crypto import bcrypt_hash, bcrypt_matches, generate_database_token, sm2_decrypt_c1c3c2
|
|
from app.core.errors import AppError, ErrorCode
|
|
from app.core.ids import snowflake
|
|
from app.core.redis import JavaRedisCodec, get_redis
|
|
from app.core.security import AuthUser, shanghai_now_naive
|
|
from app.repositories.security import SecurityRepository
|
|
from app.schemas.security import (
|
|
LoginRequest,
|
|
PasswordChangeRequest,
|
|
RetrievePasswordRequest,
|
|
SmsVerificationRequest,
|
|
)
|
|
from app.services.java_validation import validation_message
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOKEN_EXPIRE_SECONDS = 12 * 60 * 60
|
|
CAPTCHA_TTL_SECONDS = 5 * 60
|
|
CAPTCHA_LENGTH = 5
|
|
PHONE_PATTERN = re.compile(r"^\+[1-9]\d{0,3}[1-9]\d{4,14}$")
|
|
STRONG_PASSWORD = re.compile(r"^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).+$")
|
|
|
|
|
|
class SmsSender(Protocol):
|
|
async def send_verification_code(self, phone: str | None, code: str) -> None: ...
|
|
|
|
|
|
class AliyunSmsSender:
|
|
"""Minimal implementation of the Aliyun Dysmsapi RPC request used by the Java SDK."""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: SecurityRepository,
|
|
*,
|
|
redis: Redis | None = None,
|
|
client: httpx.AsyncClient | None = None,
|
|
endpoint: str = "https://dysmsapi.aliyuncs.com/",
|
|
):
|
|
self.repository = repository
|
|
self.redis = redis or get_redis()
|
|
self.client = client
|
|
self.endpoint = endpoint
|
|
|
|
async def send_verification_code(self, phone: str | None, code: str) -> None:
|
|
access_key_id = await self._param("aliyun.sms.access_key_id") or ""
|
|
access_key_secret = await self._param("aliyun.sms.access_key_secret") or ""
|
|
sign_name = await self._param("aliyun.sms.sign_name") or ""
|
|
template_code = await self._param("aliyun.sms.sms_code_template_code") or ""
|
|
# The Tea SDK constructs its client before the refundable send block;
|
|
# blank credentials therefore map to SMS_CONNECTION_FAILED (10056).
|
|
if not access_key_id.strip() or not access_key_secret.strip():
|
|
raise AppError(10056)
|
|
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
params: dict[str, str] = {
|
|
"AccessKeyId": access_key_id,
|
|
"Action": "SendSms",
|
|
"Format": "JSON",
|
|
"RegionId": "cn-hangzhou",
|
|
"SignatureMethod": "HMAC-SHA1",
|
|
"SignatureNonce": str(uuid.uuid4()),
|
|
"SignatureVersion": "1.0",
|
|
"SignName": sign_name,
|
|
"TemplateCode": template_code,
|
|
"TemplateParam": json.dumps({"code": code}, ensure_ascii=False, separators=(",", ":")),
|
|
"Timestamp": timestamp,
|
|
"Version": "2017-05-25",
|
|
}
|
|
if phone is not None:
|
|
params["PhoneNumbers"] = phone
|
|
params["Signature"] = self._signature(params, access_key_secret)
|
|
if self.client is not None:
|
|
response = await self.client.post(self.endpoint, data=params)
|
|
response.raise_for_status()
|
|
return
|
|
timeout = get_settings().external_request_timeout_seconds
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.post(self.endpoint, data=params)
|
|
response.raise_for_status()
|
|
|
|
async def _param(self, code: str) -> str | None:
|
|
cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code))
|
|
if cached is not None:
|
|
return str(cached)
|
|
value = await self.repository.get_param_value(code)
|
|
if value is not None:
|
|
await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value))
|
|
await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60)
|
|
return value
|
|
|
|
@classmethod
|
|
def _signature(cls, params: dict[str, str], secret: str) -> str:
|
|
canonical = "&".join(
|
|
f"{cls._percent_encode(key)}={cls._percent_encode(value)}" for key, value in sorted(params.items())
|
|
)
|
|
string_to_sign = f"POST&%2F&{cls._percent_encode(canonical)}"
|
|
digest = hmac.new(
|
|
f"{secret}&".encode(),
|
|
string_to_sign.encode(),
|
|
digestmod=hashlib.sha1, # noqa: S324 - mandated by Aliyun RPC SignatureMethod
|
|
).digest()
|
|
return base64.b64encode(digest).decode("ascii")
|
|
|
|
@staticmethod
|
|
def _percent_encode(value: str) -> str:
|
|
return urllib.parse.quote(str(value), safe="~")
|
|
|
|
|
|
class CaptchaService:
|
|
def __init__(self, redis: Redis | None = None):
|
|
self.redis = redis or get_redis()
|
|
|
|
async def create(self, identifier: str) -> bytes:
|
|
code = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(CAPTCHA_LENGTH))
|
|
await self._set_cache(identifier, code)
|
|
return self._render_gif(code)
|
|
|
|
async def validate(self, identifier: str | None, code: str | None, *, delete: bool) -> bool:
|
|
if not code or not code.strip():
|
|
return False
|
|
key = self._captcha_key(identifier)
|
|
cached = JavaRedisCodec.decode(await cast(Any, self.redis.get(key)))
|
|
if cached is not None and delete:
|
|
await cast(Any, self.redis.delete(key))
|
|
return cached is not None and code.casefold() == str(cached).casefold()
|
|
|
|
async def set_sms_code(self, phone: str | None, code: str) -> None:
|
|
await self._set_cache(f"sms:Validate:Code:{phone}", code)
|
|
|
|
async def validate_sms_code(self, phone: str | None, code: str | None, *, delete: bool = False) -> bool:
|
|
return await self.validate(f"sms:Validate:Code:{phone}", code, delete=delete)
|
|
|
|
async def _set_cache(self, identifier: str, value: str) -> None:
|
|
await cast(Any, self.redis.set)(
|
|
self._captcha_key(identifier),
|
|
JavaRedisCodec.encode(value),
|
|
ex=CAPTCHA_TTL_SECONDS,
|
|
)
|
|
|
|
@staticmethod
|
|
def _captcha_key(identifier: str | None) -> str:
|
|
return f"sys:captcha:{'null' if identifier is None else identifier}"
|
|
|
|
@staticmethod
|
|
def _render_gif(code: str) -> bytes:
|
|
image = Image.new("RGB", (150, 40), (248, 248, 248))
|
|
draw = ImageDraw.Draw(image)
|
|
for _ in range(8):
|
|
color = tuple(secrets.randbelow(150) for _ in range(3))
|
|
draw.line(
|
|
(
|
|
secrets.randbelow(150),
|
|
secrets.randbelow(40),
|
|
secrets.randbelow(150),
|
|
secrets.randbelow(40),
|
|
),
|
|
fill=color,
|
|
width=1,
|
|
)
|
|
font = ImageFont.load_default(size=24)
|
|
for index, character in enumerate(code):
|
|
color = tuple(secrets.randbelow(120) for _ in range(3))
|
|
draw.text((10 + index * 27, 6 + secrets.randbelow(5)), character, font=font, fill=color)
|
|
output = io.BytesIO()
|
|
image.save(output, format="GIF")
|
|
return output.getvalue()
|
|
|
|
|
|
class SecurityService:
|
|
def __init__(
|
|
self,
|
|
repository: SecurityRepository,
|
|
*,
|
|
redis: Redis | None = None,
|
|
captcha: CaptchaService | None = None,
|
|
sms_sender: SmsSender | None = None,
|
|
):
|
|
self.repository = repository
|
|
self.redis = redis or get_redis()
|
|
self.captcha = captcha or CaptchaService(self.redis)
|
|
self.sms_sender = sms_sender or AliyunSmsSender(repository, redis=self.redis)
|
|
|
|
async def login(self, dto: LoginRequest, request: Request) -> dict[str, Any]:
|
|
password = await self._decrypt_and_validate_captcha(dto.password, dto.captcha_id)
|
|
user = await self.repository.get_user_by_username(dto.username)
|
|
if user is None or not bcrypt_matches(password, cast(str | None, user.get("password"))):
|
|
raise AppError(ErrorCode.ACCOUNT_PASSWORD_ERROR)
|
|
token = await self._create_token(int(user["id"]))
|
|
await self.repository.session.commit()
|
|
return {
|
|
"token": token,
|
|
"expire": TOKEN_EXPIRE_SECONDS,
|
|
"clientHash": self._client_hash(request),
|
|
}
|
|
|
|
async def register(self, dto: LoginRequest) -> None:
|
|
if not await self.allow_user_register():
|
|
raise AppError(10072)
|
|
password = await self._decrypt_and_validate_captcha(dto.password, dto.captcha_id)
|
|
if await self._mobile_registration_enabled():
|
|
if dto.username is None or not PHONE_PATTERN.fullmatch(dto.username):
|
|
raise AppError(10069)
|
|
if not await self.captcha.validate_sms_code(dto.username, dto.mobile_captcha, delete=False):
|
|
raise AppError(10075)
|
|
if await self.repository.get_user_by_username(dto.username) is not None:
|
|
raise AppError(10070)
|
|
if not STRONG_PASSWORD.fullmatch(password):
|
|
raise AppError(ErrorCode.PASSWORD_WEAK_ERROR)
|
|
now = shanghai_now_naive()
|
|
user_count = await self.repository.count_users()
|
|
await self.repository.insert_user(
|
|
user_id=snowflake.next_id(),
|
|
username=dto.username,
|
|
password=bcrypt_hash(password),
|
|
super_admin=1 if user_count == 0 else 0,
|
|
now=now,
|
|
)
|
|
await self.repository.session.commit()
|
|
|
|
async def change_password(
|
|
self,
|
|
user: AuthUser,
|
|
dto: PasswordChangeRequest,
|
|
accept_language: str | None = None,
|
|
) -> None:
|
|
self._require_not_blank(dto.password, "sysuser.password.require", accept_language)
|
|
self._require_not_blank(dto.new_password, "sysuser.password.require", accept_language)
|
|
assert dto.password is not None
|
|
assert dto.new_password is not None
|
|
row = await self.repository.get_user_by_id(user.id)
|
|
if row is None:
|
|
raise AppError(ErrorCode.TOKEN_INVALID)
|
|
if not bcrypt_matches(dto.password, cast(str | None, row.get("password"))):
|
|
raise AppError(10048)
|
|
if not STRONG_PASSWORD.fullmatch(dto.new_password):
|
|
raise AppError(ErrorCode.PASSWORD_WEAK_ERROR)
|
|
now = shanghai_now_naive()
|
|
await self.repository.update_password(
|
|
user.id,
|
|
bcrypt_hash(dto.new_password),
|
|
now,
|
|
preserve_audit_fields=True,
|
|
)
|
|
# SysUserService.changePassword commits before the non-transactional token service logs out.
|
|
await self.repository.session.commit()
|
|
await self.repository.expire_user_token(user.id, now - timedelta(minutes=1))
|
|
await self.repository.session.commit()
|
|
|
|
async def retrieve_password(
|
|
self,
|
|
dto: RetrievePasswordRequest,
|
|
accept_language: str | None = None,
|
|
) -> None:
|
|
if not await self._mobile_registration_enabled():
|
|
raise AppError(10073)
|
|
self._require_not_blank(dto.phone, "sysuser.password.require", accept_language)
|
|
self._require_not_blank(dto.code, "sysuser.password.require", accept_language)
|
|
self._require_not_blank(dto.password, "sysuser.password.require", accept_language)
|
|
self._require_not_blank(dto.captcha_id, "sysuser.uuid.require", accept_language)
|
|
assert dto.phone is not None
|
|
assert dto.code is not None
|
|
assert dto.password is not None
|
|
assert dto.captcha_id is not None
|
|
if not PHONE_PATTERN.fullmatch(dto.phone):
|
|
raise AppError(10074)
|
|
user = await self.repository.get_user_by_username(dto.phone)
|
|
if user is None:
|
|
raise AppError(10071)
|
|
if not await self.captcha.validate_sms_code(dto.phone, dto.code, delete=False):
|
|
raise AppError(10075)
|
|
password = await self._decrypt_and_validate_captcha(dto.password, dto.captcha_id)
|
|
if not STRONG_PASSWORD.fullmatch(password):
|
|
raise AppError(ErrorCode.PASSWORD_WEAK_ERROR)
|
|
await self.repository.update_password(int(user["id"]), bcrypt_hash(password), shanghai_now_naive())
|
|
await self.repository.session.commit()
|
|
|
|
async def send_sms_verification(self, dto: SmsVerificationRequest) -> None:
|
|
if not await self.captcha.validate(dto.captcha_id, dto.captcha, delete=False):
|
|
raise AppError(10067)
|
|
if not await self._mobile_registration_enabled():
|
|
raise AppError(10068)
|
|
phone_key = "null" if dto.phone is None else dto.phone
|
|
last_send_key = f"sms:Validate:Code:{phone_key}:last_send_time"
|
|
current_ms = int(time.time() * 1000)
|
|
created = await cast(Any, self.redis.set)(last_send_key, str(current_ms), ex=60, nx=True)
|
|
if not created:
|
|
raw_last = await cast(Any, self.redis.get)(last_send_key)
|
|
if raw_last is not None:
|
|
last_ms = int(raw_last.decode() if isinstance(raw_last, bytes) else raw_last)
|
|
difference = current_ms - last_ms
|
|
if difference < 60_000:
|
|
raise AppError(10060, params=(str(max(0, (60_000 - difference) // 1000)),))
|
|
|
|
today_key = f"sms:Validate:Code:{phone_key}:today_count"
|
|
raw_count = await cast(Any, self.redis.get)(today_key)
|
|
decoded_count = JavaRedisCodec.decode(raw_count)
|
|
today_count = int(decoded_count or 0)
|
|
raw_maximum = await self._get_param("server.sms_max_send_count", from_cache=True)
|
|
maximum = int(raw_maximum) if raw_maximum is not None and raw_maximum != "" else 5
|
|
if today_count >= maximum:
|
|
raise AppError(10047)
|
|
|
|
code = "".join(secrets.choice(string.digits) for _ in range(6))
|
|
await self.captcha.set_sms_code(dto.phone, code)
|
|
new_count = await cast(Any, self.redis.incr)(today_key)
|
|
if int(new_count) == 1:
|
|
await cast(Any, self.redis.expire)(today_key, 24 * 60 * 60)
|
|
try:
|
|
await self.sms_sender.send_verification_code(dto.phone, code)
|
|
except AppError:
|
|
# Java raises connection-construction failures before entering its refundable send attempt.
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning("Aliyun SMS request failed", exc_info=exc)
|
|
await cast(Any, self.redis.delete)(today_key)
|
|
raise AppError(10055) from exc
|
|
|
|
async def public_config(self) -> dict[str, Any]:
|
|
public_key = await self._get_param("server.public_key", from_cache=True)
|
|
if public_key is None or not public_key.strip():
|
|
raise AppError(10129)
|
|
menu_config = await self._get_param("system-web.menu", from_cache=True)
|
|
result: dict[str, Any] = {
|
|
"enableMobileRegister": await self._mobile_registration_enabled(),
|
|
"version": "0.9.5",
|
|
"year": f"©{shanghai_now_naive().year}",
|
|
"allowUserRegister": await self.allow_user_register(),
|
|
"mobileAreaList": await self._dict_data_by_type("MOBILE_AREA"),
|
|
"beianIcpNum": await self._get_param("server.beian_icp_num", from_cache=True),
|
|
"beianGaNum": await self._get_param("server.beian_ga_num", from_cache=True),
|
|
"name": await self._get_param("server.name", from_cache=True),
|
|
"sm2PublicKey": public_key,
|
|
}
|
|
if menu_config is not None and menu_config.strip():
|
|
result["systemWebMenu"] = json.loads(menu_config)
|
|
return result
|
|
|
|
async def allow_user_register(self) -> bool:
|
|
value = await self._get_param("server.allow_user_register", from_cache=True)
|
|
if value == "true":
|
|
return True
|
|
return await self.repository.count_users() == 0
|
|
|
|
async def _create_token(self, user_id: int) -> str:
|
|
now = shanghai_now_naive()
|
|
expire_date = now + timedelta(seconds=TOKEN_EXPIRE_SECONDS)
|
|
current = await self.repository.get_token_by_user_id(user_id, for_update=True)
|
|
if current is None:
|
|
token = generate_database_token()
|
|
await self.repository.insert_token(
|
|
token_id=snowflake.next_id(),
|
|
user_id=user_id,
|
|
token=token,
|
|
now=now,
|
|
expire_date=expire_date,
|
|
)
|
|
return token
|
|
stored_expiry = self._datetime(current.get("expire_date"))
|
|
token = str(current["token"])
|
|
if stored_expiry is None or stored_expiry < now:
|
|
token = generate_database_token()
|
|
await self.repository.update_token(
|
|
token_id=int(current["id"]),
|
|
token=token,
|
|
now=now,
|
|
expire_date=expire_date,
|
|
)
|
|
return token
|
|
|
|
async def _decrypt_and_validate_captcha(
|
|
self,
|
|
encrypted_password: str | None,
|
|
captcha_id: str | None,
|
|
) -> str:
|
|
private_key = await self._get_param("server.private_key", from_cache=True)
|
|
if private_key is None or not private_key.strip():
|
|
raise AppError(10129)
|
|
try:
|
|
if encrypted_password is None:
|
|
raise ValueError("encrypted password is null")
|
|
content = sm2_decrypt_c1c3c2(private_key, encrypted_password)
|
|
except Exception as exc:
|
|
raise AppError(10130) from exc
|
|
if len(content) > CAPTCHA_LENGTH:
|
|
embedded_captcha = content[:CAPTCHA_LENGTH]
|
|
if not await self.captcha.validate(captcha_id, embedded_captcha, delete=True):
|
|
raise AppError(10067)
|
|
return content[CAPTCHA_LENGTH:]
|
|
if content:
|
|
raise AppError(10067)
|
|
raise AppError(10130)
|
|
|
|
async def _mobile_registration_enabled(self) -> bool:
|
|
value = await self._get_param("server.enable_mobile_register", from_cache=True)
|
|
if value is None or not value.strip():
|
|
return False
|
|
try:
|
|
parsed = json.loads(value.lower())
|
|
except json.JSONDecodeError as exc:
|
|
raise AppError(ErrorCode.PARAMS_GET_ERROR) from exc
|
|
return bool(parsed)
|
|
|
|
async def _get_param(self, code: str, *, from_cache: bool) -> str | None:
|
|
if from_cache:
|
|
cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code))
|
|
if cached is not None:
|
|
return str(cached)
|
|
value = await self.repository.get_param_value(code)
|
|
if from_cache and value is not None:
|
|
await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value))
|
|
await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60)
|
|
return value
|
|
|
|
async def _dict_data_by_type(self, dict_type: str) -> list[dict[str, Any]]:
|
|
key = f"sys:dict:data:{dict_type}"
|
|
cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)(key))
|
|
if isinstance(cached, list):
|
|
return cast(list[dict[str, Any]], cached)
|
|
values = await self.repository.get_mobile_area_items()
|
|
await cast(Any, self.redis.set)(
|
|
key,
|
|
JavaRedisCodec.encode(
|
|
values,
|
|
item_java_type="xiaozhi.modules.sys.vo.SysDictDataItem",
|
|
),
|
|
ex=24 * 60 * 60,
|
|
)
|
|
return values
|
|
|
|
@staticmethod
|
|
def _client_hash(request: Request) -> str:
|
|
user_agent = request.headers.get("User-Agent", "").lower()
|
|
forwarded_headers = (
|
|
"x-forwarded-for",
|
|
"Proxy-Client-IP",
|
|
"WL-Proxy-Client-IP",
|
|
"HTTP_CLIENT_IP",
|
|
"HTTP_X_FORWARDED_FOR",
|
|
)
|
|
ip_address = next(
|
|
(
|
|
value
|
|
for header in forwarded_headers
|
|
if (value := request.headers.get(header)) and value.casefold() != "unknown"
|
|
),
|
|
request.client.host if request.client else "",
|
|
)
|
|
date = shanghai_now_naive().strftime("%Y-%m-%d")
|
|
return hashlib.md5( # noqa: S324 - Java clientHash compatibility requires MD5
|
|
f"{ip_address}{date}{user_agent}".encode(), usedforsecurity=False
|
|
).hexdigest()
|
|
|
|
@staticmethod
|
|
def _datetime(value: Any) -> datetime | None:
|
|
if value is None or isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, str):
|
|
return datetime.fromisoformat(value)
|
|
raise TypeError(f"Unsupported database datetime value: {type(value).__name__}")
|
|
|
|
@staticmethod
|
|
def _require_not_blank(value: str | None, key: str, accept_language: str | None) -> None:
|
|
if value is None or not value.strip():
|
|
raise AppError(500, validation_message(key, accept_language))
|