njau-auth 0.1.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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .venv/
8
+ venv/
9
+ dist/
10
+ build/
11
+ auth_session.json
12
+ captcha_temp.*
13
+ playwright-profiles/
14
+ .env
15
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 leecyang
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.
22
+
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: njau-auth
3
+ Version: 0.1.0
4
+ Summary: NJAU CAS authentication helper
5
+ Project-URL: Homepage, https://github.com/leecyang/NJAU-Auth
6
+ Project-URL: Repository, https://github.com/leecyang/NJAU-Auth
7
+ Project-URL: Issues, https://github.com/leecyang/NJAU-Auth/issues
8
+ Author: leecyang
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: auth,cas,httpx,njau
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.28.1
14
+ Requires-Dist: pycryptodome>=3.20.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # NJAU-Auth
20
+
21
+ 南京农业大学统一身份认证(CAS)登录辅助库。项目形态参考 `Golevka2001/SEU-Auth`,当前版本使用纯 HTTP 请求完成登录,不依赖 Playwright 或浏览器自动化。
22
+
23
+ 当前实现参考了本地 `NJAU-Libyy` 项目中可工作的 CAS 自动化流程,支持:
24
+
25
+ - 使用学号和统一认证密码登录。
26
+ - 先 GET 登录页,提取 `execution` 和 `pwdEncryptSalt`。
27
+ - 使用 AES-128-CBC / PKCS7 生成提交用密码密文。
28
+ - 检测账号密码错误、验证码要求和短信二次验证。
29
+ - 通过回调提交短信验证码。
30
+ - 保存并复用 Cookie,减少重复登录。
31
+ - 默认服务地址为 `http://jw3.njau.edu.cn/`。
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install -e .
37
+ ```
38
+
39
+ ## Basic Usage
40
+
41
+ ```python
42
+ import asyncio
43
+ from njau_auth import NJAUAuthManager
44
+
45
+
46
+ async def sms_callback(challenge):
47
+ print(challenge.message)
48
+ return input("SMS code: ").strip()
49
+
50
+
51
+ async def main():
52
+ manager = NJAUAuthManager(
53
+ student_id="2023000000",
54
+ password="your-password",
55
+ sms_callback=sms_callback,
56
+ )
57
+
58
+ async with manager:
59
+ result = await manager.login()
60
+ print(result.final_url)
61
+ print(result.cookies)
62
+
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ ## CLI
68
+
69
+ ```bash
70
+ njau-auth-login --student-id 2023000000
71
+ ```
72
+
73
+ 如果不传 `--password`,命令会从交互式密码输入读取。
74
+
75
+ ## Notes
76
+
77
+ - 默认密码密文使用当前 CAS 可接受的形态:`pwdEncryptSalt` 作为 AES key、固定 16 字节 IV、`64 位随机前缀 + 原始密码` 作为明文。
78
+ - `utils.crypto` 里保留了固定 key 的兼容函数 `encrypt_password_with_fixed_key()`,用于兼容其他部署或后续验证。
79
+ - 如果要认证其他 CAS 服务,可在 `NJAUAuthClient` 或 `NJAUAuthManager` 中传入 `service_url` 和 `success_url_contains`。
80
+ - 当统一认证要求图形验证码或滑块验证码时,当前版本会直接抛出 `CaptchaRequiredError`,避免误判或卡死。
@@ -0,0 +1,62 @@
1
+ # NJAU-Auth
2
+
3
+ 南京农业大学统一身份认证(CAS)登录辅助库。项目形态参考 `Golevka2001/SEU-Auth`,当前版本使用纯 HTTP 请求完成登录,不依赖 Playwright 或浏览器自动化。
4
+
5
+ 当前实现参考了本地 `NJAU-Libyy` 项目中可工作的 CAS 自动化流程,支持:
6
+
7
+ - 使用学号和统一认证密码登录。
8
+ - 先 GET 登录页,提取 `execution` 和 `pwdEncryptSalt`。
9
+ - 使用 AES-128-CBC / PKCS7 生成提交用密码密文。
10
+ - 检测账号密码错误、验证码要求和短信二次验证。
11
+ - 通过回调提交短信验证码。
12
+ - 保存并复用 Cookie,减少重复登录。
13
+ - 默认服务地址为 `http://jw3.njau.edu.cn/`。
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Basic Usage
22
+
23
+ ```python
24
+ import asyncio
25
+ from njau_auth import NJAUAuthManager
26
+
27
+
28
+ async def sms_callback(challenge):
29
+ print(challenge.message)
30
+ return input("SMS code: ").strip()
31
+
32
+
33
+ async def main():
34
+ manager = NJAUAuthManager(
35
+ student_id="2023000000",
36
+ password="your-password",
37
+ sms_callback=sms_callback,
38
+ )
39
+
40
+ async with manager:
41
+ result = await manager.login()
42
+ print(result.final_url)
43
+ print(result.cookies)
44
+
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ ```bash
52
+ njau-auth-login --student-id 2023000000
53
+ ```
54
+
55
+ 如果不传 `--password`,命令会从交互式密码输入读取。
56
+
57
+ ## Notes
58
+
59
+ - 默认密码密文使用当前 CAS 可接受的形态:`pwdEncryptSalt` 作为 AES key、固定 16 字节 IV、`64 位随机前缀 + 原始密码` 作为明文。
60
+ - `utils.crypto` 里保留了固定 key 的兼容函数 `encrypt_password_with_fixed_key()`,用于兼容其他部署或后续验证。
61
+ - 如果要认证其他 CAS 服务,可在 `NJAUAuthClient` 或 `NJAUAuthManager` 中传入 `service_url` 和 `success_url_contains`。
62
+ - 当统一认证要求图形验证码或滑块验证码时,当前版本会直接抛出 `CaptchaRequiredError`,避免误判或卡死。
@@ -0,0 +1,24 @@
1
+ import asyncio
2
+
3
+ from njau_auth import NJAUAuthManager
4
+
5
+
6
+ async def sms_callback(challenge):
7
+ print(challenge.message)
8
+ return input("SMS code: ").strip()
9
+
10
+
11
+ async def main():
12
+ async with NJAUAuthManager(
13
+ student_id="2023000000",
14
+ password="your-password",
15
+ sms_callback=sms_callback,
16
+ ) as manager:
17
+ result = await manager.login()
18
+ print(result.final_url)
19
+ print(result.token)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ asyncio.run(main())
24
+
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "njau-auth"
3
+ version = "0.1.0"
4
+ description = "NJAU CAS authentication helper"
5
+ readme = "README.md"
6
+ authors = [{ name = "leecyang" }]
7
+ license = { text = "MIT" }
8
+ keywords = ["njau", "cas", "auth", "httpx"]
9
+ requires-python = ">=3.10"
10
+ dependencies = ["httpx>=0.28.1", "pycryptodome>=3.20.0"]
11
+
12
+ [project.scripts]
13
+ njau-auth-login = "njau_auth.cli:main"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/leecyang/NJAU-Auth"
17
+ Repository = "https://github.com/leecyang/NJAU-Auth"
18
+ Issues = "https://github.com/leecyang/NJAU-Auth/issues"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=8.0.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/njau_auth"]
36
+
37
+ [tool.pytest.ini_options]
38
+ pythonpath = ["src"]
39
+ testpaths = ["tests"]
40
+ python_files = ["test_*.py"]
41
+ python_classes = ["Test*"]
42
+ python_functions = ["test_*"]
43
+ addopts = "--verbose --strict-markers"
@@ -0,0 +1,25 @@
1
+ from .auth_client import NJAUAuthClient
2
+ from .auth_manager import AuthStorage, JsonFileAuthStorage, NJAUAuthManager
3
+ from .exceptions import (
4
+ CaptchaRequiredError,
5
+ CASFormError,
6
+ InvalidCredentialsError,
7
+ NJAUAuthError,
8
+ SMSRequiredError,
9
+ )
10
+ from .models import LoginResult, PageState, SMSChallenge
11
+
12
+ __all__ = [
13
+ "AuthStorage",
14
+ "CaptchaRequiredError",
15
+ "CASFormError",
16
+ "InvalidCredentialsError",
17
+ "JsonFileAuthStorage",
18
+ "LoginResult",
19
+ "NJAUAuthClient",
20
+ "NJAUAuthError",
21
+ "NJAUAuthManager",
22
+ "PageState",
23
+ "SMSChallenge",
24
+ "SMSRequiredError",
25
+ ]
@@ -0,0 +1,325 @@
1
+ import inspect
2
+ import re
3
+ from typing import Any, Awaitable, Callable
4
+ from urllib.parse import urlencode, urlparse, urlunparse
5
+
6
+ import httpx
7
+
8
+ from .exceptions import CaptchaRequiredError, InvalidCredentialsError, NJAUAuthError
9
+ from .models import LoginResult, SMSChallenge
10
+ from .utils.crypto import DEFAULT_AES_IV, encrypt_password
11
+ from .utils.parse import (
12
+ extract_error_text,
13
+ extract_login_page,
14
+ has_captcha_challenge,
15
+ has_sms_challenge,
16
+ is_student_id,
17
+ )
18
+
19
+ DEFAULT_BASE_URL = "https://authserver.njau.edu.cn"
20
+ DEFAULT_SERVICE_URL = "http://jw3.njau.edu.cn/"
21
+ DEFAULT_SUCCESS_URL_CONTAINS = "jw"
22
+ DEFAULT_TOKEN_STORAGE_KEY = None
23
+ DEFAULT_USER_AGENT = (
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
25
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
26
+ )
27
+
28
+ SMSCallback = Callable[[SMSChallenge], str | Awaitable[str]]
29
+
30
+
31
+ async def _default_sms_callback(challenge: SMSChallenge) -> str:
32
+ print(challenge.message)
33
+ return input("Please enter SMS code: ").strip()
34
+
35
+
36
+ class NJAUAuthClient:
37
+ """Pure HTTP NJAU CAS client."""
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ service_url: str = DEFAULT_SERVICE_URL,
44
+ success_url_contains: str = DEFAULT_SUCCESS_URL_CONTAINS,
45
+ token_storage_key: str | None = DEFAULT_TOKEN_STORAGE_KEY,
46
+ timeout: float = 30.0,
47
+ headers: dict[str, str] | None = None,
48
+ aes_iv: str = DEFAULT_AES_IV,
49
+ ):
50
+ self.base_url = base_url.rstrip("/")
51
+ self.service_url = service_url
52
+ self.success_url_contains = success_url_contains
53
+ self.token_storage_key = token_storage_key
54
+ self.timeout = timeout
55
+ self.aes_iv = aes_iv
56
+ self._headers = {
57
+ "User-Agent": DEFAULT_USER_AGENT,
58
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
59
+ **(headers or {}),
60
+ }
61
+ self._client: httpx.AsyncClient | None = None
62
+
63
+ async def __aenter__(self) -> "NJAUAuthClient":
64
+ await self.open()
65
+ return self
66
+
67
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
68
+ await self.close()
69
+
70
+ @property
71
+ def client(self) -> httpx.AsyncClient:
72
+ if self._client is None:
73
+ raise RuntimeError("Client is not open. Call open() first.")
74
+ return self._client
75
+
76
+ async def open(self) -> None:
77
+ if self._client is not None:
78
+ return
79
+ self._client = httpx.AsyncClient(
80
+ headers=self._headers,
81
+ follow_redirects=True,
82
+ timeout=self.timeout,
83
+ )
84
+
85
+ async def close(self) -> None:
86
+ if self._client is not None:
87
+ await self._client.aclose()
88
+ self._client = None
89
+
90
+ def get_cookies(self) -> dict[str, str]:
91
+ return dict(self.client.cookies)
92
+
93
+ def load_cookies(self, cookies: dict[str, str]) -> None:
94
+ self.client.cookies.update(cookies)
95
+
96
+ async def resume(self) -> LoginResult | None:
97
+ await self.open()
98
+ response = await self.client.get(self.login_url)
99
+ if self._is_success(response):
100
+ return self._result(response)
101
+ return None
102
+
103
+ async def login(
104
+ self,
105
+ student_id: str,
106
+ password: str,
107
+ *,
108
+ sms_callback: SMSCallback | None = None,
109
+ captcha: str = "",
110
+ clear_existing_state: bool = True,
111
+ ) -> LoginResult:
112
+ if not is_student_id(student_id):
113
+ raise ValueError("student_id must be 4-32 letters or digits")
114
+ if not password:
115
+ raise ValueError("password must not be empty")
116
+
117
+ await self.open()
118
+ if clear_existing_state:
119
+ self.client.cookies.clear()
120
+
121
+ login_response = await self.client.get(self.login_url)
122
+ login_response.raise_for_status()
123
+ page = extract_login_page(
124
+ login_response.text,
125
+ str(login_response.url),
126
+ base_url=self.base_url,
127
+ )
128
+ encrypted = encrypt_password(password, page.pwd_encrypt_salt, iv=self.aes_iv)
129
+
130
+ data = {
131
+ "username": student_id,
132
+ "password": encrypted,
133
+ "captcha": captcha,
134
+ "_eventId": page.fields.get("_eventId", "submit"),
135
+ "cllt": "userNameLogin",
136
+ "dllt": page.fields.get("dllt", "generalLogin"),
137
+ "lt": page.fields.get("lt", ""),
138
+ "execution": page.execution,
139
+ }
140
+ response = await self.client.post(
141
+ self._with_service(page.action),
142
+ data=data,
143
+ headers={
144
+ "Origin": self.base_url,
145
+ "Referer": str(login_response.url),
146
+ "Content-Type": "application/x-www-form-urlencoded",
147
+ },
148
+ )
149
+ return await self._handle_login_response(
150
+ response,
151
+ sms_callback=sms_callback or _default_sms_callback,
152
+ )
153
+
154
+ async def _handle_login_response(
155
+ self,
156
+ response: httpx.Response,
157
+ *,
158
+ sms_callback: SMSCallback,
159
+ ) -> LoginResult:
160
+ if self._is_success(response):
161
+ return self._result(response)
162
+
163
+ error_text = extract_error_text(response.text)
164
+ if has_captcha_challenge(response.text, error_text):
165
+ raise CaptchaRequiredError(error_text or "CAS requires captcha verification")
166
+ if self._is_invalid_credentials(error_text):
167
+ raise InvalidCredentialsError(error_text)
168
+ if has_sms_challenge(response.text, str(response.url)):
169
+ response = await self._complete_sms(response, sms_callback)
170
+ if self._is_success(response):
171
+ return self._result(response)
172
+ error_text = extract_error_text(response.text)
173
+ raise NJAUAuthError("CAS_SMS_FAILED", error_text or "SMS verification failed")
174
+ if error_text:
175
+ raise NJAUAuthError("CAS_LOGIN_FAILED", error_text)
176
+ raise NJAUAuthError("CAS_LOGIN_FAILED", "CAS login did not reach the target service")
177
+
178
+ async def _complete_sms(
179
+ self,
180
+ response: httpx.Response,
181
+ sms_callback: SMSCallback,
182
+ ) -> httpx.Response:
183
+ send_message = await self._try_send_sms_code(response)
184
+ for attempt in range(1, 4):
185
+ code = await self._call_sms_callback(
186
+ sms_callback,
187
+ SMSChallenge(
188
+ attempt=attempt,
189
+ expires_at=0,
190
+ message=send_message or "Enter the 6-digit SMS code sent by NJAU CAS",
191
+ ),
192
+ )
193
+ if not code.isdigit() or len(code) != 6:
194
+ raise ValueError("SMS code must be exactly 6 digits")
195
+
196
+ data = self._sms_form_data(response.text)
197
+ data["dynamicCode"] = code
198
+ submit_url = self._sms_submit_url(response)
199
+ response = await self.client.post(
200
+ submit_url,
201
+ data=data,
202
+ headers={
203
+ "Origin": self.base_url,
204
+ "Referer": str(response.url),
205
+ "Content-Type": "application/x-www-form-urlencoded",
206
+ },
207
+ )
208
+ if self._is_success(response):
209
+ return response
210
+ error_text = extract_error_text(response.text)
211
+ if attempt == 3 or not has_sms_challenge(response.text, str(response.url)):
212
+ raise NJAUAuthError("CAS_SMS_FAILED", error_text or "SMS verification failed")
213
+ return response
214
+
215
+ async def _try_send_sms_code(self, response: httpx.Response) -> str:
216
+ candidates = self._sms_send_candidates(response)
217
+ last_error = ""
218
+ for url, data in candidates:
219
+ try:
220
+ sent = await self.client.post(
221
+ url,
222
+ data=data,
223
+ headers={
224
+ "Origin": self.base_url,
225
+ "Referer": str(response.url),
226
+ "X-Requested-With": "XMLHttpRequest",
227
+ },
228
+ )
229
+ if sent.status_code >= 400:
230
+ continue
231
+ payload = self._json_or_text(sent)
232
+ if isinstance(payload, dict):
233
+ message = str(payload.get("message") or payload.get("msg") or payload.get("info") or "")
234
+ code = str(payload.get("code") or "")
235
+ if code.lower() in {"success", "ok", "200"} or payload.get("success") is True:
236
+ return message
237
+ last_error = message or code
238
+ elif "success" in payload.lower() or "已发送" in payload:
239
+ return payload
240
+ except httpx.HTTPError as exc:
241
+ last_error = str(exc)
242
+ if last_error:
243
+ return last_error
244
+ return "SMS send endpoint was not confirmed; enter the code if it was sent"
245
+
246
+ def _sms_send_candidates(self, response: httpx.Response) -> list[tuple[str, dict[str, str]]]:
247
+ html = response.text
248
+ candidates: list[tuple[str, dict[str, str]]] = []
249
+ for match in set(
250
+ re_match
251
+ for re_match in re.findall(r'["\']([^"\']*dynamicCode[^"\']*?\.htl)["\']', html)
252
+ ):
253
+ candidates.append((httpx.URL(str(response.url)).join(match).__str__(), {}))
254
+ candidates.extend(
255
+ [
256
+ (f"{self.base_url}/authserver/reAuth/getDynamicCode.htl", {}),
257
+ (f"{self.base_url}/authserver/reAuth/sendDynamicCode.htl", {}),
258
+ (f"{self.base_url}/authserver/dynamicCode/getDynamicCode.htl", {}),
259
+ ]
260
+ )
261
+ return candidates
262
+
263
+ def _sms_form_data(self, html: str) -> dict[str, str]:
264
+ from .utils.parse import parse_forms
265
+
266
+ forms = parse_forms(html)
267
+ form = forms.get("pwdFromId") or forms.get("phoneFromId") or next(iter(forms.values()), None)
268
+ fields = dict(form["inputs"]) if form else {}
269
+ fields.setdefault("_eventId", "submit")
270
+ fields.setdefault("cllt", "userNameLogin")
271
+ fields.setdefault("dllt", "generalLogin")
272
+ fields.setdefault("lt", "")
273
+ return fields
274
+
275
+ def _sms_submit_url(self, response: httpx.Response) -> str:
276
+ from .utils.parse import parse_forms
277
+
278
+ forms = parse_forms(response.text)
279
+ form = forms.get("pwdFromId") or forms.get("phoneFromId") or next(iter(forms.values()), None)
280
+ action = form["attrs"].get("action") if form else "/authserver/login"
281
+ return self._with_service(httpx.URL(str(response.url)).join(action or "/authserver/login").__str__())
282
+
283
+ def _is_success(self, response: httpx.Response) -> bool:
284
+ url = str(response.url)
285
+ if "authserver.njau.edu.cn" not in url and self.success_url_contains in url:
286
+ return True
287
+ return "xsMain.jsp" in url or "ticket=ST-" in url
288
+
289
+ def _result(self, response: httpx.Response) -> LoginResult:
290
+ return LoginResult(
291
+ final_url=str(response.url),
292
+ token=None,
293
+ cookies=self.get_cookies(),
294
+ storage_state={"cookies": self.get_cookies()},
295
+ html=response.text,
296
+ )
297
+
298
+ def _with_service(self, url: str) -> str:
299
+ parsed = urlparse(url)
300
+ query = parsed.query
301
+ if "service=" not in query:
302
+ query = f"{query}&{urlencode({'service': self.service_url})}" if query else urlencode({"service": self.service_url})
303
+ return urlunparse(parsed._replace(query=query))
304
+
305
+ @property
306
+ def login_url(self) -> str:
307
+ return f"{self.base_url}/authserver/login?{urlencode({'service': self.service_url})}"
308
+
309
+ @staticmethod
310
+ def _json_or_text(response: httpx.Response) -> Any:
311
+ try:
312
+ return response.json()
313
+ except ValueError:
314
+ return response.text
315
+
316
+ @staticmethod
317
+ def _is_invalid_credentials(error_text: str) -> bool:
318
+ return any(word in error_text for word in ["用户名", "密码错误", "账号", "凭证错误"])
319
+
320
+ @staticmethod
321
+ async def _call_sms_callback(callback: SMSCallback, challenge: SMSChallenge) -> str:
322
+ value = callback(challenge)
323
+ if inspect.isawaitable(value):
324
+ value = await value
325
+ return str(value).strip()
@@ -0,0 +1,136 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any, Protocol
4
+
5
+ from .auth_client import (
6
+ DEFAULT_SERVICE_URL,
7
+ DEFAULT_SUCCESS_URL_CONTAINS,
8
+ DEFAULT_TOKEN_STORAGE_KEY,
9
+ NJAUAuthClient,
10
+ SMSCallback,
11
+ )
12
+ from .models import LoginResult
13
+
14
+
15
+ class AuthStorage(Protocol):
16
+ async def load_cookies(self, student_id: str) -> dict[str, str] | None:
17
+ ...
18
+
19
+ async def save_cookies(self, student_id: str, cookies: dict[str, str]) -> None:
20
+ ...
21
+
22
+ async def clear_cookies(self, student_id: str) -> None:
23
+ ...
24
+
25
+
26
+ class JsonFileAuthStorage:
27
+ def __init__(self, path: str | Path = "auth_session.json"):
28
+ self.path = Path(path)
29
+ self._data: dict[str, Any] = {}
30
+ self._load()
31
+
32
+ def _load(self) -> None:
33
+ if not self.path.exists():
34
+ return
35
+ try:
36
+ self._data = json.loads(self.path.read_text(encoding="utf-8"))
37
+ except (OSError, json.JSONDecodeError):
38
+ self._data = {}
39
+
40
+ def _save(self) -> None:
41
+ self.path.parent.mkdir(parents=True, exist_ok=True)
42
+ self.path.write_text(
43
+ json.dumps(self._data, ensure_ascii=False, indent=2),
44
+ encoding="utf-8",
45
+ )
46
+
47
+ async def load_cookies(self, student_id: str) -> dict[str, str] | None:
48
+ value = self._data.get("cookies", {}).get(student_id)
49
+ return value if isinstance(value, dict) else None
50
+
51
+ async def save_cookies(self, student_id: str, cookies: dict[str, str]) -> None:
52
+ self._data.setdefault("cookies", {})[student_id] = cookies
53
+ self._save()
54
+
55
+ async def clear_cookies(self, student_id: str) -> None:
56
+ self._data.get("cookies", {}).pop(student_id, None)
57
+ self._save()
58
+
59
+
60
+ class NJAUAuthManager:
61
+ def __init__(
62
+ self,
63
+ student_id: str,
64
+ password: str,
65
+ *,
66
+ sms_callback: SMSCallback | None = None,
67
+ storage: AuthStorage | None = None,
68
+ service_url: str = DEFAULT_SERVICE_URL,
69
+ success_url_contains: str = DEFAULT_SUCCESS_URL_CONTAINS,
70
+ token_storage_key: str | None = DEFAULT_TOKEN_STORAGE_KEY,
71
+ timeout: float = 30.0,
72
+ headers: dict[str, str] | None = None,
73
+ ):
74
+ self.student_id = student_id
75
+ self.password = password
76
+ self.sms_callback = sms_callback
77
+ self.storage = storage or JsonFileAuthStorage()
78
+ self.service_url = service_url
79
+ self.success_url_contains = success_url_contains
80
+ self.token_storage_key = token_storage_key
81
+ self.timeout = timeout
82
+ self.headers = headers or {}
83
+ self._client: NJAUAuthClient | None = None
84
+
85
+ async def __aenter__(self) -> "NJAUAuthManager":
86
+ return self
87
+
88
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
89
+ await self.close()
90
+
91
+ async def close(self) -> None:
92
+ if self._client is not None:
93
+ await self._client.close()
94
+ self._client = None
95
+
96
+ async def login(self, *, force_refresh: bool = False) -> LoginResult:
97
+ cookies = None
98
+ if not force_refresh:
99
+ cookies = await self.storage.load_cookies(self.student_id)
100
+
101
+ self._client = NJAUAuthClient(
102
+ service_url=self.service_url,
103
+ success_url_contains=self.success_url_contains,
104
+ token_storage_key=self.token_storage_key,
105
+ timeout=self.timeout,
106
+ headers=self.headers,
107
+ )
108
+ await self._client.open()
109
+ if cookies:
110
+ self._client.load_cookies(cookies)
111
+
112
+ if not force_refresh and cookies is not None:
113
+ resumed = await self._client.resume()
114
+ if resumed is not None:
115
+ return resumed
116
+ await self._client.close()
117
+ self._client = None
118
+ cookies = None
119
+
120
+ if self._client is None:
121
+ self._client = NJAUAuthClient(
122
+ service_url=self.service_url,
123
+ success_url_contains=self.success_url_contains,
124
+ token_storage_key=self.token_storage_key,
125
+ timeout=self.timeout,
126
+ headers=self.headers,
127
+ )
128
+
129
+ result = await self._client.login(
130
+ self.student_id,
131
+ self.password,
132
+ sms_callback=self.sms_callback,
133
+ clear_existing_state=force_refresh,
134
+ )
135
+ await self.storage.save_cookies(self.student_id, result.cookies)
136
+ return result
@@ -0,0 +1,46 @@
1
+ import argparse
2
+ import asyncio
3
+ import getpass
4
+
5
+ from .auth_manager import NJAUAuthManager
6
+
7
+
8
+ def _parser() -> argparse.ArgumentParser:
9
+ parser = argparse.ArgumentParser(description="Login to NJAU CAS")
10
+ parser.add_argument("--student-id", required=True)
11
+ parser.add_argument("--password")
12
+ parser.add_argument("--force-refresh", action="store_true")
13
+ parser.add_argument("--service-url")
14
+ return parser
15
+
16
+
17
+ async def _run(args: argparse.Namespace) -> None:
18
+ password = args.password or getpass.getpass("CAS password: ")
19
+
20
+ async def sms_callback(challenge):
21
+ print(challenge.message)
22
+ return input("SMS code: ").strip()
23
+
24
+ options = {}
25
+ if args.service_url:
26
+ options["service_url"] = args.service_url
27
+
28
+ async with NJAUAuthManager(
29
+ student_id=args.student_id,
30
+ password=password,
31
+ sms_callback=sms_callback,
32
+ **options,
33
+ ) as manager:
34
+ result = await manager.login(force_refresh=args.force_refresh)
35
+ print(f"final_url={result.final_url}")
36
+ print(f"token={result.token or ''}")
37
+ print("cookies=" + "; ".join(f"{key}={value}" for key, value in result.cookies.items()))
38
+
39
+
40
+ def main() -> None:
41
+ args = _parser().parse_args()
42
+ asyncio.run(_run(args))
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
@@ -0,0 +1,27 @@
1
+ class NJAUAuthError(Exception):
2
+ """Base exception for NJAU authentication failures."""
3
+
4
+ def __init__(self, code: str, message: str, detail: str | None = None):
5
+ super().__init__(message)
6
+ self.code = code
7
+ self.detail = detail
8
+
9
+
10
+ class InvalidCredentialsError(NJAUAuthError):
11
+ def __init__(self, message: str = "Invalid student id or password"):
12
+ super().__init__("CAS_INVALID_CREDENTIALS", message)
13
+
14
+
15
+ class CaptchaRequiredError(NJAUAuthError):
16
+ def __init__(self, message: str = "CAS requires captcha verification"):
17
+ super().__init__("CAS_CAPTCHA_REQUIRED", message)
18
+
19
+
20
+ class SMSRequiredError(NJAUAuthError):
21
+ def __init__(self, message: str = "CAS requires SMS verification"):
22
+ super().__init__("CAS_SMS_REQUIRED", message)
23
+
24
+
25
+ class CASFormError(NJAUAuthError):
26
+ def __init__(self, message: str = "CAS login form is incomplete"):
27
+ super().__init__("CAS_FORM_ERROR", message)
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+
6
+ class PageState(str, Enum):
7
+ LOGIN = "LOGIN"
8
+ PASSWORD = "PASSWORD"
9
+ SMS = "SMS"
10
+ AUTHENTICATED = "AUTHENTICATED"
11
+ CAPTCHA = "CAPTCHA"
12
+ ERROR = "ERROR"
13
+ WAITING = "WAITING"
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class SMSChallenge:
18
+ attempt: int
19
+ expires_at: float
20
+ message: str
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class LoginResult:
25
+ final_url: str
26
+ token: str | None
27
+ cookies: dict[str, str]
28
+ storage_state: dict[str, Any]
29
+ html: str = ""
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class LoginPage:
34
+ url: str
35
+ action: str
36
+ execution: str
37
+ pwd_encrypt_salt: str
38
+ fields: dict[str, str]
@@ -0,0 +1,19 @@
1
+ from .crypto import (
2
+ DEFAULT_AES_IV,
3
+ DEFAULT_AES_KEY,
4
+ aes_128_cbc_pkcs7_base64,
5
+ encrypt_password,
6
+ encrypt_password_with_fixed_key,
7
+ )
8
+ from .parse import classify_sms_page_state, is_student_id, normalize_cas_error
9
+
10
+ __all__ = [
11
+ "DEFAULT_AES_IV",
12
+ "DEFAULT_AES_KEY",
13
+ "aes_128_cbc_pkcs7_base64",
14
+ "classify_sms_page_state",
15
+ "encrypt_password",
16
+ "encrypt_password_with_fixed_key",
17
+ "is_student_id",
18
+ "normalize_cas_error",
19
+ ]
@@ -0,0 +1,53 @@
1
+ import base64
2
+ import secrets
3
+
4
+ from Crypto.Cipher import AES
5
+ from Crypto.Util.Padding import pad
6
+
7
+ DEFAULT_AES_KEY = "gfsdiR2u0wBytBq7"
8
+ DEFAULT_AES_IV = "HDbk7NdBpFPpFrZR"
9
+ RANDOM_PREFIX_CHARS = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
10
+
11
+
12
+ def aes_128_cbc_pkcs7_base64(plaintext: str, key: str, iv: str = DEFAULT_AES_IV) -> str:
13
+ key_bytes = key.encode("utf-8")
14
+ iv_bytes = iv.encode("utf-8")
15
+ if len(key_bytes) != 16:
16
+ raise ValueError("AES-128-CBC key must be exactly 16 bytes")
17
+ if len(iv_bytes) != 16:
18
+ raise ValueError("AES-128-CBC IV must be exactly 16 bytes")
19
+ cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
20
+ ciphertext = cipher.encrypt(pad(plaintext.encode("utf-8"), AES.block_size))
21
+ return base64.b64encode(ciphertext).decode("ascii")
22
+
23
+
24
+ def random_prefix(length: int = 64) -> str:
25
+ return "".join(secrets.choice(RANDOM_PREFIX_CHARS) for _ in range(length))
26
+
27
+
28
+ def encrypt_password(password: str, pwd_encrypt_salt: str, *, iv: str = DEFAULT_AES_IV) -> str:
29
+ """Encrypt the password in the format accepted by the current NJAU CAS page.
30
+
31
+ The browser script calls encryptPassword(password, pwdEncryptSalt). Empirically
32
+ the server accepts AES-CBC with the dynamic page salt as the key, any 16-byte
33
+ IV, and a 64-character random prefix before the raw password.
34
+ """
35
+
36
+ return aes_128_cbc_pkcs7_base64(
37
+ random_prefix(64) + password,
38
+ key=pwd_encrypt_salt,
39
+ iv=iv,
40
+ )
41
+
42
+
43
+ def encrypt_password_with_fixed_key(
44
+ password: str,
45
+ pwd_encrypt_salt: str,
46
+ *,
47
+ key: str = DEFAULT_AES_KEY,
48
+ iv: str = DEFAULT_AES_IV,
49
+ ) -> str:
50
+ """Compatibility helper for fixed-key deployments: AES(salt + password)."""
51
+
52
+ return aes_128_cbc_pkcs7_base64(pwd_encrypt_salt + password, key=key, iv=iv)
53
+
@@ -0,0 +1,126 @@
1
+ import re
2
+ from html.parser import HTMLParser
3
+ from typing import Any
4
+ from urllib.parse import urljoin
5
+
6
+ from njau_auth.exceptions import CASFormError
7
+ from njau_auth.models import LoginPage, PageState
8
+
9
+
10
+ def is_student_id(value: str) -> bool:
11
+ return bool(re.fullmatch(r"[0-9A-Za-z]{4,32}", value or ""))
12
+
13
+
14
+ def normalize_cas_error(text: str) -> str:
15
+ value = re.sub(r"<[^>]+>", "", text or "")
16
+ return " ".join(value.split()).strip()
17
+
18
+
19
+ def classify_sms_page_state(
20
+ *,
21
+ url: str,
22
+ token: str | None = None,
23
+ input_visible: bool = False,
24
+ error_text: str = "",
25
+ success_url_contains: str = "",
26
+ ) -> PageState:
27
+ if success_url_contains and success_url_contains in url:
28
+ return PageState.AUTHENTICATED
29
+ if token:
30
+ return PageState.AUTHENTICATED
31
+ if error_text.strip():
32
+ return PageState.ERROR
33
+ if input_visible:
34
+ return PageState.SMS
35
+ return PageState.WAITING
36
+
37
+
38
+ class _FormParser(HTMLParser):
39
+ def __init__(self) -> None:
40
+ super().__init__()
41
+ self.forms: dict[str, dict[str, Any]] = {}
42
+ self._current_form_id: str | None = None
43
+
44
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
45
+ attr = {key: value or "" for key, value in attrs}
46
+ if tag.lower() == "form":
47
+ form_id = attr.get("id") or f"__form_{len(self.forms)}"
48
+ self._current_form_id = form_id
49
+ self.forms[form_id] = {
50
+ "attrs": attr,
51
+ "inputs": {},
52
+ }
53
+ return
54
+
55
+ if tag.lower() == "input" and self._current_form_id:
56
+ name = attr.get("name") or attr.get("id")
57
+ if name:
58
+ self.forms[self._current_form_id]["inputs"][name] = attr.get("value", "")
59
+
60
+ def handle_endtag(self, tag: str) -> None:
61
+ if tag.lower() == "form":
62
+ self._current_form_id = None
63
+
64
+
65
+ def parse_forms(html: str) -> dict[str, dict[str, Any]]:
66
+ parser = _FormParser()
67
+ parser.feed(html)
68
+ return parser.forms
69
+
70
+
71
+ def extract_login_page(html: str, url: str, *, base_url: str) -> LoginPage:
72
+ forms = parse_forms(html)
73
+ form = forms.get("pwdFromId")
74
+ if not form:
75
+ raise CASFormError("pwdFromId form was not found")
76
+
77
+ fields = dict(form["inputs"])
78
+ execution = fields.get("execution", "")
79
+ pwd_encrypt_salt = fields.get("pwdEncryptSalt", "")
80
+ if not execution:
81
+ raise CASFormError("execution field was not found")
82
+ if not pwd_encrypt_salt:
83
+ raise CASFormError("pwdEncryptSalt field was not found")
84
+
85
+ action = form["attrs"].get("action") or "/authserver/login"
86
+ return LoginPage(
87
+ url=url,
88
+ action=urljoin(base_url, action),
89
+ execution=execution,
90
+ pwd_encrypt_salt=pwd_encrypt_salt,
91
+ fields=fields,
92
+ )
93
+
94
+
95
+ def extract_error_text(html: str) -> str:
96
+ patterns = [
97
+ r'id=["\']showErrorTip["\'][^>]*>(.*?)</',
98
+ r'class=["\'][^"\']*(?:form-error|error|el-message)[^"\']*["\'][^>]*>(.*?)</',
99
+ ]
100
+ for pattern in patterns:
101
+ match = re.search(pattern, html, re.I | re.S)
102
+ if match:
103
+ text = normalize_cas_error(match.group(1))
104
+ if text:
105
+ return text
106
+ return ""
107
+
108
+
109
+ def has_sms_challenge(html: str, url: str) -> bool:
110
+ needles = [
111
+ "dynamicCode",
112
+ "getDynamicCode",
113
+ "短信验证码",
114
+ "reAuthCheck",
115
+ "reAuthLoginView",
116
+ ]
117
+ return any(needle in html or needle in url for needle in needles)
118
+
119
+
120
+ def has_captcha_challenge(html: str, error_text: str = "") -> bool:
121
+ if "sliderCaptchaDiv" in html:
122
+ return True
123
+ if "captchaDiv" in html and "getCaptcha.htl" in html:
124
+ return True
125
+ return any(word in error_text for word in ["验证码", "图形动态码", "滑块"])
126
+
@@ -0,0 +1,105 @@
1
+ from njau_auth.models import PageState
2
+ from njau_auth.utils import (
3
+ aes_128_cbc_pkcs7_base64,
4
+ classify_sms_page_state,
5
+ encrypt_password_with_fixed_key,
6
+ is_student_id,
7
+ normalize_cas_error,
8
+ )
9
+ from njau_auth.utils.parse import extract_login_page
10
+
11
+
12
+ def test_is_student_id_accepts_letters_and_digits():
13
+ assert is_student_id("2023000000") is True
14
+ assert is_student_id("A1234567") is True
15
+
16
+
17
+ def test_is_student_id_rejects_bad_values():
18
+ assert is_student_id("") is False
19
+ assert is_student_id("123") is False
20
+ assert is_student_id("2023-000") is False
21
+
22
+
23
+ def test_classify_authenticated_when_success_url_and_token():
24
+ assert (
25
+ classify_sms_page_state(
26
+ url="https://libyy.njau.edu.cn/student/studentIndex",
27
+ token="token",
28
+ input_visible=False,
29
+ error_text="",
30
+ success_url_contains="/student/studentIndex",
31
+ )
32
+ is PageState.AUTHENTICATED
33
+ )
34
+
35
+
36
+ def test_classify_error_before_waiting():
37
+ assert (
38
+ classify_sms_page_state(
39
+ url="https://authserver.njau.edu.cn/authserver/reAuthLoginView",
40
+ token=None,
41
+ input_visible=True,
42
+ error_text=" 验证码错误 ",
43
+ success_url_contains="/student/studentIndex",
44
+ )
45
+ is PageState.ERROR
46
+ )
47
+
48
+
49
+ def test_classify_sms_waiting():
50
+ assert (
51
+ classify_sms_page_state(
52
+ url="https://authserver.njau.edu.cn/authserver/reAuthLoginView",
53
+ token=None,
54
+ input_visible=True,
55
+ error_text="",
56
+ success_url_contains="/student/studentIndex",
57
+ )
58
+ is PageState.SMS
59
+ )
60
+
61
+
62
+ def test_normalize_cas_error_collapses_whitespace():
63
+ assert normalize_cas_error(" 用户名或\n密码错误\t") == "用户名或 密码错误"
64
+
65
+
66
+ def test_extract_login_page_from_pwd_form():
67
+ html = """
68
+ <form id="pwdFromId" action="/authserver/login">
69
+ <input id="username" name="username" value="">
70
+ <input id="saltPassword" name="password" type="hidden">
71
+ <input id="_eventId" name="_eventId" value="submit">
72
+ <input id="cllt" name="cllt" value="userNameLogin">
73
+ <input id="dllt" name="dllt" value="generalLogin">
74
+ <input id="lt" name="lt" value="">
75
+ <input id="pwdEncryptSalt" value="abcdefghijklmnop">
76
+ <input id="execution" name="execution" value="exec-token">
77
+ </form>
78
+ """
79
+ page = extract_login_page(
80
+ html,
81
+ "https://authserver.njau.edu.cn/authserver/login",
82
+ base_url="https://authserver.njau.edu.cn",
83
+ )
84
+ assert page.action == "https://authserver.njau.edu.cn/authserver/login"
85
+ assert page.execution == "exec-token"
86
+ assert page.pwd_encrypt_salt == "abcdefghijklmnop"
87
+
88
+
89
+ def test_aes_128_cbc_pkcs7_base64_is_deterministic_for_fixed_inputs():
90
+ encrypted = aes_128_cbc_pkcs7_base64(
91
+ "salt-password",
92
+ key="gfsdiR2u0wBytBq7",
93
+ iv="HDbk7NdBpFPpFrZR",
94
+ )
95
+ assert encrypted == "CrlQs5cBatFsWRtEppXJjg=="
96
+
97
+
98
+ def test_fixed_key_compatibility_helper_uses_salt_plus_password():
99
+ encrypted = encrypt_password_with_fixed_key(
100
+ "password",
101
+ "salt-",
102
+ key="gfsdiR2u0wBytBq7",
103
+ iv="HDbk7NdBpFPpFrZR",
104
+ )
105
+ assert encrypted == "CrlQs5cBatFsWRtEppXJjg=="