pyedookit 0.3.2__tar.gz → 0.4.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.
- {pyedookit-0.3.2 → pyedookit-0.4.0}/PKG-INFO +13 -8
- {pyedookit-0.3.2 → pyedookit-0.4.0}/README.md +12 -7
- {pyedookit-0.3.2 → pyedookit-0.4.0}/pyproject.toml +1 -1
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/__init__.py +2 -0
- pyedookit-0.4.0/src/pyedookit/auth.py +206 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/client.py +54 -37
- pyedookit-0.4.0/src/pyedookit/session.py +87 -0
- pyedookit-0.4.0/src/pyedookit/transport.py +229 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fakes.py +13 -2
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_auth.py +45 -30
- pyedookit-0.4.0/tests/test_client.py +246 -0
- pyedookit-0.4.0/tests/test_session.py +76 -0
- pyedookit-0.4.0/tests/test_transport.py +184 -0
- pyedookit-0.3.2/src/pyedookit/auth.py +0 -379
- pyedookit-0.3.2/src/pyedookit/session.py +0 -70
- pyedookit-0.3.2/tests/test_client.py +0 -125
- pyedookit-0.3.2/tests/test_session.py +0 -54
- {pyedookit-0.3.2 → pyedookit-0.4.0}/.gitignore +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/LICENSE +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/exceptions.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/models.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/__init__.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/dates.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/events.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/fingerprint.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/payments.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/timetable.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/py.typed +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/__init__.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fixtures/payments_structure.html +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fixtures/timetable_real_structure.html +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fixtures/upcoming_events_structure.html +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_dates.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_events_parser.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_fingerprint.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_payments_parser.py +0 -0
- {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_timetable_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyedookit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Async client for the Edookit parent portal (edookit.net)
|
|
5
5
|
Project-URL: Homepage, https://github.com/TFoxik/pyedookit
|
|
6
6
|
Author: TFoxik
|
|
@@ -70,17 +70,22 @@ asyncio.run(main())
|
|
|
70
70
|
### Sessions
|
|
71
71
|
|
|
72
72
|
The password is only used by `login()`. Store `session.to_json()` instead,
|
|
73
|
-
and treat it like a password: it holds the Plus4U
|
|
74
|
-
|
|
73
|
+
and treat it like a password: it holds the Plus4U sign-in, which lasts about
|
|
74
|
+
19 days (`session.plus4u_expires_at` tells you when, or is `None` if that's
|
|
75
|
+
unknown or already past). Load it with
|
|
76
|
+
`Session.from_json()`; sessions stored by 0.3.x still load.
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
`
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
`EdookitClient` renews the short-lived edookit session by itself and
|
|
79
|
+
retries once. Pass `on_session_update` to persist each renewed session:
|
|
80
|
+
|
|
81
|
+
client = EdookitClient(session, on_session_update=lambda s: save(s.to_json()))
|
|
82
|
+
|
|
83
|
+
When the Plus4U sign-in has expired too, calls raise `AuthError` and you
|
|
84
|
+
need `login()` again.
|
|
80
85
|
|
|
81
86
|
| Exception | Meaning | What to do |
|
|
82
87
|
|---|---|---|
|
|
83
|
-
| `SessionExpired` |
|
|
88
|
+
| `SessionExpired` | still expired right after a renewal | retry later |
|
|
84
89
|
| `AuthError` | bad credentials, or the Plus4U cookie expired | `login()` |
|
|
85
90
|
| `EdookitConnectionError` | network error or 30 s timeout | retry later |
|
|
86
91
|
| `ParseError` | the portal's markup changed | the parser needs updating |
|
|
@@ -48,17 +48,22 @@ asyncio.run(main())
|
|
|
48
48
|
### Sessions
|
|
49
49
|
|
|
50
50
|
The password is only used by `login()`. Store `session.to_json()` instead,
|
|
51
|
-
and treat it like a password: it holds the Plus4U
|
|
52
|
-
|
|
51
|
+
and treat it like a password: it holds the Plus4U sign-in, which lasts about
|
|
52
|
+
19 days (`session.plus4u_expires_at` tells you when, or is `None` if that's
|
|
53
|
+
unknown or already past). Load it with
|
|
54
|
+
`Session.from_json()`; sessions stored by 0.3.x still load.
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
`
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
`EdookitClient` renews the short-lived edookit session by itself and
|
|
57
|
+
retries once. Pass `on_session_update` to persist each renewed session:
|
|
58
|
+
|
|
59
|
+
client = EdookitClient(session, on_session_update=lambda s: save(s.to_json()))
|
|
60
|
+
|
|
61
|
+
When the Plus4U sign-in has expired too, calls raise `AuthError` and you
|
|
62
|
+
need `login()` again.
|
|
58
63
|
|
|
59
64
|
| Exception | Meaning | What to do |
|
|
60
65
|
|---|---|---|
|
|
61
|
-
| `SessionExpired` |
|
|
66
|
+
| `SessionExpired` | still expired right after a renewal | retry later |
|
|
62
67
|
| `AuthError` | bad credentials, or the Plus4U cookie expired | `login()` |
|
|
63
68
|
| `EdookitConnectionError` | network error or 30 s timeout | retry later |
|
|
64
69
|
| `ParseError` | the portal's markup changed | the parser needs updating |
|
|
@@ -10,11 +10,13 @@ from .exceptions import (
|
|
|
10
10
|
from .models import PORTAL_TZ, Lesson, PaymentRequest, Payments, SchoolEvent, Term
|
|
11
11
|
from .parsers.fingerprint import content_fingerprint
|
|
12
12
|
from .session import Session
|
|
13
|
+
from .transport import CookieStore
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
15
16
|
"CAPTURE_PAGES",
|
|
16
17
|
"PORTAL_TZ",
|
|
17
18
|
"AuthError",
|
|
19
|
+
"CookieStore",
|
|
18
20
|
"EdookitClient",
|
|
19
21
|
"EdookitConnectionError",
|
|
20
22
|
"EdookitError",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
from yarl import URL
|
|
9
|
+
|
|
10
|
+
from . import transport
|
|
11
|
+
from .exceptions import AuthError
|
|
12
|
+
from .session import PLUS4U_HOST, Session, school_base, school_host, validate_school
|
|
13
|
+
from .transport import CookieStore, Response
|
|
14
|
+
|
|
15
|
+
_PLUS4U_OIDC_BASE = (
|
|
16
|
+
"https://uuidentity.plus4u.net/uu-oidc-maing02/bb977a99f4cc4c37a2afce3fd599d0a7"
|
|
17
|
+
)
|
|
18
|
+
_AUTH_URL = f"{_PLUS4U_OIDC_BASE}/oidc/auth"
|
|
19
|
+
_AUTH_PASSWORD_URL = f"{_PLUS4U_OIDC_BASE}/authPassword/authenticate"
|
|
20
|
+
_REALM_CODE = "uuIdentityPasswordAuthNRealm"
|
|
21
|
+
|
|
22
|
+
# Confirmed against a real portal: the OIDC client_id is per-school, not a
|
|
23
|
+
# single value shared across every Edookit install (third-party docs this
|
|
24
|
+
# was originally inferred from implied otherwise). Each school's /user/login
|
|
25
|
+
# page embeds it in a small inline JS config block, e.g.
|
|
26
|
+
# uu_app_oidc_providers_oidcg02_client_id: "983960dcb7b84b34b9aecc5194381d56"
|
|
27
|
+
# so it has to be scraped fresh per school rather than hardcoded.
|
|
28
|
+
_CLIENT_ID_RE = re.compile(
|
|
29
|
+
r"uu_app_oidc_providers_oidcg02_client_id\s*:\s*['\"]([^'\"]+)['\"]"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Edookit sends the browser back to one of these paths when a session has
|
|
33
|
+
# expired or was never valid; shared with client.py so "did we get logged
|
|
34
|
+
# out" is checked the same way everywhere.
|
|
35
|
+
LOGIN_MARKERS = ("/user/login", "/user/oidc")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_login_url(url: str) -> bool:
|
|
39
|
+
return any(marker in url for marker in LOGIN_MARKERS)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _callback_url(school: str) -> str:
|
|
43
|
+
return f"{school_base(school)}/user/oidc-login-callback"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _auth_params(
|
|
47
|
+
school: str, client_id: str, state: str, *, prompt_none: bool = False
|
|
48
|
+
) -> dict[str, str]:
|
|
49
|
+
params = {
|
|
50
|
+
"response_type": "code",
|
|
51
|
+
"client_id": client_id,
|
|
52
|
+
"redirect_uri": _callback_url(school),
|
|
53
|
+
"scope": "openid",
|
|
54
|
+
"state": state,
|
|
55
|
+
}
|
|
56
|
+
if prompt_none:
|
|
57
|
+
params["prompt"] = "none"
|
|
58
|
+
return params
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _discover_client_id(
|
|
62
|
+
http: aiohttp.ClientSession, school: str, cookies: CookieStore
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Scrape the school's own /user/login page for its Plus4U client_id.
|
|
65
|
+
|
|
66
|
+
Its Set-Cookie response - PHPSESSID etc. - lands in `cookies` and so
|
|
67
|
+
joins the rest of the login flow, rather than being discarded.
|
|
68
|
+
"""
|
|
69
|
+
result = await transport.request(http, "GET", f"{school_base(school)}/user/login", cookies)
|
|
70
|
+
match = _CLIENT_ID_RE.search(result.text)
|
|
71
|
+
if not match:
|
|
72
|
+
raise AuthError(f"could not find a Plus4U client_id on {school}'s login page")
|
|
73
|
+
return match.group(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _session_from_cookies(school: str, cookies: CookieStore) -> Session:
|
|
77
|
+
if not cookies.get(school_host(school), "PHPSESSID"):
|
|
78
|
+
raise AuthError("login flow completed but no edookit session cookie was set")
|
|
79
|
+
return Session(school=school, cookies=cookies)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _finish_callback(
|
|
83
|
+
http: aiohttp.ClientSession, callback_url: str, cookies: CookieStore, school: str
|
|
84
|
+
) -> Session:
|
|
85
|
+
"""Follow the final OIDC callback redirect chain and build a Session from it.
|
|
86
|
+
|
|
87
|
+
Shared tail of login() and refresh(): both end with "GET the code=
|
|
88
|
+
callback, make sure we didn't land back on the login page, then read the
|
|
89
|
+
cookies that piled up along the way."
|
|
90
|
+
|
|
91
|
+
Confirmed against a real browser trace (HAR): after the callback GET
|
|
92
|
+
resolves (still 200 on /oidc/callback), the browser makes one more GET
|
|
93
|
+
to the portal root, which is what actually settles the session server
|
|
94
|
+
side - skipping it leaves the account looking like it has almost no
|
|
95
|
+
modules enabled (only the dashboard and badges), even though login
|
|
96
|
+
itself succeeded and the session cookies are valid.
|
|
97
|
+
"""
|
|
98
|
+
result = await transport.request(http, "GET", callback_url, cookies, follow_redirects=True)
|
|
99
|
+
if is_login_url(result.url):
|
|
100
|
+
raise AuthError("edookit rejected the Plus4U authorization code")
|
|
101
|
+
settle = await transport.request(
|
|
102
|
+
http, "GET", school_base(school), cookies, follow_redirects=True
|
|
103
|
+
)
|
|
104
|
+
if is_login_url(settle.url):
|
|
105
|
+
raise AuthError("session did not settle after the OIDC callback")
|
|
106
|
+
return _session_from_cookies(school, cookies)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def probe_client_id(school: str) -> str:
|
|
110
|
+
"""Discover a school's Plus4U client_id without logging in.
|
|
111
|
+
|
|
112
|
+
Used by scripts/capture_fixtures.py to show what login()/refresh() will
|
|
113
|
+
use for this school before spending the user's password.
|
|
114
|
+
"""
|
|
115
|
+
validate_school(school)
|
|
116
|
+
async with transport.http_session(None) as http:
|
|
117
|
+
return await _discover_client_id(http, school, CookieStore())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _authorize(
|
|
121
|
+
http: aiohttp.ClientSession, school: str, cookies: CookieStore, *, prompt_none: bool = False
|
|
122
|
+
) -> tuple[str, str, Response]:
|
|
123
|
+
"""Start the OIDC authorization: shared opening of login() and refresh()."""
|
|
124
|
+
client_id = await _discover_client_id(http, school, cookies)
|
|
125
|
+
state = secrets.token_urlsafe(16)
|
|
126
|
+
params = _auth_params(school, client_id, state, prompt_none=prompt_none)
|
|
127
|
+
return client_id, state, await transport.request(
|
|
128
|
+
http, "GET", _AUTH_URL, cookies, params=params
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def login(
|
|
133
|
+
school: str,
|
|
134
|
+
username: str,
|
|
135
|
+
password: str,
|
|
136
|
+
*,
|
|
137
|
+
http: aiohttp.ClientSession | None = None,
|
|
138
|
+
) -> Session:
|
|
139
|
+
"""Run the Plus4U OIDC password login and return a usable Session.
|
|
140
|
+
|
|
141
|
+
Raises AuthError if the credentials are rejected or the flow's shape has
|
|
142
|
+
changed (e.g. Edookit or Plus4U altered a redirect), and
|
|
143
|
+
EdookitConnectionError if either host can't be reached.
|
|
144
|
+
|
|
145
|
+
A passed-in `http` session must not keep its own cookies (use
|
|
146
|
+
`aiohttp.DummyCookieJar()`): cookies are tracked per host by hand here,
|
|
147
|
+
and a real jar would also collect and resend them.
|
|
148
|
+
"""
|
|
149
|
+
validate_school(school)
|
|
150
|
+
cookies = CookieStore()
|
|
151
|
+
async with transport.http_session(http) as http:
|
|
152
|
+
client_id, state, step1 = await _authorize(http, school, cookies)
|
|
153
|
+
if not step1.is_redirect or not step1.location:
|
|
154
|
+
raise AuthError(f"expected a redirect from Plus4U auth endpoint, got {step1.status}")
|
|
155
|
+
login_url = URL(step1.location)
|
|
156
|
+
server_state = login_url.query.get("state", state)
|
|
157
|
+
server_client_id = login_url.query.get("clientId", client_id)
|
|
158
|
+
|
|
159
|
+
payload = {
|
|
160
|
+
"clientId": server_client_id,
|
|
161
|
+
"state": server_state,
|
|
162
|
+
"rememberMe": True,
|
|
163
|
+
"realmCode": _REALM_CODE,
|
|
164
|
+
"username": username,
|
|
165
|
+
"password": password,
|
|
166
|
+
}
|
|
167
|
+
step2 = await transport.request(http, "POST", _AUTH_PASSWORD_URL, cookies, json=payload)
|
|
168
|
+
if step2.status in (400, 401):
|
|
169
|
+
raise AuthError("Plus4U rejected the username or password")
|
|
170
|
+
continue_url = step2.location
|
|
171
|
+
if not continue_url and step2.text:
|
|
172
|
+
try:
|
|
173
|
+
body = _json.loads(step2.text)
|
|
174
|
+
except ValueError:
|
|
175
|
+
body = None
|
|
176
|
+
if isinstance(body, dict):
|
|
177
|
+
continue_url = body.get("redirectUrl")
|
|
178
|
+
if not continue_url:
|
|
179
|
+
raise AuthError("Plus4U password auth did not return a continuation URL")
|
|
180
|
+
|
|
181
|
+
step3 = await transport.request(http, "GET", continue_url, cookies)
|
|
182
|
+
if not step3.location or "code=" not in step3.location:
|
|
183
|
+
raise AuthError("Plus4U did not return an authorization code")
|
|
184
|
+
|
|
185
|
+
return await _finish_callback(http, step3.location, cookies, school)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def refresh(session: Session, *, http: aiohttp.ClientSession | None = None) -> Session:
|
|
189
|
+
"""Obtain a fresh edookit session using the stored Plus4U cookies.
|
|
190
|
+
|
|
191
|
+
No password is needed; this is what keeps a long-running Home Assistant
|
|
192
|
+
integration logged in across the ~30-60 minute edookit session lifetime.
|
|
193
|
+
Raises AuthError if the Plus4U cookies have themselves expired (roughly
|
|
194
|
+
every 19 days), which means a full `login()` with the password is needed.
|
|
195
|
+
`http` has the same cookie-jar requirement as in `login()`.
|
|
196
|
+
"""
|
|
197
|
+
# Only the Plus4U cookies: the portal answers /user/login with a redirect
|
|
198
|
+
# to /oidc/logout when it sees the expired edookit session, so the new
|
|
199
|
+
# edookit session is built from scratch, exactly like in login().
|
|
200
|
+
cookies = session.cookies.for_host(PLUS4U_HOST)
|
|
201
|
+
async with transport.http_session(http) as http:
|
|
202
|
+
_, _, step1 = await _authorize(http, session.school, cookies, prompt_none=True)
|
|
203
|
+
if not step1.is_redirect or not step1.location or "code=" not in step1.location:
|
|
204
|
+
raise AuthError("Plus4U session expired; full login required")
|
|
205
|
+
|
|
206
|
+
return await _finish_callback(http, step1.location, cookies, session.school)
|
|
@@ -2,17 +2,21 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
5
7
|
|
|
6
8
|
import aiohttp
|
|
7
9
|
|
|
8
|
-
from . import auth
|
|
9
|
-
from .exceptions import
|
|
10
|
+
from . import auth, transport
|
|
11
|
+
from .exceptions import SessionExpired
|
|
10
12
|
from .models import Lesson, Payments, SchoolEvent, Term
|
|
11
13
|
from .parsers.events import parse_events
|
|
12
14
|
from .parsers.payments import parse_payments
|
|
13
15
|
from .parsers.timetable import parse_terms, parse_timetable
|
|
14
16
|
from .session import Session
|
|
15
17
|
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
16
20
|
_TIMETABLE_PATH = "/timetable/"
|
|
17
21
|
_EVENTS_PATH = "/timetable/upcoming"
|
|
18
22
|
_PAYMENTS_PATH = "/payments/"
|
|
@@ -28,10 +32,10 @@ CAPTURE_PAGES: dict[str, str] = {
|
|
|
28
32
|
class EdookitClient:
|
|
29
33
|
"""Fetches parsed data from one school's Edookit parent portal.
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
Renews an expired edookit session by itself from the Plus4U sign-in and
|
|
36
|
+
retries once; `on_session_update` receives each renewed `Session` so the
|
|
37
|
+
caller can persist it. `AuthError` means the Plus4U sign-in expired too
|
|
38
|
+
and a full `login()` is needed.
|
|
35
39
|
|
|
36
40
|
Pass `http` to reuse an existing `aiohttp.ClientSession` across requests
|
|
37
41
|
instead of opening a new connection per call; otherwise one is created
|
|
@@ -43,9 +47,17 @@ class EdookitClient:
|
|
|
43
47
|
the caller's event loop.
|
|
44
48
|
"""
|
|
45
49
|
|
|
46
|
-
def __init__(
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
session: Session,
|
|
53
|
+
http: aiohttp.ClientSession | None = None,
|
|
54
|
+
*,
|
|
55
|
+
on_session_update: Callable[[Session], None] | None = None,
|
|
56
|
+
) -> None:
|
|
47
57
|
self._session = session
|
|
48
58
|
self._http = http
|
|
59
|
+
self._on_session_update = on_session_update
|
|
60
|
+
self._renew_lock = asyncio.Lock()
|
|
49
61
|
|
|
50
62
|
@property
|
|
51
63
|
def session(self) -> Session:
|
|
@@ -78,39 +90,45 @@ class EdookitClient:
|
|
|
78
90
|
return await self._get(path)
|
|
79
91
|
|
|
80
92
|
async def _get(self, path: str, params: dict[str, int] | None = None) -> str:
|
|
81
|
-
|
|
82
|
-
# allow_redirects=False: our Cookie/CSRF headers are set manually, and
|
|
83
|
-
# older aiohttp forwards a manually-set Cookie header to a redirect's
|
|
84
|
-
# target even when that target is a different host - so an expired
|
|
85
|
-
# session's redirect to Plus4U must never be followed with them
|
|
86
|
-
# attached. Any redirect at all means the session isn't good anymore.
|
|
93
|
+
session = self._session
|
|
87
94
|
try:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
return await self._fetch(session, path, params)
|
|
96
|
+
except SessionExpired:
|
|
97
|
+
_LOGGER.debug("Edookit session expired, renewing it")
|
|
98
|
+
await self._renew(session)
|
|
99
|
+
return await self._fetch(self._session, path, params)
|
|
100
|
+
|
|
101
|
+
async def _renew(self, stale: Session) -> None:
|
|
102
|
+
async with self._renew_lock:
|
|
103
|
+
if self._session is not stale:
|
|
104
|
+
return
|
|
105
|
+
self._session = await auth.refresh(stale, http=self._http)
|
|
106
|
+
if self._on_session_update is not None:
|
|
107
|
+
self._on_session_update(self._session)
|
|
108
|
+
|
|
109
|
+
async def _fetch(self, session: Session, path: str, params: dict[str, int] | None) -> str:
|
|
110
|
+
async with transport.http_session(self._http) as http:
|
|
111
|
+
response = await transport.request(
|
|
112
|
+
http,
|
|
113
|
+
"GET",
|
|
114
|
+
f"{session.base_url}{path}",
|
|
115
|
+
session.cookies,
|
|
116
|
+
headers={"X-CSRF-Token": session.csrf_token, "X-Requested-With": "XMLHttpRequest"},
|
|
117
|
+
params=params,
|
|
118
|
+
)
|
|
119
|
+
if response.is_redirect or _is_ajax_redirect(response.content_type, response.text):
|
|
104
120
|
raise SessionExpired("edookit session has expired")
|
|
105
|
-
return
|
|
121
|
+
return response.text
|
|
106
122
|
|
|
107
123
|
|
|
108
|
-
def
|
|
109
|
-
"""Whether the portal answered with
|
|
124
|
+
def _is_ajax_redirect(content_type: str, body: str) -> bool:
|
|
125
|
+
"""Whether the portal answered with an AJAX-style redirect.
|
|
110
126
|
|
|
111
|
-
Requests carry X-Requested-With, so an expired session gets a 200 with
|
|
112
|
-
{"redirect":
|
|
113
|
-
|
|
127
|
+
Requests carry X-Requested-With, so an expired session gets a 200 with a
|
|
128
|
+
JSON {"redirect": url} rather than an HTTP redirect. Both shapes seen on a
|
|
129
|
+
real portal are expired sessions: /user/login?message=1 and
|
|
130
|
+
/oidc/logout?performCallback=.... A redirect answer never carries the
|
|
131
|
+
page, so the target URL is deliberately not inspected.
|
|
114
132
|
"""
|
|
115
133
|
if not content_type.startswith("application/json"):
|
|
116
134
|
return False
|
|
@@ -118,5 +136,4 @@ def _redirects_to_login(content_type: str, body: str) -> bool:
|
|
|
118
136
|
data = json.loads(body)
|
|
119
137
|
except ValueError:
|
|
120
138
|
return False
|
|
121
|
-
|
|
122
|
-
return isinstance(redirect, str) and auth.is_login_url(redirect)
|
|
139
|
+
return isinstance(data, dict) and isinstance(data.get("redirect"), str)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from .exceptions import AuthError
|
|
10
|
+
from .transport import Cookie, CookieStore
|
|
11
|
+
|
|
12
|
+
PLUS4U_HOST = "uuidentity.plus4u.net"
|
|
13
|
+
|
|
14
|
+
# A school subdomain becomes part of every portal URL with no other encoding,
|
|
15
|
+
# so reject anything that isn't a bare hostname label - otherwise a value
|
|
16
|
+
# like "evil.com/" or "evil.com#" would send the session's cookies off-portal.
|
|
17
|
+
_SCHOOL_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_school(school: str) -> None:
|
|
21
|
+
if not _SCHOOL_RE.fullmatch(school):
|
|
22
|
+
raise AuthError(f"{school!r} is not a valid Edookit school subdomain")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def school_host(school: str) -> str:
|
|
26
|
+
return f"{school}.edookit.net"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def school_base(school: str) -> str:
|
|
30
|
+
return f"https://{school_host(school)}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class Session:
|
|
35
|
+
"""Everything needed to talk to a school's portal without the password.
|
|
36
|
+
|
|
37
|
+
`cookies` holds the short-lived edookit session (30-60 minutes) and the
|
|
38
|
+
long-lived Plus4U sign-in that `refresh()` renews it from. Treat the
|
|
39
|
+
serialised form like a password.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
school: str
|
|
43
|
+
cookies: CookieStore = field(repr=False)
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
validate_school(self.school)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_cookies(
|
|
50
|
+
cls, school: str, edookit: Mapping[str, str], plus4u: Mapping[str, str]
|
|
51
|
+
) -> Session:
|
|
52
|
+
return cls(
|
|
53
|
+
school,
|
|
54
|
+
CookieStore(
|
|
55
|
+
{
|
|
56
|
+
school_host(school): {n: Cookie(v) for n, v in edookit.items()},
|
|
57
|
+
PLUS4U_HOST: {n: Cookie(v) for n, v in plus4u.items()},
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def host(self) -> str:
|
|
64
|
+
return school_host(self.school)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def base_url(self) -> str:
|
|
68
|
+
return school_base(self.school)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def csrf_token(self) -> str:
|
|
72
|
+
return self.cookies.get(self.host, "uu.app.csrf") or ""
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def plus4u_expires_at(self) -> datetime | None:
|
|
76
|
+
return self.cookies.expires(PLUS4U_HOST, "uoid.ps")
|
|
77
|
+
|
|
78
|
+
def to_json(self) -> str:
|
|
79
|
+
return json.dumps({"school": self.school, "cookies": self.cookies.to_dict()})
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_json(cls, raw: str) -> Session:
|
|
83
|
+
data = json.loads(raw)
|
|
84
|
+
if "cookies" in data:
|
|
85
|
+
return cls(data["school"], CookieStore.from_dict(data["cookies"]))
|
|
86
|
+
# Written by pyedookit <= 0.3.x: two plain dicts and no cookie expiry.
|
|
87
|
+
return cls.from_cookies(data["school"], data["edookit_cookies"], data["plus4u_cookies"])
|