python-siseli 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_siseli-0.1.0 → python_siseli-0.2.0}/PKG-INFO +2 -1
- {python_siseli-0.1.0 → python_siseli-0.2.0}/pyproject.toml +2 -1
- {python_siseli-0.1.0 → python_siseli-0.2.0}/python_siseli.egg-info/PKG-INFO +2 -1
- {python_siseli-0.1.0 → python_siseli-0.2.0}/python_siseli.egg-info/SOURCES.txt +3 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/python_siseli.egg-info/requires.txt +1 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/__init__.py +2 -0
- python_siseli-0.2.0/siseli/auth.py +169 -0
- python_siseli-0.2.0/siseli/const.py +9 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/exceptions.py +20 -1
- python_siseli-0.2.0/siseli/open_auth.py +177 -0
- python_siseli-0.2.0/tests/test_auth.py +297 -0
- python_siseli-0.2.0/tests/test_open_auth.py +137 -0
- python_siseli-0.1.0/siseli/auth.py +0 -96
- python_siseli-0.1.0/siseli/const.py +0 -5
- {python_siseli-0.1.0 → python_siseli-0.2.0}/README.md +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/python_siseli.egg-info/dependency_links.txt +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/python_siseli.egg-info/top_level.txt +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/setup.cfg +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/_utils.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/alarms.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/client.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/config.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/dashboard.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/device.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/dictionary.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/history.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/__init__.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/alarm.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/auth.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/common.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/config.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/dashboard.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/device.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/dictionary.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/history.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/state.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/models/station.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/state.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/siseli/station.py +0 -0
- {python_siseli-0.1.0 → python_siseli-0.2.0}/tests/test_phase3.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-siseli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Siseli Cloud
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
9
10
|
Provides-Extra: dev
|
|
10
11
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
12
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-siseli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python SDK for Siseli Cloud"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
dependencies = [
|
|
13
13
|
"httpx>=0.27",
|
|
14
|
+
"cryptography>=42",
|
|
14
15
|
]
|
|
15
16
|
|
|
16
17
|
[project.optional-dependencies]
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-siseli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Siseli Cloud
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
9
10
|
Provides-Extra: dev
|
|
10
11
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
12
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -17,6 +17,7 @@ siseli/device.py
|
|
|
17
17
|
siseli/dictionary.py
|
|
18
18
|
siseli/exceptions.py
|
|
19
19
|
siseli/history.py
|
|
20
|
+
siseli/open_auth.py
|
|
20
21
|
siseli/state.py
|
|
21
22
|
siseli/station.py
|
|
22
23
|
siseli/models/__init__.py
|
|
@@ -30,4 +31,6 @@ siseli/models/dictionary.py
|
|
|
30
31
|
siseli/models/history.py
|
|
31
32
|
siseli/models/state.py
|
|
32
33
|
siseli/models/station.py
|
|
34
|
+
tests/test_auth.py
|
|
35
|
+
tests/test_open_auth.py
|
|
33
36
|
tests/test_phase3.py
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""python-siseli — Python SDK for Siseli Cloud."""
|
|
2
2
|
|
|
3
3
|
from .client import SiseliClient
|
|
4
|
+
from .open_auth import attach_open_auth
|
|
4
5
|
from .exceptions import ApiError, AuthenticationError, NetworkError, SiseliError, TokenExpiredError
|
|
5
6
|
from .models import (
|
|
6
7
|
Alarm,
|
|
@@ -25,6 +26,7 @@ from .models import (
|
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"SiseliClient",
|
|
29
|
+
"attach_open_auth",
|
|
28
30
|
"SiseliError",
|
|
29
31
|
"AuthenticationError",
|
|
30
32
|
"TokenExpiredError",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Authentication: login and token lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import AuthenticationError, NetworkError
|
|
12
|
+
from .models.auth import TokenInfo
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _md5(text: str) -> str:
|
|
18
|
+
# The Siseli Cloud API requires the MD5 hash of the plaintext password.
|
|
19
|
+
# This is a protocol constraint imposed by the server, not a local
|
|
20
|
+
# password-storage decision. The hash is sent over HTTPS and is never
|
|
21
|
+
# stored on disk.
|
|
22
|
+
return hashlib.md5(text.encode()).hexdigest() # noqa: S324
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_dt(value: str | None) -> datetime | None:
|
|
26
|
+
if not value:
|
|
27
|
+
return None
|
|
28
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_api_message(response: httpx.Response) -> str | None:
|
|
32
|
+
"""Extract a safe, short error message from an HTTP error response.
|
|
33
|
+
|
|
34
|
+
Tries to parse the body as JSON and returns the value of the first
|
|
35
|
+
recognised error field (``error``, ``message``, ``detail``). Falls back
|
|
36
|
+
to the first 200 characters of the raw text body so there is always
|
|
37
|
+
*something* useful in logs. Never returns passwords, tokens, or auth
|
|
38
|
+
headers — those are never present in response bodies.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
body = response.json()
|
|
42
|
+
if isinstance(body, dict):
|
|
43
|
+
for key in ("error", "message", "detail"):
|
|
44
|
+
value = body.get(key)
|
|
45
|
+
if value and isinstance(value, str):
|
|
46
|
+
return value[:200]
|
|
47
|
+
except Exception: # noqa: BLE001
|
|
48
|
+
pass
|
|
49
|
+
text = (response.text or "").strip()
|
|
50
|
+
return text[:200] if text else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Auth:
|
|
54
|
+
"""Manages credentials and the current access token.
|
|
55
|
+
|
|
56
|
+
The password is hashed with MD5 before being sent to the API, which
|
|
57
|
+
matches the behaviour observed in the browser client.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, account: str, password: str) -> None:
|
|
61
|
+
self._account = account
|
|
62
|
+
# The web client sends the MD5 hash of the plaintext password.
|
|
63
|
+
self._password_hash = _md5(password)
|
|
64
|
+
self._token_info: TokenInfo | None = None
|
|
65
|
+
|
|
66
|
+
async def login(self, http: httpx.AsyncClient) -> TokenInfo:
|
|
67
|
+
"""Authenticate and store the returned tokens.
|
|
68
|
+
|
|
69
|
+
Raises :exc:`~siseli.exceptions.AuthenticationError` on HTTP-level
|
|
70
|
+
auth failures (4xx). Raises :exc:`~siseli.exceptions.NetworkError`
|
|
71
|
+
on network / timeout errors so callers can distinguish the two cases.
|
|
72
|
+
"""
|
|
73
|
+
endpoint = "/apis/login/account"
|
|
74
|
+
_LOGGER.debug("Siseli auth: POST %s (account=%r)", endpoint, self._account)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
response = await http.post(
|
|
78
|
+
endpoint,
|
|
79
|
+
json={"account": self._account, "password": self._password_hash},
|
|
80
|
+
)
|
|
81
|
+
except httpx.TimeoutException as exc:
|
|
82
|
+
_LOGGER.warning("Siseli auth: request timed out for account=%r", self._account)
|
|
83
|
+
raise NetworkError(f"Login request timed out: {exc}") from exc
|
|
84
|
+
except httpx.HTTPError as exc:
|
|
85
|
+
_LOGGER.warning(
|
|
86
|
+
"Siseli auth: network error for account=%r: %s", self._account, exc
|
|
87
|
+
)
|
|
88
|
+
raise NetworkError(f"Login request failed: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
http_status = response.status_code
|
|
91
|
+
_LOGGER.debug(
|
|
92
|
+
"Siseli auth: response status=%d for account=%r", http_status, self._account
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# For HTTP error responses, extract a safe diagnostic message and raise
|
|
96
|
+
# AuthenticationError (4xx) or NetworkError (5xx).
|
|
97
|
+
if not response.is_success:
|
|
98
|
+
api_message = _extract_api_message(response)
|
|
99
|
+
_LOGGER.warning(
|
|
100
|
+
"Siseli auth: failed for account=%r — HTTP %d%s",
|
|
101
|
+
self._account,
|
|
102
|
+
http_status,
|
|
103
|
+
f", api_message={api_message!r}" if api_message else "",
|
|
104
|
+
)
|
|
105
|
+
diag = f"HTTP {http_status}"
|
|
106
|
+
if api_message:
|
|
107
|
+
diag = f"{diag}: {api_message}"
|
|
108
|
+
if http_status >= 500:
|
|
109
|
+
raise NetworkError(f"Login request failed: {diag}")
|
|
110
|
+
raise AuthenticationError(
|
|
111
|
+
f"Login failed: {diag}",
|
|
112
|
+
http_status=http_status,
|
|
113
|
+
api_message=api_message,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
body = response.json()
|
|
117
|
+
if body.get("code") != 0:
|
|
118
|
+
api_message = body.get("message") or body.get("error") or body.get("detail")
|
|
119
|
+
if api_message:
|
|
120
|
+
api_message = str(api_message)
|
|
121
|
+
_LOGGER.warning(
|
|
122
|
+
"Siseli auth: API returned non-zero code=%r for account=%r, message=%r",
|
|
123
|
+
body.get("code"),
|
|
124
|
+
self._account,
|
|
125
|
+
api_message,
|
|
126
|
+
)
|
|
127
|
+
raise AuthenticationError(
|
|
128
|
+
f"Login failed (code {body.get('code')}): {api_message}",
|
|
129
|
+
http_status=http_status,
|
|
130
|
+
api_message=api_message,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
data = body["data"]
|
|
134
|
+
self._token_info = TokenInfo(
|
|
135
|
+
access_token=data["accessToken"],
|
|
136
|
+
refresh_token=data["refreshToken"],
|
|
137
|
+
access_token_expires_at=_parse_dt(data.get("accessTokenWillExpiredAt")),
|
|
138
|
+
refresh_token_expires_at=_parse_dt(data.get("refreshTokenWillExpiredAt")),
|
|
139
|
+
auth_id=data.get("authId", ""),
|
|
140
|
+
account=data.get("account", self._account),
|
|
141
|
+
user_id=data.get("userId", ""),
|
|
142
|
+
)
|
|
143
|
+
_LOGGER.debug("Siseli auth: login successful for account=%r", self._account)
|
|
144
|
+
return self._token_info
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def access_token(self) -> str:
|
|
148
|
+
"""Return the current access token.
|
|
149
|
+
|
|
150
|
+
Raises :exc:`~siseli.exceptions.AuthenticationError` if not yet
|
|
151
|
+
authenticated.
|
|
152
|
+
"""
|
|
153
|
+
if self._token_info is None:
|
|
154
|
+
raise AuthenticationError("Not authenticated — call authenticate() first")
|
|
155
|
+
return self._token_info.access_token
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def token_info(self) -> TokenInfo | None:
|
|
159
|
+
"""Return token metadata, or *None* if not authenticated."""
|
|
160
|
+
return self._token_info
|
|
161
|
+
|
|
162
|
+
def is_authenticated(self) -> bool:
|
|
163
|
+
"""Return *True* when there is a valid, non-expired access token."""
|
|
164
|
+
if self._token_info is None:
|
|
165
|
+
return False
|
|
166
|
+
expires_at = self._token_info.access_token_expires_at
|
|
167
|
+
if expires_at is None:
|
|
168
|
+
return True
|
|
169
|
+
return datetime.now(UTC) < expires_at
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
BASE_URL = "https://solar.siseli.com"
|
|
2
|
+
DEFAULT_TIMEOUT = 30.0
|
|
3
|
+
DEFAULT_PAGE_SIZE = 20
|
|
4
|
+
# dataSource=1 observed in all telemetry requests in HAR captures
|
|
5
|
+
DEFAULT_DATA_SOURCE = 1
|
|
6
|
+
|
|
7
|
+
# Production Siseli Open API application credentials from the official web client.
|
|
8
|
+
SISELI_APP_ID = "rBrTRfAPXz"
|
|
9
|
+
SISELI_APP_SECRET_ENCRYPTED = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
|
|
@@ -8,7 +8,26 @@ class SiseliError(Exception):
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class AuthenticationError(SiseliError):
|
|
11
|
-
"""Raised when authentication fails.
|
|
11
|
+
"""Raised when authentication fails.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
http_status: HTTP status code returned by the server, or *None* when
|
|
15
|
+
the failure occurred before a response was received.
|
|
16
|
+
api_message: Short error message extracted from the API response body
|
|
17
|
+
(e.g. the ``message`` or ``error`` field), or *None* when
|
|
18
|
+
unavailable. Never contains passwords, tokens, or other secrets.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
http_status: int | None = None,
|
|
26
|
+
api_message: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.http_status = http_status
|
|
29
|
+
self.api_message = api_message
|
|
30
|
+
super().__init__(message)
|
|
12
31
|
|
|
13
32
|
|
|
14
33
|
class TokenExpiredError(AuthenticationError):
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Siseli Open API request signing helpers.
|
|
2
|
+
|
|
3
|
+
Every API request must carry IOT-Open-* headers that prove the caller holds a
|
|
4
|
+
valid application credential. The signing algorithm mirrors the official web
|
|
5
|
+
client (CryptoJS-based) exactly:
|
|
6
|
+
|
|
7
|
+
1. Decrypt the base64-encoded app secret with AES-128-CBC.
|
|
8
|
+
Key and IV are derived from MD5(app_id) as ASCII hex chars.
|
|
9
|
+
2. Compute a body hash: empty string for GET; SHA-256 hex for everything else.
|
|
10
|
+
3. Collect query params, inject the three open-auth fields, sort by key,
|
|
11
|
+
serialise without URL-encoding, hex-encode the UTF-8 bytes.
|
|
12
|
+
4. Sign: HMAC-SHA256(hex_string, secret) → MD5 → lowercase hex.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
from urllib.parse import parse_qs
|
|
22
|
+
|
|
23
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
24
|
+
|
|
25
|
+
from .const import SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decrypt_open_secret(app_id: str, encrypted_secret_b64: str) -> str:
|
|
32
|
+
"""Decrypt the base64-encoded app secret using AES-128-CBC.
|
|
33
|
+
|
|
34
|
+
The AES key and IV are derived from MD5(app_id).lower():
|
|
35
|
+
- first 16 hex chars → 16 ASCII bytes → AES key
|
|
36
|
+
- last 16 hex chars → 16 ASCII bytes → IV
|
|
37
|
+
|
|
38
|
+
ZeroPadding is assumed: trailing null bytes are stripped from the output.
|
|
39
|
+
"""
|
|
40
|
+
import base64
|
|
41
|
+
|
|
42
|
+
md5_hex = hashlib.md5(app_id.encode()).hexdigest() # noqa: S324
|
|
43
|
+
key = md5_hex[:16].encode("ascii")
|
|
44
|
+
iv = md5_hex[16:].encode("ascii")
|
|
45
|
+
|
|
46
|
+
encrypted_bytes = base64.b64decode(encrypted_secret_b64)
|
|
47
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
48
|
+
decryptor = cipher.decryptor()
|
|
49
|
+
plaintext = decryptor.update(encrypted_bytes) + decryptor.finalize()
|
|
50
|
+
return plaintext.rstrip(b"\x00").decode("utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sha256_hex(data: bytes) -> str:
|
|
54
|
+
"""Return the lowercase hex SHA-256 digest of *data*."""
|
|
55
|
+
return hashlib.sha256(data).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_open_headers(
|
|
59
|
+
*,
|
|
60
|
+
method: str,
|
|
61
|
+
query: str,
|
|
62
|
+
body: bytes | None,
|
|
63
|
+
app_id: str,
|
|
64
|
+
app_secret: str,
|
|
65
|
+
timezone: str,
|
|
66
|
+
nonce: str,
|
|
67
|
+
) -> dict[str, str]:
|
|
68
|
+
"""Build the IOT-Open-* headers (and IOT-Time-Zone) for a single request.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
method:
|
|
73
|
+
HTTP method ("GET", "POST", …).
|
|
74
|
+
query:
|
|
75
|
+
The raw query string portion of the URL (without the leading "?").
|
|
76
|
+
body:
|
|
77
|
+
Raw request body bytes, or *None* / empty for GET requests.
|
|
78
|
+
app_id:
|
|
79
|
+
The application ID (``IOT-Open-AppID``).
|
|
80
|
+
app_secret:
|
|
81
|
+
The *decrypted* application secret used as the HMAC key.
|
|
82
|
+
timezone:
|
|
83
|
+
The client timezone string, e.g. "UTC" or "Europe/Moscow".
|
|
84
|
+
nonce:
|
|
85
|
+
A unique string per request (UUID hex recommended).
|
|
86
|
+
"""
|
|
87
|
+
# --- body hash -----------------------------------------------------------
|
|
88
|
+
if method.upper() == "GET":
|
|
89
|
+
body_hash = ""
|
|
90
|
+
else:
|
|
91
|
+
body_hash = _sha256_hex(body if body else b"")
|
|
92
|
+
|
|
93
|
+
# --- params for the sign input -------------------------------------------
|
|
94
|
+
params: dict[str, str] = {}
|
|
95
|
+
if query:
|
|
96
|
+
for key, values in parse_qs(query, keep_blank_values=True).items():
|
|
97
|
+
params[key] = values[0]
|
|
98
|
+
|
|
99
|
+
# Remove any stale open-auth fields so they cannot influence the new sign.
|
|
100
|
+
for field in ("IOT-Open-AppID", "IOT-Open-Nonce", "IOT-Open-Sign", "IOT-Open-Body-Hash"):
|
|
101
|
+
params.pop(field, None)
|
|
102
|
+
|
|
103
|
+
params["IOT-Open-AppID"] = app_id
|
|
104
|
+
params["IOT-Open-Nonce"] = nonce
|
|
105
|
+
params["IOT-Open-Body-Hash"] = body_hash
|
|
106
|
+
|
|
107
|
+
# Sort alphabetically (case-sensitive, matching JS default sort order).
|
|
108
|
+
query_str = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
109
|
+
|
|
110
|
+
# --- sign ----------------------------------------------------------------
|
|
111
|
+
# CryptoJS: Utf8.parse(query_str) → Hex.stringify → hex_string
|
|
112
|
+
# Then: HmacSHA256(hex_string, secret) → MD5 → hex
|
|
113
|
+
hex_str = query_str.encode("utf-8").hex()
|
|
114
|
+
hmac_digest = hmac.new(
|
|
115
|
+
app_secret.encode("utf-8"),
|
|
116
|
+
hex_str.encode("utf-8"),
|
|
117
|
+
hashlib.sha256,
|
|
118
|
+
).digest()
|
|
119
|
+
sign = hashlib.md5(hmac_digest).hexdigest() # noqa: S324
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"IOT-Time-Zone": timezone,
|
|
123
|
+
"IOT-Open-AppID": app_id,
|
|
124
|
+
"IOT-Open-Nonce": nonce,
|
|
125
|
+
"IOT-Open-Body-Hash": body_hash,
|
|
126
|
+
"IOT-Open-Sign": sign,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def attach_open_auth(
|
|
131
|
+
client: Any,
|
|
132
|
+
*,
|
|
133
|
+
app_id: str = SISELI_APP_ID,
|
|
134
|
+
encrypted_secret: str = SISELI_APP_SECRET_ENCRYPTED,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Attach an httpx request hook that adds Open API signing headers.
|
|
137
|
+
|
|
138
|
+
The hook is appended to ``client._http.event_hooks["request"]`` and runs
|
|
139
|
+
before every outgoing request, injecting:
|
|
140
|
+
|
|
141
|
+
- ``IOT-Time-Zone``
|
|
142
|
+
- ``IOT-Open-AppID``
|
|
143
|
+
- ``IOT-Open-Nonce``
|
|
144
|
+
- ``IOT-Open-Body-Hash``
|
|
145
|
+
- ``IOT-Open-Sign``
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
client:
|
|
150
|
+
A :class:`~siseli.SiseliClient` instance (or any object with
|
|
151
|
+
``._http.event_hooks`` and ``._timezone``).
|
|
152
|
+
app_id:
|
|
153
|
+
Override the default production App ID.
|
|
154
|
+
encrypted_secret:
|
|
155
|
+
Override the default production encrypted secret.
|
|
156
|
+
"""
|
|
157
|
+
secret = decrypt_open_secret(app_id, encrypted_secret)
|
|
158
|
+
timezone = getattr(client, "_timezone", "UTC")
|
|
159
|
+
|
|
160
|
+
async def _sign_request(request: "httpx.Request") -> None:
|
|
161
|
+
nonce = uuid.uuid4().hex
|
|
162
|
+
query = request.url.query
|
|
163
|
+
if isinstance(query, bytes):
|
|
164
|
+
query = query.decode("utf-8")
|
|
165
|
+
body = request.content if request.method.upper() != "GET" else None
|
|
166
|
+
headers = build_open_headers(
|
|
167
|
+
method=request.method,
|
|
168
|
+
query=query,
|
|
169
|
+
body=body,
|
|
170
|
+
app_id=app_id,
|
|
171
|
+
app_secret=secret,
|
|
172
|
+
timezone=timezone,
|
|
173
|
+
nonce=nonce,
|
|
174
|
+
)
|
|
175
|
+
request.headers.update(headers)
|
|
176
|
+
|
|
177
|
+
client._http.event_hooks["request"].append(_sign_request)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Tests for diagnostic logging and error handling in Auth.login()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from siseli.auth import Auth, _extract_api_message
|
|
9
|
+
from siseli.exceptions import AuthenticationError, NetworkError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Helpers
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
_GOOD_BODY = {
|
|
17
|
+
"code": 0,
|
|
18
|
+
"data": {
|
|
19
|
+
"accessToken": "tok-access",
|
|
20
|
+
"refreshToken": "tok-refresh",
|
|
21
|
+
"accessTokenWillExpiredAt": None,
|
|
22
|
+
"refreshTokenWillExpiredAt": None,
|
|
23
|
+
"authId": "auth-1",
|
|
24
|
+
"account": "user@example.com",
|
|
25
|
+
"userId": "uid-1",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_response(status_code: int, json_body: object | None = None, text: str = "") -> httpx.Response:
|
|
31
|
+
"""Build a minimal fake httpx.Response."""
|
|
32
|
+
import json as _json
|
|
33
|
+
|
|
34
|
+
if json_body is not None:
|
|
35
|
+
content = _json.dumps(json_body).encode()
|
|
36
|
+
headers = {"content-type": "application/json"}
|
|
37
|
+
else:
|
|
38
|
+
content = text.encode()
|
|
39
|
+
headers = {"content-type": "text/plain"}
|
|
40
|
+
|
|
41
|
+
return httpx.Response(
|
|
42
|
+
status_code=status_code,
|
|
43
|
+
content=content,
|
|
44
|
+
headers=headers,
|
|
45
|
+
request=httpx.Request("POST", "https://example.com/apis/login/account"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _FakeAsyncClient:
|
|
50
|
+
"""Minimal async HTTP client stub."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, response: httpx.Response | Exception) -> None:
|
|
53
|
+
self._response = response
|
|
54
|
+
self.last_request_json: dict | None = None
|
|
55
|
+
|
|
56
|
+
async def post(self, url: str, *, json: dict | None = None, **_: object) -> httpx.Response:
|
|
57
|
+
self.last_request_json = json
|
|
58
|
+
if isinstance(self._response, Exception):
|
|
59
|
+
raise self._response
|
|
60
|
+
return self._response
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# _extract_api_message
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_extract_api_message_json_error_field() -> None:
|
|
69
|
+
resp = _make_response(401, json_body={"error": "invalid_credentials"})
|
|
70
|
+
assert _extract_api_message(resp) == "invalid_credentials"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_extract_api_message_json_message_field() -> None:
|
|
74
|
+
resp = _make_response(401, json_body={"message": "Bad password"})
|
|
75
|
+
assert _extract_api_message(resp) == "Bad password"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_extract_api_message_json_detail_field() -> None:
|
|
79
|
+
resp = _make_response(403, json_body={"detail": "Account locked"})
|
|
80
|
+
assert _extract_api_message(resp) == "Account locked"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_extract_api_message_prefers_error_over_message() -> None:
|
|
84
|
+
resp = _make_response(401, json_body={"error": "first", "message": "second"})
|
|
85
|
+
assert _extract_api_message(resp) == "first"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_extract_api_message_plain_text_fallback() -> None:
|
|
89
|
+
resp = _make_response(500, text="Internal Server Error")
|
|
90
|
+
assert _extract_api_message(resp) == "Internal Server Error"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_extract_api_message_truncates_long_text() -> None:
|
|
94
|
+
long_text = "x" * 300
|
|
95
|
+
resp = _make_response(500, text=long_text)
|
|
96
|
+
result = _extract_api_message(resp)
|
|
97
|
+
assert result is not None
|
|
98
|
+
assert len(result) == 200
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_extract_api_message_empty_body() -> None:
|
|
102
|
+
resp = _make_response(401, text="")
|
|
103
|
+
assert _extract_api_message(resp) is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Auth.login — HTTP error status codes
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_login_401_raises_authentication_error_with_status() -> None:
|
|
113
|
+
response = _make_response(401, json_body={"error": "invalid_credentials"})
|
|
114
|
+
client = _FakeAsyncClient(response)
|
|
115
|
+
|
|
116
|
+
auth = Auth("user@example.com", "secret")
|
|
117
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
118
|
+
await auth.login(client)
|
|
119
|
+
|
|
120
|
+
err = exc_info.value
|
|
121
|
+
assert err.http_status == 401
|
|
122
|
+
assert err.api_message == "invalid_credentials"
|
|
123
|
+
assert "401" in str(err)
|
|
124
|
+
assert "invalid_credentials" in str(err)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_login_403_raises_authentication_error() -> None:
|
|
129
|
+
response = _make_response(403, json_body={"message": "Account disabled"})
|
|
130
|
+
client = _FakeAsyncClient(response)
|
|
131
|
+
|
|
132
|
+
auth = Auth("user@example.com", "secret")
|
|
133
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
134
|
+
await auth.login(client)
|
|
135
|
+
|
|
136
|
+
err = exc_info.value
|
|
137
|
+
assert err.http_status == 403
|
|
138
|
+
assert err.api_message == "Account disabled"
|
|
139
|
+
assert "403" in str(err)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_login_429_raises_authentication_error() -> None:
|
|
144
|
+
response = _make_response(429, json_body={"message": "Too Many Requests"})
|
|
145
|
+
client = _FakeAsyncClient(response)
|
|
146
|
+
|
|
147
|
+
auth = Auth("user@example.com", "secret")
|
|
148
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
149
|
+
await auth.login(client)
|
|
150
|
+
|
|
151
|
+
err = exc_info.value
|
|
152
|
+
assert err.http_status == 429
|
|
153
|
+
assert err.api_message == "Too Many Requests"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.asyncio
|
|
157
|
+
async def test_login_5xx_raises_network_error() -> None:
|
|
158
|
+
response = _make_response(503, json_body={"error": "Service unavailable"})
|
|
159
|
+
client = _FakeAsyncClient(response)
|
|
160
|
+
|
|
161
|
+
auth = Auth("user@example.com", "secret")
|
|
162
|
+
with pytest.raises(NetworkError) as exc_info:
|
|
163
|
+
await auth.login(client)
|
|
164
|
+
|
|
165
|
+
assert "503" in str(exc_info.value)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.asyncio
|
|
169
|
+
async def test_login_500_raises_network_error_not_auth_error() -> None:
|
|
170
|
+
response = _make_response(500, text="Internal Server Error")
|
|
171
|
+
client = _FakeAsyncClient(response)
|
|
172
|
+
|
|
173
|
+
auth = Auth("user@example.com", "secret")
|
|
174
|
+
with pytest.raises(NetworkError):
|
|
175
|
+
await auth.login(client)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Auth.login — network / timeout errors
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.mark.asyncio
|
|
184
|
+
async def test_login_timeout_raises_network_error() -> None:
|
|
185
|
+
client = _FakeAsyncClient(httpx.TimeoutException("timed out"))
|
|
186
|
+
|
|
187
|
+
auth = Auth("user@example.com", "secret")
|
|
188
|
+
with pytest.raises(NetworkError) as exc_info:
|
|
189
|
+
await auth.login(client)
|
|
190
|
+
|
|
191
|
+
assert "timed out" in str(exc_info.value).lower() or "timeout" in str(exc_info.value).lower()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_login_connection_error_raises_network_error() -> None:
|
|
196
|
+
client = _FakeAsyncClient(httpx.ConnectError("connection refused"))
|
|
197
|
+
|
|
198
|
+
auth = Auth("user@example.com", "secret")
|
|
199
|
+
with pytest.raises(NetworkError):
|
|
200
|
+
await auth.login(client)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Auth.login — non-zero API code (HTTP 200 but code != 0)
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.asyncio
|
|
209
|
+
async def test_login_api_nonzero_code_raises_auth_error() -> None:
|
|
210
|
+
response = _make_response(200, json_body={"code": 10001, "message": "Wrong credentials"})
|
|
211
|
+
client = _FakeAsyncClient(response)
|
|
212
|
+
|
|
213
|
+
auth = Auth("user@example.com", "secret")
|
|
214
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
215
|
+
await auth.login(client)
|
|
216
|
+
|
|
217
|
+
err = exc_info.value
|
|
218
|
+
assert err.http_status == 200
|
|
219
|
+
assert err.api_message == "Wrong credentials"
|
|
220
|
+
assert "10001" in str(err)
|
|
221
|
+
assert "Wrong credentials" in str(err)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Auth.login — no secrets in error messages
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.asyncio
|
|
230
|
+
async def test_login_error_message_does_not_contain_password() -> None:
|
|
231
|
+
response = _make_response(401, json_body={"error": "invalid_credentials"})
|
|
232
|
+
client = _FakeAsyncClient(response)
|
|
233
|
+
|
|
234
|
+
password = "s3cr3tpassword"
|
|
235
|
+
auth = Auth("user@example.com", password)
|
|
236
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
237
|
+
await auth.login(client)
|
|
238
|
+
|
|
239
|
+
assert password not in str(exc_info.value)
|
|
240
|
+
# MD5 hash should not appear either (it's the sent value, not in response)
|
|
241
|
+
import hashlib
|
|
242
|
+
md5_hash = hashlib.md5(password.encode()).hexdigest() # noqa: S324
|
|
243
|
+
assert md5_hash not in str(exc_info.value)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@pytest.mark.asyncio
|
|
247
|
+
async def test_login_request_does_not_send_plaintext_password() -> None:
|
|
248
|
+
"""Verify the login request sends MD5 hash, not the plaintext password."""
|
|
249
|
+
response = _make_response(200, json_body=_GOOD_BODY)
|
|
250
|
+
client = _FakeAsyncClient(response)
|
|
251
|
+
|
|
252
|
+
password = "plaintext_password"
|
|
253
|
+
auth = Auth("user@example.com", password)
|
|
254
|
+
await auth.login(client)
|
|
255
|
+
|
|
256
|
+
assert client.last_request_json is not None
|
|
257
|
+
assert client.last_request_json.get("password") != password
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Auth.login — successful login
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.asyncio
|
|
266
|
+
async def test_login_success_returns_token_info() -> None:
|
|
267
|
+
response = _make_response(200, json_body=_GOOD_BODY)
|
|
268
|
+
client = _FakeAsyncClient(response)
|
|
269
|
+
|
|
270
|
+
auth = Auth("user@example.com", "secret")
|
|
271
|
+
token_info = await auth.login(client)
|
|
272
|
+
|
|
273
|
+
assert token_info.access_token == "tok-access"
|
|
274
|
+
assert token_info.refresh_token == "tok-refresh"
|
|
275
|
+
assert auth.is_authenticated()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# AuthenticationError attributes
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_authentication_error_attributes() -> None:
|
|
284
|
+
err = AuthenticationError(
|
|
285
|
+
"Login failed: HTTP 401: invalid_credentials",
|
|
286
|
+
http_status=401,
|
|
287
|
+
api_message="invalid_credentials",
|
|
288
|
+
)
|
|
289
|
+
assert err.http_status == 401
|
|
290
|
+
assert err.api_message == "invalid_credentials"
|
|
291
|
+
assert "401" in str(err)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_authentication_error_defaults() -> None:
|
|
295
|
+
err = AuthenticationError("Not authenticated")
|
|
296
|
+
assert err.http_status is None
|
|
297
|
+
assert err.api_message is None
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tests for Siseli Open API signing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from siseli.const import SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED
|
|
11
|
+
from siseli.open_auth import (
|
|
12
|
+
_sha256_hex,
|
|
13
|
+
attach_open_auth,
|
|
14
|
+
build_open_headers,
|
|
15
|
+
decrypt_open_secret,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _MockHttpClient:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.event_hooks = {"request": []}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _MockSiseliClient:
|
|
25
|
+
_timezone = "Europe/Moscow"
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._http = _MockHttpClient()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_decrypt_open_secret_with_app_id() -> None:
|
|
32
|
+
"""Encrypted Open secret is decrypted with AppID-derived key/IV."""
|
|
33
|
+
assert (
|
|
34
|
+
decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
35
|
+
== "CJbrtLtqFES62bJ3ZW7c"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get_request_signing_uses_empty_body_hash() -> None:
|
|
40
|
+
"""GET requests must sign with an empty body hash."""
|
|
41
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
42
|
+
headers = build_open_headers(
|
|
43
|
+
method="GET",
|
|
44
|
+
query="b=2&a=1",
|
|
45
|
+
body=None,
|
|
46
|
+
app_id=SISELI_APP_ID,
|
|
47
|
+
app_secret=secret,
|
|
48
|
+
timezone="UTC",
|
|
49
|
+
nonce="nonce-123",
|
|
50
|
+
)
|
|
51
|
+
assert headers["IOT-Open-Body-Hash"] == ""
|
|
52
|
+
assert headers["IOT-Open-Sign"] == "bee1c1c32629baf72be698fbe53fdc11"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_post_request_signing_with_json_body() -> None:
|
|
56
|
+
"""POST requests must hash and sign the raw JSON body bytes."""
|
|
57
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
58
|
+
body = b'{"name":"abc","value":123}'
|
|
59
|
+
headers = build_open_headers(
|
|
60
|
+
method="POST",
|
|
61
|
+
query="z=9",
|
|
62
|
+
body=body,
|
|
63
|
+
app_id=SISELI_APP_ID,
|
|
64
|
+
app_secret=secret,
|
|
65
|
+
timezone="UTC",
|
|
66
|
+
nonce="nonce-123",
|
|
67
|
+
)
|
|
68
|
+
assert headers["IOT-Open-Body-Hash"] == hashlib.sha256(body).hexdigest()
|
|
69
|
+
assert headers["IOT-Open-Sign"] == "1f3525cce7ea5fa378409070c17f0076"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_unicode_json_body_signing() -> None:
|
|
73
|
+
"""Unicode body content must be hashed/signatured as UTF-8 bytes."""
|
|
74
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
75
|
+
body = '{"text":"Привет 🌞"}'.encode()
|
|
76
|
+
headers = build_open_headers(
|
|
77
|
+
method="POST",
|
|
78
|
+
query="",
|
|
79
|
+
body=body,
|
|
80
|
+
app_id=SISELI_APP_ID,
|
|
81
|
+
app_secret=secret,
|
|
82
|
+
timezone="UTC",
|
|
83
|
+
nonce="nonce-123",
|
|
84
|
+
)
|
|
85
|
+
assert headers["IOT-Open-Body-Hash"] == hashlib.sha256(body).hexdigest()
|
|
86
|
+
assert headers["IOT-Open-Sign"] == "8d4c8f2771f9f7f419fd4cbec1d0f1e6"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_empty_post_body_signing() -> None:
|
|
90
|
+
"""Empty POST body must use SHA256 of empty bytes."""
|
|
91
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
92
|
+
headers = build_open_headers(
|
|
93
|
+
method="POST",
|
|
94
|
+
query="x=",
|
|
95
|
+
body=b"",
|
|
96
|
+
app_id=SISELI_APP_ID,
|
|
97
|
+
app_secret=secret,
|
|
98
|
+
timezone="UTC",
|
|
99
|
+
nonce="nonce-123",
|
|
100
|
+
)
|
|
101
|
+
assert (
|
|
102
|
+
headers["IOT-Open-Body-Hash"]
|
|
103
|
+
== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
104
|
+
)
|
|
105
|
+
assert headers["IOT-Open-Sign"] == "9f8506bc2a8f3da76eebc4f99691d271"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_header_generation_includes_required_iot_open_values() -> None:
|
|
110
|
+
"""Request hook injects required IOT-Open-* and timezone headers."""
|
|
111
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
112
|
+
|
|
113
|
+
client = _MockSiseliClient()
|
|
114
|
+
attach_open_auth(client)
|
|
115
|
+
assert len(client._http.event_hooks["request"]) == 1
|
|
116
|
+
|
|
117
|
+
request = httpx.Request(
|
|
118
|
+
"POST",
|
|
119
|
+
"https://solar.siseli.com/apis/login/account",
|
|
120
|
+
content=b'{"account":"u","password":"p"}',
|
|
121
|
+
)
|
|
122
|
+
await client._http.event_hooks["request"][0](request)
|
|
123
|
+
|
|
124
|
+
for key in (
|
|
125
|
+
"IOT-Time-Zone",
|
|
126
|
+
"IOT-Open-AppID",
|
|
127
|
+
"IOT-Open-Nonce",
|
|
128
|
+
"IOT-Open-Body-Hash",
|
|
129
|
+
"IOT-Open-Sign",
|
|
130
|
+
):
|
|
131
|
+
assert key in request.headers
|
|
132
|
+
assert request.headers["IOT-Time-Zone"] == "Europe/Moscow"
|
|
133
|
+
assert request.headers["IOT-Open-AppID"] == SISELI_APP_ID
|
|
134
|
+
expected_hash = _sha256_hex(request.content)
|
|
135
|
+
assert request.headers["IOT-Open-Body-Hash"] == expected_hash
|
|
136
|
+
assert request.headers["IOT-Open-Sign"]
|
|
137
|
+
assert request.headers["IOT-Open-Sign"] != secret
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""Authentication: login and token lifecycle management."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
from datetime import UTC, datetime
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
|
-
from .exceptions import AuthenticationError
|
|
11
|
-
from .models.auth import TokenInfo
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _md5(text: str) -> str:
|
|
15
|
-
# The Siseli Cloud API requires the MD5 hash of the plaintext password.
|
|
16
|
-
# This is a protocol constraint imposed by the server, not a local
|
|
17
|
-
# password-storage decision. The hash is sent over HTTPS and is never
|
|
18
|
-
# stored on disk.
|
|
19
|
-
return hashlib.md5(text.encode()).hexdigest() # noqa: S324
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _parse_dt(value: str | None) -> datetime | None:
|
|
23
|
-
if not value:
|
|
24
|
-
return None
|
|
25
|
-
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Auth:
|
|
29
|
-
"""Manages credentials and the current access token.
|
|
30
|
-
|
|
31
|
-
The password is hashed with MD5 before being sent to the API, which
|
|
32
|
-
matches the behaviour observed in the browser client.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
def __init__(self, account: str, password: str) -> None:
|
|
36
|
-
self._account = account
|
|
37
|
-
# The web client sends the MD5 hash of the plaintext password.
|
|
38
|
-
self._password_hash = _md5(password)
|
|
39
|
-
self._token_info: TokenInfo | None = None
|
|
40
|
-
|
|
41
|
-
async def login(self, http: httpx.AsyncClient) -> TokenInfo:
|
|
42
|
-
"""Authenticate and store the returned tokens.
|
|
43
|
-
|
|
44
|
-
Raises :exc:`~siseli.exceptions.AuthenticationError` on failure.
|
|
45
|
-
"""
|
|
46
|
-
try:
|
|
47
|
-
response = await http.post(
|
|
48
|
-
"/apis/login/account",
|
|
49
|
-
json={"account": self._account, "password": self._password_hash},
|
|
50
|
-
)
|
|
51
|
-
response.raise_for_status()
|
|
52
|
-
except httpx.HTTPError as exc:
|
|
53
|
-
raise AuthenticationError(f"Login request failed: {exc}") from exc
|
|
54
|
-
|
|
55
|
-
body = response.json()
|
|
56
|
-
if body.get("code") != 0:
|
|
57
|
-
raise AuthenticationError(
|
|
58
|
-
f"Login failed (code {body.get('code')}): {body.get('message')}"
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
data = body["data"]
|
|
62
|
-
self._token_info = TokenInfo(
|
|
63
|
-
access_token=data["accessToken"],
|
|
64
|
-
refresh_token=data["refreshToken"],
|
|
65
|
-
access_token_expires_at=_parse_dt(data.get("accessTokenWillExpiredAt")),
|
|
66
|
-
refresh_token_expires_at=_parse_dt(data.get("refreshTokenWillExpiredAt")),
|
|
67
|
-
auth_id=data.get("authId", ""),
|
|
68
|
-
account=data.get("account", self._account),
|
|
69
|
-
user_id=data.get("userId", ""),
|
|
70
|
-
)
|
|
71
|
-
return self._token_info
|
|
72
|
-
|
|
73
|
-
@property
|
|
74
|
-
def access_token(self) -> str:
|
|
75
|
-
"""Return the current access token.
|
|
76
|
-
|
|
77
|
-
Raises :exc:`~siseli.exceptions.AuthenticationError` if not yet
|
|
78
|
-
authenticated.
|
|
79
|
-
"""
|
|
80
|
-
if self._token_info is None:
|
|
81
|
-
raise AuthenticationError("Not authenticated — call authenticate() first")
|
|
82
|
-
return self._token_info.access_token
|
|
83
|
-
|
|
84
|
-
@property
|
|
85
|
-
def token_info(self) -> TokenInfo | None:
|
|
86
|
-
"""Return token metadata, or *None* if not authenticated."""
|
|
87
|
-
return self._token_info
|
|
88
|
-
|
|
89
|
-
def is_authenticated(self) -> bool:
|
|
90
|
-
"""Return *True* when there is a valid, non-expired access token."""
|
|
91
|
-
if self._token_info is None:
|
|
92
|
-
return False
|
|
93
|
-
expires_at = self._token_info.access_token_expires_at
|
|
94
|
-
if expires_at is None:
|
|
95
|
-
return True
|
|
96
|
-
return datetime.now(UTC) < expires_at
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|