bocconi-sdk 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.
- bocconi_sdk-0.2.0/LICENSE +21 -0
- bocconi_sdk-0.2.0/PKG-INFO +159 -0
- bocconi_sdk-0.2.0/README.md +134 -0
- bocconi_sdk-0.2.0/bocconi/__init__.py +48 -0
- bocconi_sdk-0.2.0/bocconi/auth.py +217 -0
- bocconi_sdk-0.2.0/bocconi/blackboard/__init__.py +26 -0
- bocconi_sdk-0.2.0/bocconi/blackboard/client.py +657 -0
- bocconi_sdk-0.2.0/bocconi/client.py +120 -0
- bocconi_sdk-0.2.0/bocconi/exceptions.py +25 -0
- bocconi_sdk-0.2.0/bocconi/youatb/__init__.py +67 -0
- bocconi_sdk-0.2.0/bocconi/youatb/calendar.py +128 -0
- bocconi_sdk-0.2.0/bocconi/youatb/client.py +417 -0
- bocconi_sdk-0.2.0/bocconi/youatb/profile.py +111 -0
- bocconi_sdk-0.2.0/bocconi/youatb/puntoblu.py +483 -0
- bocconi_sdk-0.2.0/bocconi/youatb/signalr.py +190 -0
- bocconi_sdk-0.2.0/bocconi/youatb/widgets.py +125 -0
- bocconi_sdk-0.2.0/bocconi_sdk.egg-info/PKG-INFO +159 -0
- bocconi_sdk-0.2.0/bocconi_sdk.egg-info/SOURCES.txt +21 -0
- bocconi_sdk-0.2.0/bocconi_sdk.egg-info/dependency_links.txt +1 -0
- bocconi_sdk-0.2.0/bocconi_sdk.egg-info/requires.txt +5 -0
- bocconi_sdk-0.2.0/bocconi_sdk.egg-info/top_level.txt +1 -0
- bocconi_sdk-0.2.0/pyproject.toml +34 -0
- bocconi_sdk-0.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 youatb contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bocconi-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Unofficial Python SDK for Bocconi University student services (yoU@B portal, Blackboard, Esse3)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: bocconi,youatb,blackboard,esse3,university,student
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Education
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.31
|
|
22
|
+
Requires-Dist: websocket-client>=1.7
|
|
23
|
+
Requires-Dist: tzdata>=2024.1; platform_system == "Windows"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Bocconi Software Development Kit
|
|
27
|
+
|
|
28
|
+
Unofficial Python SDK for **Bocconi University student services**. One
|
|
29
|
+
username/password login unlocks every subsystem through the shared SAML2 SSO
|
|
30
|
+
(`idp.unibocconi.it`). Reverse-engineered from live traffic.
|
|
31
|
+
|
|
32
|
+
## Subsystems
|
|
33
|
+
|
|
34
|
+
| Package | System | What you get |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `bocconi.youatb` | **yoU@B portal** (`youatb.unibocconi.it`) | Dashboard widgets (SignalR 2.x `widgethub`), lesson calendar JSON feed, profile/careers, language switching |
|
|
37
|
+
| `bocconi.youatb.puntoblu` | **Esse3 / PuntoBlu** (`www.pb.unibocconi.it`) | Student info, exam sessions, grades booklet, fees/certificates/… (HTML scraping) |
|
|
38
|
+
| `bocconi.blackboard` | **Blackboard** (`blackboard.unibocconi.it`) | Courses, deadlines/to-dos, messages, announcements, unread counters, activity stream |
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```powershell
|
|
43
|
+
cd bocconi-sdk
|
|
44
|
+
python -m venv .venv
|
|
45
|
+
.\.venv\Scripts\Activate.ps1
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Dependencies: `requests`, `websocket-client`. Python ≥ 3.9.
|
|
50
|
+
|
|
51
|
+
## Quickstart — parent client (recommended)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from datetime import date
|
|
55
|
+
from bocconi import BocconiClient
|
|
56
|
+
|
|
57
|
+
with BocconiClient("1234567", "your-password") as client:
|
|
58
|
+
client.login() # one SAML login unlocks everything
|
|
59
|
+
|
|
60
|
+
yt = client.youatb # yoU@B portal
|
|
61
|
+
print(yt.profile.name, yt.student_id)
|
|
62
|
+
widgets = yt.dashboard() # SignalR widgethub
|
|
63
|
+
for ev in yt.calendar(date(2026, 8, 31), date(2026, 9, 6)):
|
|
64
|
+
print(ev.start, ev.course_name, ev.room)
|
|
65
|
+
|
|
66
|
+
bb = client.blackboard # Blackboard Ultra
|
|
67
|
+
for course in bb.courses():
|
|
68
|
+
print(course.display_id, course.name)
|
|
69
|
+
for item in bb.todo().future:
|
|
70
|
+
print(item.due_date, item.title)
|
|
71
|
+
|
|
72
|
+
pb = yt.puntoblu # Esse3
|
|
73
|
+
print(pb.student_info().degree_program)
|
|
74
|
+
print(pb.exam_sessions()[:3])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Standalone children
|
|
78
|
+
|
|
79
|
+
Each subsystem also works independently (own session, own login):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from bocconi.youatb import YouAtBClient
|
|
83
|
+
from bocconi.blackboard import BlackboardClient
|
|
84
|
+
|
|
85
|
+
with YouAtBClient("1234567", "pw") as yt:
|
|
86
|
+
yt.login()
|
|
87
|
+
print(yt.calendar())
|
|
88
|
+
|
|
89
|
+
bb = BlackboardClient.authenticate("1234567", "pw") # SAML via Blackboard's SP
|
|
90
|
+
print(bb.me())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or attach children to an existing authenticated session:
|
|
94
|
+
`YouAtBClient.from_session(session)`, `BlackboardClient.from_session(session)`.
|
|
95
|
+
|
|
96
|
+
## API overview
|
|
97
|
+
|
|
98
|
+
### `BocconiClient(username, password, lang="en")`
|
|
99
|
+
|
|
100
|
+
- `login()` / `logout()` / `close()` — context manager supported.
|
|
101
|
+
- `.youatb` → `YouAtBClient`, `.blackboard` → `BlackboardClient` (lazy).
|
|
102
|
+
- `fetch(url)` — GET any Bocconi SP URL with automatic SSO completion.
|
|
103
|
+
|
|
104
|
+
### `bocconi.youatb.YouAtBClient`
|
|
105
|
+
|
|
106
|
+
- `login()`, `profile` (name, photo, careers), `student_id`,
|
|
107
|
+
`set_career(...)`, `set_language("ita"|"eng")`
|
|
108
|
+
- `dashboard(widget_ids=None)` / `load_widgets(ids)` → `WidgetResult`
|
|
109
|
+
(`WidgetContent`: `.content_type`, `.url`, `.links`, `.text`; widget-ID
|
|
110
|
+
constants exported from `bocconi.youatb`)
|
|
111
|
+
- `calendar(start=None, end=None)` → `list[CalendarEvent]` — typed lessons:
|
|
112
|
+
course code/name, class, professors, room/building/floor, lat/lon, tz-aware
|
|
113
|
+
Europe/Rome datetimes
|
|
114
|
+
- `.puntoblu` → `PuntoBluClient`
|
|
115
|
+
|
|
116
|
+
### `bocconi.youatb.puntoblu.PuntoBluClient`
|
|
117
|
+
|
|
118
|
+
- `student_info()` → `StudentInfo` (bilingual eng/ita, enrolment history)
|
|
119
|
+
- `exam_sessions()` → `list[ExamSession]` — dates, type (partial/general),
|
|
120
|
+
enrolment windows, booking status/link
|
|
121
|
+
- `booklet()` → `list[BookletEntry]` — credits, year, status, grades
|
|
122
|
+
- `menu()`, `get(path)` + `PB_*` path constants; career interstitial handled
|
|
123
|
+
automatically (`select_career(stu_id)`)
|
|
124
|
+
|
|
125
|
+
### `bocconi.blackboard.BlackboardClient`
|
|
126
|
+
|
|
127
|
+
- `me()` → `BbUser`; `courses()` / `recent_courses()` → `list[BbCourse]`
|
|
128
|
+
- `todo(since=None, until=None)` → `BbTodo` — actionable deadlines grouped
|
|
129
|
+
`.overdue`/.`due_today`/`.future` (>15-day ranges auto-chunked)
|
|
130
|
+
- `calendar_items` / `deadlines(since, until)` → `list[BbCalendarItem]`
|
|
131
|
+
(calendar view, keeps completed items)
|
|
132
|
+
- `announcements(course_id)`, `conversations(course_id)`,
|
|
133
|
+
`message_summary()`, `announcement_counts` / `conversation_counts` /
|
|
134
|
+
`unread_grades_count` / `discussion_unread_count(course_id)`
|
|
135
|
+
- `calendars()`, `terms()`, `course_membership(course_id)`, `stream()`,
|
|
136
|
+
`system_preference(key)`, `entitlements()`, `api_get(path)`
|
|
137
|
+
|
|
138
|
+
## Notes & caveats
|
|
139
|
+
|
|
140
|
+
- The portal hub speaks **ASP.NET SignalR 2.x** (`clientProtocol=2.1`) — *not*
|
|
141
|
+
ASP.NET Core SignalR; a minimal 2.x client is bundled
|
|
142
|
+
(`bocconi.youatb.signalr.SignalR2Client`).
|
|
143
|
+
- Blackboard's SP sends the SAMLRequest via **HTTP-POST binding** — handled
|
|
144
|
+
transparently by `bocconi.auth` for all services.
|
|
145
|
+
- Hub/Esse3 HTML fragments are **untrusted server-rendered markup**; the SDK
|
|
146
|
+
never executes embedded scripts, `.text` strips tags.
|
|
147
|
+
- Sessions expire server-side; on `SessionExpiredError` call `login()` again.
|
|
148
|
+
- Unofficial project, not affiliated with Università Bocconi. Use
|
|
149
|
+
responsibly: don't hammer the servers, don't share session cookies.
|
|
150
|
+
|
|
151
|
+
This SDK currently does not cover the full range of features offered by the Bocconi yoU@B/Blackboard ecosystem, so feel free to open a pull request
|
|
152
|
+
|
|
153
|
+
Run the full demo:
|
|
154
|
+
|
|
155
|
+
```powershell
|
|
156
|
+
$env:YOUATB_USER = "1234567"
|
|
157
|
+
$env:YOUATB_PASS = "your-password"
|
|
158
|
+
python examples/demo.py
|
|
159
|
+
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Bocconi Software Development Kit
|
|
2
|
+
|
|
3
|
+
Unofficial Python SDK for **Bocconi University student services**. One
|
|
4
|
+
username/password login unlocks every subsystem through the shared SAML2 SSO
|
|
5
|
+
(`idp.unibocconi.it`). Reverse-engineered from live traffic.
|
|
6
|
+
|
|
7
|
+
## Subsystems
|
|
8
|
+
|
|
9
|
+
| Package | System | What you get |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `bocconi.youatb` | **yoU@B portal** (`youatb.unibocconi.it`) | Dashboard widgets (SignalR 2.x `widgethub`), lesson calendar JSON feed, profile/careers, language switching |
|
|
12
|
+
| `bocconi.youatb.puntoblu` | **Esse3 / PuntoBlu** (`www.pb.unibocconi.it`) | Student info, exam sessions, grades booklet, fees/certificates/… (HTML scraping) |
|
|
13
|
+
| `bocconi.blackboard` | **Blackboard** (`blackboard.unibocconi.it`) | Courses, deadlines/to-dos, messages, announcements, unread counters, activity stream |
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
cd bocconi-sdk
|
|
19
|
+
python -m venv .venv
|
|
20
|
+
.\.venv\Scripts\Activate.ps1
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Dependencies: `requests`, `websocket-client`. Python ≥ 3.9.
|
|
25
|
+
|
|
26
|
+
## Quickstart — parent client (recommended)
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from datetime import date
|
|
30
|
+
from bocconi import BocconiClient
|
|
31
|
+
|
|
32
|
+
with BocconiClient("1234567", "your-password") as client:
|
|
33
|
+
client.login() # one SAML login unlocks everything
|
|
34
|
+
|
|
35
|
+
yt = client.youatb # yoU@B portal
|
|
36
|
+
print(yt.profile.name, yt.student_id)
|
|
37
|
+
widgets = yt.dashboard() # SignalR widgethub
|
|
38
|
+
for ev in yt.calendar(date(2026, 8, 31), date(2026, 9, 6)):
|
|
39
|
+
print(ev.start, ev.course_name, ev.room)
|
|
40
|
+
|
|
41
|
+
bb = client.blackboard # Blackboard Ultra
|
|
42
|
+
for course in bb.courses():
|
|
43
|
+
print(course.display_id, course.name)
|
|
44
|
+
for item in bb.todo().future:
|
|
45
|
+
print(item.due_date, item.title)
|
|
46
|
+
|
|
47
|
+
pb = yt.puntoblu # Esse3
|
|
48
|
+
print(pb.student_info().degree_program)
|
|
49
|
+
print(pb.exam_sessions()[:3])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Standalone children
|
|
53
|
+
|
|
54
|
+
Each subsystem also works independently (own session, own login):
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from bocconi.youatb import YouAtBClient
|
|
58
|
+
from bocconi.blackboard import BlackboardClient
|
|
59
|
+
|
|
60
|
+
with YouAtBClient("1234567", "pw") as yt:
|
|
61
|
+
yt.login()
|
|
62
|
+
print(yt.calendar())
|
|
63
|
+
|
|
64
|
+
bb = BlackboardClient.authenticate("1234567", "pw") # SAML via Blackboard's SP
|
|
65
|
+
print(bb.me())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or attach children to an existing authenticated session:
|
|
69
|
+
`YouAtBClient.from_session(session)`, `BlackboardClient.from_session(session)`.
|
|
70
|
+
|
|
71
|
+
## API overview
|
|
72
|
+
|
|
73
|
+
### `BocconiClient(username, password, lang="en")`
|
|
74
|
+
|
|
75
|
+
- `login()` / `logout()` / `close()` — context manager supported.
|
|
76
|
+
- `.youatb` → `YouAtBClient`, `.blackboard` → `BlackboardClient` (lazy).
|
|
77
|
+
- `fetch(url)` — GET any Bocconi SP URL with automatic SSO completion.
|
|
78
|
+
|
|
79
|
+
### `bocconi.youatb.YouAtBClient`
|
|
80
|
+
|
|
81
|
+
- `login()`, `profile` (name, photo, careers), `student_id`,
|
|
82
|
+
`set_career(...)`, `set_language("ita"|"eng")`
|
|
83
|
+
- `dashboard(widget_ids=None)` / `load_widgets(ids)` → `WidgetResult`
|
|
84
|
+
(`WidgetContent`: `.content_type`, `.url`, `.links`, `.text`; widget-ID
|
|
85
|
+
constants exported from `bocconi.youatb`)
|
|
86
|
+
- `calendar(start=None, end=None)` → `list[CalendarEvent]` — typed lessons:
|
|
87
|
+
course code/name, class, professors, room/building/floor, lat/lon, tz-aware
|
|
88
|
+
Europe/Rome datetimes
|
|
89
|
+
- `.puntoblu` → `PuntoBluClient`
|
|
90
|
+
|
|
91
|
+
### `bocconi.youatb.puntoblu.PuntoBluClient`
|
|
92
|
+
|
|
93
|
+
- `student_info()` → `StudentInfo` (bilingual eng/ita, enrolment history)
|
|
94
|
+
- `exam_sessions()` → `list[ExamSession]` — dates, type (partial/general),
|
|
95
|
+
enrolment windows, booking status/link
|
|
96
|
+
- `booklet()` → `list[BookletEntry]` — credits, year, status, grades
|
|
97
|
+
- `menu()`, `get(path)` + `PB_*` path constants; career interstitial handled
|
|
98
|
+
automatically (`select_career(stu_id)`)
|
|
99
|
+
|
|
100
|
+
### `bocconi.blackboard.BlackboardClient`
|
|
101
|
+
|
|
102
|
+
- `me()` → `BbUser`; `courses()` / `recent_courses()` → `list[BbCourse]`
|
|
103
|
+
- `todo(since=None, until=None)` → `BbTodo` — actionable deadlines grouped
|
|
104
|
+
`.overdue`/.`due_today`/`.future` (>15-day ranges auto-chunked)
|
|
105
|
+
- `calendar_items` / `deadlines(since, until)` → `list[BbCalendarItem]`
|
|
106
|
+
(calendar view, keeps completed items)
|
|
107
|
+
- `announcements(course_id)`, `conversations(course_id)`,
|
|
108
|
+
`message_summary()`, `announcement_counts` / `conversation_counts` /
|
|
109
|
+
`unread_grades_count` / `discussion_unread_count(course_id)`
|
|
110
|
+
- `calendars()`, `terms()`, `course_membership(course_id)`, `stream()`,
|
|
111
|
+
`system_preference(key)`, `entitlements()`, `api_get(path)`
|
|
112
|
+
|
|
113
|
+
## Notes & caveats
|
|
114
|
+
|
|
115
|
+
- The portal hub speaks **ASP.NET SignalR 2.x** (`clientProtocol=2.1`) — *not*
|
|
116
|
+
ASP.NET Core SignalR; a minimal 2.x client is bundled
|
|
117
|
+
(`bocconi.youatb.signalr.SignalR2Client`).
|
|
118
|
+
- Blackboard's SP sends the SAMLRequest via **HTTP-POST binding** — handled
|
|
119
|
+
transparently by `bocconi.auth` for all services.
|
|
120
|
+
- Hub/Esse3 HTML fragments are **untrusted server-rendered markup**; the SDK
|
|
121
|
+
never executes embedded scripts, `.text` strips tags.
|
|
122
|
+
- Sessions expire server-side; on `SessionExpiredError` call `login()` again.
|
|
123
|
+
- Unofficial project, not affiliated with Università Bocconi. Use
|
|
124
|
+
responsibly: don't hammer the servers, don't share session cookies.
|
|
125
|
+
|
|
126
|
+
This SDK currently does not cover the full range of features offered by the Bocconi yoU@B/Blackboard ecosystem, so feel free to open a pull request
|
|
127
|
+
|
|
128
|
+
Run the full demo:
|
|
129
|
+
|
|
130
|
+
```powershell
|
|
131
|
+
$env:YOUATB_USER = "1234567"
|
|
132
|
+
$env:YOUATB_PASS = "your-password"
|
|
133
|
+
python examples/demo.py
|
|
134
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Unofficial Python SDK for Bocconi University student services.
|
|
2
|
+
|
|
3
|
+
One SAML2 SSO login (idp.unibocconi.it) unlocks all subsystems:
|
|
4
|
+
|
|
5
|
+
* :mod:`bocconi.youatb` — yoU@B portal (SignalR widgets, calendar,
|
|
6
|
+
profile/careers) and the Esse3 "PuntoBlu" student area
|
|
7
|
+
* :mod:`bocconi.blackboard` — Blackboard Learn Ultra (courses, deadlines,
|
|
8
|
+
messages, announcements, activity stream)
|
|
9
|
+
|
|
10
|
+
Quickstart::
|
|
11
|
+
|
|
12
|
+
from bocconi import BocconiClient
|
|
13
|
+
|
|
14
|
+
with BocconiClient("1234567", "secret") as client:
|
|
15
|
+
client.login()
|
|
16
|
+
client.youatb.dashboard()
|
|
17
|
+
client.blackboard.courses()
|
|
18
|
+
|
|
19
|
+
Reverse-engineered from the live services; see youatb-docs.md in the
|
|
20
|
+
sibling ``youatb_re`` project for the full protocol write-up. Unofficial,
|
|
21
|
+
not affiliated with Università Bocconi.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from . import blackboard, youatb
|
|
25
|
+
from .client import BocconiClient
|
|
26
|
+
from .exceptions import (
|
|
27
|
+
AuthenticationError,
|
|
28
|
+
InvalidCredentials,
|
|
29
|
+
SessionExpiredError,
|
|
30
|
+
SignalRError,
|
|
31
|
+
WidgetError,
|
|
32
|
+
YouAtBError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.2.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"BocconiClient",
|
|
39
|
+
"youatb",
|
|
40
|
+
"blackboard",
|
|
41
|
+
# errors
|
|
42
|
+
"YouAtBError",
|
|
43
|
+
"AuthenticationError",
|
|
44
|
+
"InvalidCredentials",
|
|
45
|
+
"SessionExpiredError",
|
|
46
|
+
"SignalRError",
|
|
47
|
+
"WidgetError",
|
|
48
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""SAML2 Web-SSO login against the Bocconi Shibboleth IdP.
|
|
2
|
+
|
|
3
|
+
Implements the flow documented in youatb-docs.md §2/§5:
|
|
4
|
+
|
|
5
|
+
GET <SP protected URL> -> 302 chain to idp.unibocconi.it
|
|
6
|
+
GET IdP login page -> form with `execution` webflow token
|
|
7
|
+
POST j_username/j_password -> 200 auto-submit form (SAMLResponse)
|
|
8
|
+
POST SAMLResponse to SP ACS URL -> 302 back, SP session cookies set
|
|
9
|
+
|
|
10
|
+
No JavaScript is executed; the auto-submit forms are parsed and replayed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import html as _html
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from html.parser import HTMLParser
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
from urllib.parse import urljoin
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
InvalidCredentials,
|
|
28
|
+
SessionExpiredError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
IDP_HOST = "idp.unibocconi.it"
|
|
34
|
+
|
|
35
|
+
DEFAULT_USER_AGENT = (
|
|
36
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_CRED_FAIL_RE = re.compile(
|
|
41
|
+
r"loginfailed|credentials not recognized|login failed|password.*incorrect",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Minimal HTML form extraction (stdlib only)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class HtmlForm:
|
|
52
|
+
action: str = ""
|
|
53
|
+
method: str = "get"
|
|
54
|
+
inputs: Dict[str, str] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _FormParser(HTMLParser):
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
super().__init__(convert_charrefs=True)
|
|
60
|
+
self.forms: List[HtmlForm] = []
|
|
61
|
+
self._current: Optional[HtmlForm] = None
|
|
62
|
+
|
|
63
|
+
def handle_starttag(self, tag: str, attrs) -> None:
|
|
64
|
+
attrs = dict(attrs)
|
|
65
|
+
if tag == "form":
|
|
66
|
+
self._current = HtmlForm(
|
|
67
|
+
action=attrs.get("action", ""),
|
|
68
|
+
method=attrs.get("method", "get").lower(),
|
|
69
|
+
)
|
|
70
|
+
elif tag == "input" and self._current is not None:
|
|
71
|
+
name = attrs.get("name")
|
|
72
|
+
if name:
|
|
73
|
+
self._current.inputs[name] = attrs.get("value", "")
|
|
74
|
+
|
|
75
|
+
def handle_endtag(self, tag: str) -> None:
|
|
76
|
+
if tag == "form" and self._current is not None:
|
|
77
|
+
self.forms.append(self._current)
|
|
78
|
+
self._current = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_forms(html_text: str) -> List[HtmlForm]:
|
|
82
|
+
parser = _FormParser()
|
|
83
|
+
parser.feed(html_text)
|
|
84
|
+
return parser.forms
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _find_saml_form(html_text: str) -> Optional[HtmlForm]:
|
|
88
|
+
"""Find an auto-submit SAML form.
|
|
89
|
+
|
|
90
|
+
Two kinds occur in Bocconi SSO flows:
|
|
91
|
+
* ``SAMLResponse`` — IdP -> SP (the common case, POST binding response)
|
|
92
|
+
* ``SAMLRequest`` — SP -> IdP (POST binding *request*; Blackboard's
|
|
93
|
+
``/auth-saml/saml/login`` uses this instead of a 302 redirect)
|
|
94
|
+
"""
|
|
95
|
+
for form in extract_forms(html_text):
|
|
96
|
+
if "SAMLResponse" in form.inputs or "SAMLRequest" in form.inputs:
|
|
97
|
+
return form
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _find_password_form(html_text: str) -> Optional[HtmlForm]:
|
|
102
|
+
for form in extract_forms(html_text):
|
|
103
|
+
if "j_password" in form.inputs or "j_username" in form.inputs:
|
|
104
|
+
return form
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# SAML plumbing
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def complete_saml_posts(
|
|
113
|
+
session: requests.Session,
|
|
114
|
+
response: requests.Response,
|
|
115
|
+
max_hops: int = 10,
|
|
116
|
+
) -> requests.Response:
|
|
117
|
+
"""Replay auto-submit SAML forms until a real page is reached.
|
|
118
|
+
|
|
119
|
+
Handles both directions of the POST binding: IdP->SP ``SAMLResponse``
|
|
120
|
+
forms (youatb, www.pb, app, ...) and SP->IdP ``SAMLRequest`` forms
|
|
121
|
+
(Blackboard's ``/auth-saml/saml/login``).
|
|
122
|
+
"""
|
|
123
|
+
for _ in range(max_hops):
|
|
124
|
+
form = _find_saml_form(response.text)
|
|
125
|
+
if form is None:
|
|
126
|
+
return response
|
|
127
|
+
action = urljoin(response.url, _html.unescape(form.action))
|
|
128
|
+
log.debug("SAML POST -> %s", action)
|
|
129
|
+
response = session.post(action, data=form.inputs, timeout=30)
|
|
130
|
+
raise AuthenticationError("Too many SAML redirect hops")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def has_session_cookie(session: requests.Session) -> bool:
|
|
134
|
+
"""True if the jar holds a session marker of any known Bocconi service."""
|
|
135
|
+
markers = (".ASPXAUTH", "BbRouter", "samlCookie")
|
|
136
|
+
return any(
|
|
137
|
+
c.name in markers or c.name.startswith("_shibsession_")
|
|
138
|
+
for c in session.cookies
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def login(
|
|
143
|
+
session: requests.Session,
|
|
144
|
+
entry_url: str,
|
|
145
|
+
username: str,
|
|
146
|
+
password: str,
|
|
147
|
+
) -> requests.Response:
|
|
148
|
+
"""Perform a full username/password login starting from any SP entry URL.
|
|
149
|
+
|
|
150
|
+
Works for the yoU@B portal (``https://youatb.unibocconi.it/``) as well as
|
|
151
|
+
for SPs that emit the SAMLRequest via POST binding, e.g. Blackboard
|
|
152
|
+
(``https://blackboard.unibocconi.it/ultra/institution-page``): the chain
|
|
153
|
+
eventually reaches the IdP password form either way.
|
|
154
|
+
|
|
155
|
+
Returns the final authenticated response.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
InvalidCredentials: the IdP rejected the credentials.
|
|
159
|
+
AuthenticationError: any other flow failure.
|
|
160
|
+
"""
|
|
161
|
+
log.info("Opening %s", entry_url)
|
|
162
|
+
resp = session.get(entry_url, timeout=30)
|
|
163
|
+
resp = complete_saml_posts(session, resp)
|
|
164
|
+
|
|
165
|
+
pw_form = _find_password_form(resp.text)
|
|
166
|
+
if pw_form is None:
|
|
167
|
+
# No login form: either already authenticated (existing IdP session
|
|
168
|
+
# silently SSO'd us in) or something unexpected happened.
|
|
169
|
+
if has_session_cookie(session):
|
|
170
|
+
log.info("Authenticated via existing IdP SSO session")
|
|
171
|
+
return resp
|
|
172
|
+
raise AuthenticationError(
|
|
173
|
+
f"No login form found and no session established (final URL: {resp.url})"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
action = urljoin(resp.url, _html.unescape(pw_form.action))
|
|
177
|
+
payload = dict(pw_form.inputs) # keep any hidden webflow fields
|
|
178
|
+
payload["j_username"] = username
|
|
179
|
+
payload["j_password"] = password
|
|
180
|
+
payload["_eventId_proceed"] = ""
|
|
181
|
+
log.info("Posting credentials to %s", action)
|
|
182
|
+
resp = session.post(action, data=payload, timeout=30)
|
|
183
|
+
|
|
184
|
+
if "SAMLResponse" not in resp.text:
|
|
185
|
+
if _CRED_FAIL_RE.search(resp.text) or _find_password_form(resp.text):
|
|
186
|
+
raise InvalidCredentials("IdP rejected the username/password")
|
|
187
|
+
raise AuthenticationError(
|
|
188
|
+
f"Unexpected IdP response (no SAMLResponse, URL: {resp.url})"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
resp = complete_saml_posts(session, resp)
|
|
192
|
+
|
|
193
|
+
if not has_session_cookie(session):
|
|
194
|
+
raise AuthenticationError(
|
|
195
|
+
"SAML flow completed but no session cookie was set by the SP"
|
|
196
|
+
)
|
|
197
|
+
log.info("Login successful")
|
|
198
|
+
return resp
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def ensure_service_session(
|
|
202
|
+
session: requests.Session,
|
|
203
|
+
url: str,
|
|
204
|
+
) -> requests.Response:
|
|
205
|
+
"""GET a protected URL on any Bocconi SP, completing SSO if needed.
|
|
206
|
+
|
|
207
|
+
With a live IdP session this is silent: the SP bounces us to the IdP and
|
|
208
|
+
straight back with a fresh SAMLResponse. Raises SessionExpiredError if the
|
|
209
|
+
IdP asks for credentials again.
|
|
210
|
+
"""
|
|
211
|
+
resp = session.get(url, timeout=30)
|
|
212
|
+
resp = complete_saml_posts(session, resp)
|
|
213
|
+
if IDP_HOST in resp.url and _find_password_form(resp.text):
|
|
214
|
+
raise SessionExpiredError(
|
|
215
|
+
"IdP SSO session expired; call YouAtBClient.login() again"
|
|
216
|
+
)
|
|
217
|
+
return resp
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Blackboard Learn Ultra subsystem: courses, deadlines/to-dos, messages,
|
|
2
|
+
announcements, activity stream."""
|
|
3
|
+
|
|
4
|
+
from .client import (
|
|
5
|
+
BbAnnouncement,
|
|
6
|
+
BbCalendar,
|
|
7
|
+
BbCalendarItem,
|
|
8
|
+
BbConversation,
|
|
9
|
+
BbCourse,
|
|
10
|
+
BbTodo,
|
|
11
|
+
BbTodoItem,
|
|
12
|
+
BbUser,
|
|
13
|
+
BlackboardClient,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BlackboardClient",
|
|
18
|
+
"BbUser",
|
|
19
|
+
"BbCourse",
|
|
20
|
+
"BbConversation",
|
|
21
|
+
"BbAnnouncement",
|
|
22
|
+
"BbCalendarItem",
|
|
23
|
+
"BbCalendar",
|
|
24
|
+
"BbTodo",
|
|
25
|
+
"BbTodoItem",
|
|
26
|
+
]
|