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.
- njau_auth-0.1.0/.gitignore +15 -0
- njau_auth-0.1.0/LICENSE +22 -0
- njau_auth-0.1.0/PKG-INFO +80 -0
- njau_auth-0.1.0/README.md +62 -0
- njau_auth-0.1.0/examples/basic_usage.py +24 -0
- njau_auth-0.1.0/pyproject.toml +43 -0
- njau_auth-0.1.0/src/njau_auth/__init__.py +25 -0
- njau_auth-0.1.0/src/njau_auth/auth_client.py +325 -0
- njau_auth-0.1.0/src/njau_auth/auth_manager.py +136 -0
- njau_auth-0.1.0/src/njau_auth/cli.py +46 -0
- njau_auth-0.1.0/src/njau_auth/exceptions.py +27 -0
- njau_auth-0.1.0/src/njau_auth/models.py +38 -0
- njau_auth-0.1.0/src/njau_auth/utils/__init__.py +19 -0
- njau_auth-0.1.0/src/njau_auth/utils/crypto.py +53 -0
- njau_auth-0.1.0/src/njau_auth/utils/parse.py +126 -0
- njau_auth-0.1.0/tests/test_parse_utils.py +105 -0
njau_auth-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
|
njau_auth-0.1.0/PKG-INFO
ADDED
|
@@ -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=="
|