bocconi-sdk 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
bocconi/auth.py ADDED
@@ -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
+ ]