pro-sejongpeia 1.2.30__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.
- pro_sejongpeia-1.2.30/.gitignore +91 -0
- pro_sejongpeia-1.2.30/LICENSE +21 -0
- pro_sejongpeia-1.2.30/PKG-INFO +75 -0
- pro_sejongpeia-1.2.30/README.md +56 -0
- pro_sejongpeia-1.2.30/pyproject.toml +34 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/__init__.py +61 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/client.py +207 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/errors.py +43 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/models.py +141 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/parsers.py +265 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/portal_main_parser.py +67 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/public_data.py +369 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/public_data_models.py +160 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/py.typed +0 -0
- pro_sejongpeia-1.2.30/src/sejongpeia/transport.py +68 -0
- pro_sejongpeia-1.2.30/tests/conftest.py +59 -0
- pro_sejongpeia-1.2.30/tests/test_client.py +211 -0
- pro_sejongpeia-1.2.30/tests/test_contract.py +40 -0
- pro_sejongpeia-1.2.30/tests/test_dependency_rules.py +27 -0
- pro_sejongpeia-1.2.30/tests/test_live.py +31 -0
- pro_sejongpeia-1.2.30/tests/test_parsers.py +104 -0
- pro_sejongpeia-1.2.30/tests/test_portal_main_parser.py +53 -0
- pro_sejongpeia-1.2.30/tests/test_public_data.py +270 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/.idea
|
|
2
|
+
/.claude/settings.local.json
|
|
3
|
+
/.report
|
|
4
|
+
|
|
5
|
+
# 작업 과정 문서 (projectops 이슈·보고서·점검 증거, superpowers 설계·계획) 는 추적하지 않음
|
|
6
|
+
# 사용자용 문서(docs/*.md, docs/assets)는 계속 추적
|
|
7
|
+
/docs/projectops/
|
|
8
|
+
/docs/superpowers/
|
|
9
|
+
|
|
10
|
+
# Gradle
|
|
11
|
+
.gradle/
|
|
12
|
+
build/
|
|
13
|
+
!gradle/wrapper/gradle-wrapper.jar
|
|
14
|
+
!gradle/wrapper/gradle-wrapper.properties
|
|
15
|
+
gradle.properties
|
|
16
|
+
|
|
17
|
+
# Compiled Files
|
|
18
|
+
*.class
|
|
19
|
+
*.jar
|
|
20
|
+
!gradle/wrapper/gradle-wrapper.jar
|
|
21
|
+
|
|
22
|
+
# IDE - IntelliJ IDEA
|
|
23
|
+
*.iws
|
|
24
|
+
*.iml
|
|
25
|
+
*.ipr
|
|
26
|
+
out/
|
|
27
|
+
!**/src/main/**/out/
|
|
28
|
+
!**/src/test/**/out/
|
|
29
|
+
|
|
30
|
+
# IDE - Eclipse
|
|
31
|
+
.classpath
|
|
32
|
+
.project
|
|
33
|
+
.settings/
|
|
34
|
+
bin/
|
|
35
|
+
!**/src/main/**/bin/
|
|
36
|
+
!**/src/test/**/bin/
|
|
37
|
+
|
|
38
|
+
# IDE - VS Code
|
|
39
|
+
.vscode/
|
|
40
|
+
|
|
41
|
+
# IDE - NetBeans
|
|
42
|
+
/nbproject/private/
|
|
43
|
+
/nbbuild/
|
|
44
|
+
/dist/
|
|
45
|
+
/nbdist/
|
|
46
|
+
/.nbgradle/
|
|
47
|
+
|
|
48
|
+
# OS Files
|
|
49
|
+
.DS_Store
|
|
50
|
+
.DS_Store?
|
|
51
|
+
._*
|
|
52
|
+
.Spotlight-V100
|
|
53
|
+
.Trashes
|
|
54
|
+
ehthumbs.db
|
|
55
|
+
Thumbs.db
|
|
56
|
+
.AppleDouble
|
|
57
|
+
.LSOverride
|
|
58
|
+
|
|
59
|
+
# Logs
|
|
60
|
+
*.log
|
|
61
|
+
*.log.*
|
|
62
|
+
|
|
63
|
+
# Local Configuration
|
|
64
|
+
*.local
|
|
65
|
+
|
|
66
|
+
# Test Reports
|
|
67
|
+
.report/
|
|
68
|
+
|
|
69
|
+
# Test Configuration (민감 정보 포함)
|
|
70
|
+
**/src/test/resources/application-dev.yml
|
|
71
|
+
|
|
72
|
+
# Python (기존 항목 유지)
|
|
73
|
+
__pycache__/
|
|
74
|
+
*.py[cod]
|
|
75
|
+
*$py.class
|
|
76
|
+
*.so
|
|
77
|
+
|
|
78
|
+
# Python
|
|
79
|
+
python/.venv/
|
|
80
|
+
python/dist/
|
|
81
|
+
.pytest_cache/
|
|
82
|
+
*.egg-info/
|
|
83
|
+
|
|
84
|
+
# Node
|
|
85
|
+
node/node_modules/
|
|
86
|
+
node/dist/
|
|
87
|
+
*.tgz
|
|
88
|
+
|
|
89
|
+
# SDD workspace
|
|
90
|
+
.superpowers/
|
|
91
|
+
node/src/version.ts
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Suh Saechan
|
|
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,75 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pro-sejongpeia
|
|
3
|
+
Version: 1.2.30
|
|
4
|
+
Summary: Sejong University login API for Python: verify students with portal SSO in one line (세종대 로그인 API)
|
|
5
|
+
Project-URL: Homepage, https://cassiiopeia.github.io/pro-sejongpeia/
|
|
6
|
+
Project-URL: Source, https://github.com/Cassiiopeia/pro-sejongpeia
|
|
7
|
+
Project-URL: Issues, https://github.com/Cassiiopeia/pro-sejongpeia/issues
|
|
8
|
+
Author: Suh Saechan
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: authentication,sejong,sejong-university,sso,student-verification,세종대
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: Korean
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pro-sejongpeia (Python)
|
|
21
|
+
|
|
22
|
+
**세종대 로그인 API (Python)** · Sejong University login API
|
|
23
|
+
|
|
24
|
+
한국어 | [English](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md)
|
|
25
|
+
|
|
26
|
+
학번과 포털 비밀번호로 세종대 재학생 여부를 확인하고 학과, 학년, 고전독서 인증, 연락처를 가져옵니다. 표준 라이브러리 외 의존성이 없습니다.
|
|
27
|
+
|
|
28
|
+
**[▶ 브라우저에서 바로 체험하기](https://cassiiopeia.github.io/pro-sejongpeia/#demo)** · 호출 한 번에 포털 로그인 한 번 (약 0.8초) · Java·Node 패키지와 같은 결과 · 비밀번호가 틀리면 바로 멈춤 · 매일 실제 포털로 점검
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pro-sejongpeia
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from sejongpeia import authenticate, SejongAuthError, ErrorCode
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
result = authenticate("21011234", "비밀번호")
|
|
39
|
+
print(result.name, result.major, result.grade)
|
|
40
|
+
except SejongAuthError as error:
|
|
41
|
+
if error.code == ErrorCode.AUTHENTICATION_FAILED:
|
|
42
|
+
print("학번 또는 비밀번호가 틀렸습니다")
|
|
43
|
+
elif error.code == ErrorCode.ACCOUNT_LOCKED:
|
|
44
|
+
print("포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| 함수 | 반환 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `authenticate(id, pw)` | 학생 정보 + 고전독서 + 연락처 (연락처 조회가 실패하면 연락처만 `None`) |
|
|
50
|
+
| `authenticate_dhc(id, pw, include_raw=False)` | 학생 정보 + 고전독서 |
|
|
51
|
+
| `authenticate_sis(id, pw, include_raw=False)` | 학생 정보 + 이메일, 전화번호, 영문 이름 |
|
|
52
|
+
|
|
53
|
+
### 대신 해결해 주는 것
|
|
54
|
+
|
|
55
|
+
직접 구현하려면 모두 풀어야 하는 부분입니다.
|
|
56
|
+
|
|
57
|
+
- 최신 환경의 기본 설정이 막는, 포털이 유일하게 허용하는 암호(`TLS_RSA_WITH_AES_256_CBC_SHA`)를 Python `ssl` 설정으로 자동 허용
|
|
58
|
+
- 방화벽이 막는 기본 User-Agent 대신 식별 가능한 User-Agent 전송
|
|
59
|
+
- 여러 세종대 도메인에 걸친 SSO 쿠키와 리다이렉트 처리
|
|
60
|
+
- 200 응답 안의 판정으로 비밀번호 오류, 계정 잠금, 이용 불가를 즉시 구분 (틀린 비밀번호는 재시도하지 않음)
|
|
61
|
+
- DHC의 HTML 표와 SIS의 JSON을 타입이 있는 결과로 변환
|
|
62
|
+
|
|
63
|
+
설정: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
|
|
64
|
+
|
|
65
|
+
에러 코드(`error.code`): `AUTHENTICATION_FAILED`, `ACCOUNT_LOCKED`, `ACCOUNT_UNAVAILABLE`, `INVALID_INPUT`, `CONNECTION_FAILED`, `CONNECTION_TIMEOUT`, `SESSION_ERROR`, `DATA_FETCH_FAILED`, `PARSE_ERROR`, `SSL_CONFIGURATION_ERROR`. 메시지가 아니라 코드로 분기하세요.
|
|
66
|
+
|
|
67
|
+
> **계정 잠금.** 세종대 포털은 비밀번호를 여러 번 틀리면(약 5회) 계정을 잠급니다. <https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp> 에서 비밀번호를 재설정해야 풀립니다. 라이브러리는 거부된 로그인을 재시도하지 않습니다. 누구나 학번을 입력할 수 있는 서비스라면 학번별 실패 횟수를 제한하세요.
|
|
68
|
+
|
|
69
|
+
Python 3.9 이상. 세종대학교 공식 제품이 아닙니다.
|
|
70
|
+
|
|
71
|
+
문서, 라이브 데모, Java·Node 버전: https://cassiiopeia.github.io/pro-sejongpeia/
|
|
72
|
+
|
|
73
|
+
## English
|
|
74
|
+
|
|
75
|
+
Verify a Sejong University student with a student ID and portal password, and get their major, year, classic-reading certification and contact info. The API above is the same in English; error messages are in English. Full English docs: [README.en.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# pro-sejongpeia (Python)
|
|
2
|
+
|
|
3
|
+
**세종대 로그인 API (Python)** · Sejong University login API
|
|
4
|
+
|
|
5
|
+
한국어 | [English](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md)
|
|
6
|
+
|
|
7
|
+
학번과 포털 비밀번호로 세종대 재학생 여부를 확인하고 학과, 학년, 고전독서 인증, 연락처를 가져옵니다. 표준 라이브러리 외 의존성이 없습니다.
|
|
8
|
+
|
|
9
|
+
**[▶ 브라우저에서 바로 체험하기](https://cassiiopeia.github.io/pro-sejongpeia/#demo)** · 호출 한 번에 포털 로그인 한 번 (약 0.8초) · Java·Node 패키지와 같은 결과 · 비밀번호가 틀리면 바로 멈춤 · 매일 실제 포털로 점검
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pro-sejongpeia
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from sejongpeia import authenticate, SejongAuthError, ErrorCode
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
result = authenticate("21011234", "비밀번호")
|
|
20
|
+
print(result.name, result.major, result.grade)
|
|
21
|
+
except SejongAuthError as error:
|
|
22
|
+
if error.code == ErrorCode.AUTHENTICATION_FAILED:
|
|
23
|
+
print("학번 또는 비밀번호가 틀렸습니다")
|
|
24
|
+
elif error.code == ErrorCode.ACCOUNT_LOCKED:
|
|
25
|
+
print("포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| 함수 | 반환 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `authenticate(id, pw)` | 학생 정보 + 고전독서 + 연락처 (연락처 조회가 실패하면 연락처만 `None`) |
|
|
31
|
+
| `authenticate_dhc(id, pw, include_raw=False)` | 학생 정보 + 고전독서 |
|
|
32
|
+
| `authenticate_sis(id, pw, include_raw=False)` | 학생 정보 + 이메일, 전화번호, 영문 이름 |
|
|
33
|
+
|
|
34
|
+
### 대신 해결해 주는 것
|
|
35
|
+
|
|
36
|
+
직접 구현하려면 모두 풀어야 하는 부분입니다.
|
|
37
|
+
|
|
38
|
+
- 최신 환경의 기본 설정이 막는, 포털이 유일하게 허용하는 암호(`TLS_RSA_WITH_AES_256_CBC_SHA`)를 Python `ssl` 설정으로 자동 허용
|
|
39
|
+
- 방화벽이 막는 기본 User-Agent 대신 식별 가능한 User-Agent 전송
|
|
40
|
+
- 여러 세종대 도메인에 걸친 SSO 쿠키와 리다이렉트 처리
|
|
41
|
+
- 200 응답 안의 판정으로 비밀번호 오류, 계정 잠금, 이용 불가를 즉시 구분 (틀린 비밀번호는 재시도하지 않음)
|
|
42
|
+
- DHC의 HTML 표와 SIS의 JSON을 타입이 있는 결과로 변환
|
|
43
|
+
|
|
44
|
+
설정: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
|
|
45
|
+
|
|
46
|
+
에러 코드(`error.code`): `AUTHENTICATION_FAILED`, `ACCOUNT_LOCKED`, `ACCOUNT_UNAVAILABLE`, `INVALID_INPUT`, `CONNECTION_FAILED`, `CONNECTION_TIMEOUT`, `SESSION_ERROR`, `DATA_FETCH_FAILED`, `PARSE_ERROR`, `SSL_CONFIGURATION_ERROR`. 메시지가 아니라 코드로 분기하세요.
|
|
47
|
+
|
|
48
|
+
> **계정 잠금.** 세종대 포털은 비밀번호를 여러 번 틀리면(약 5회) 계정을 잠급니다. <https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp> 에서 비밀번호를 재설정해야 풀립니다. 라이브러리는 거부된 로그인을 재시도하지 않습니다. 누구나 학번을 입력할 수 있는 서비스라면 학번별 실패 횟수를 제한하세요.
|
|
49
|
+
|
|
50
|
+
Python 3.9 이상. 세종대학교 공식 제품이 아닙니다.
|
|
51
|
+
|
|
52
|
+
문서, 라이브 데모, Java·Node 버전: https://cassiiopeia.github.io/pro-sejongpeia/
|
|
53
|
+
|
|
54
|
+
## English
|
|
55
|
+
|
|
56
|
+
Verify a Sejong University student with a student ID and portal password, and get their major, year, classic-reading certification and contact info. The API above is the same in English; error messages are in English. Full English docs: [README.en.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pro-sejongpeia"
|
|
7
|
+
version = "1.2.30"
|
|
8
|
+
description = "Sejong University login API for Python: verify students with portal SSO in one line (세종대 로그인 API)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Suh Saechan" }]
|
|
14
|
+
keywords = ["sejong", "sejong-university", "세종대", "authentication", "sso", "student-verification"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Natural Language :: Korean",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://cassiiopeia.github.io/pro-sejongpeia/"
|
|
26
|
+
Source = "https://github.com/Cassiiopeia/pro-sejongpeia"
|
|
27
|
+
Issues = "https://github.com/Cassiiopeia/pro-sejongpeia/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/sejongpeia"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["src", "tests"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""세종대 로그인 API: 학번·비밀번호로 세종대학교 포털(SSO) 인증
|
|
2
|
+
|
|
3
|
+
from sejongpeia import authenticate
|
|
4
|
+
result = authenticate("21011234", "password")
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# 버전 단일 원본: pyproject.toml (projectops 가 version.yml 과 함께 갱신)
|
|
11
|
+
__version__ = version("pro-sejongpeia")
|
|
12
|
+
except PackageNotFoundError: # 설치 없이 소스로 실행 (테스트)
|
|
13
|
+
__version__ = "0.0.0-dev"
|
|
14
|
+
|
|
15
|
+
from .client import SejongAuthClient # noqa: E402
|
|
16
|
+
from .errors import ErrorCode, SejongAuthError # noqa: E402
|
|
17
|
+
from .models import ( # noqa: E402
|
|
18
|
+
ClassicCertification,
|
|
19
|
+
ClassicContestRecord,
|
|
20
|
+
ClassicExamRecord,
|
|
21
|
+
ClassicSubjectRecord,
|
|
22
|
+
SejongAuthResult,
|
|
23
|
+
SejongClassicReading,
|
|
24
|
+
SejongDhcAuthResult,
|
|
25
|
+
SejongSisAuthResult,
|
|
26
|
+
)
|
|
27
|
+
from .public_data import SejongPublicData # noqa: E402
|
|
28
|
+
from .public_data_models import ( # noqa: E402
|
|
29
|
+
CafeteriaMenuItem, CalendarEvent, CampusPlace, CourseType, Department, DormitoryMenuDay,
|
|
30
|
+
DormitoryNotice, DormitoryNotices, ExtracurricularEvent, Lecture, OrganizationCategory,
|
|
31
|
+
SelectiveDomain, Term,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_default_client = SejongAuthClient()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def authenticate(student_id: str, password: str) -> SejongAuthResult:
|
|
38
|
+
"""통합 인증 (기본 설정: 10초, 재시도 3회, 인증서 검증)"""
|
|
39
|
+
return _default_client.authenticate(student_id, password)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def authenticate_dhc(student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
|
|
43
|
+
"""DHC 인증: 학생 정보 + 고전독서"""
|
|
44
|
+
return _default_client.authenticate_dhc(student_id, password, include_raw=include_raw)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def authenticate_sis(student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
|
|
48
|
+
"""SIS 인증: 학생 정보 + 연락처"""
|
|
49
|
+
return _default_client.authenticate_sis(student_id, password, include_raw=include_raw)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"authenticate", "authenticate_dhc", "authenticate_sis", "SejongAuthClient",
|
|
54
|
+
"SejongAuthError", "ErrorCode",
|
|
55
|
+
"SejongAuthResult", "SejongDhcAuthResult", "SejongSisAuthResult", "SejongClassicReading",
|
|
56
|
+
"ClassicCertification", "ClassicExamRecord", "ClassicSubjectRecord", "ClassicContestRecord",
|
|
57
|
+
"SejongPublicData", "OrganizationCategory", "Term", "Department", "CourseType", "SelectiveDomain", "Lecture",
|
|
58
|
+
"CalendarEvent", "DormitoryMenuDay", "DormitoryNotice", "DormitoryNotices", "CafeteriaMenuItem",
|
|
59
|
+
"CampusPlace", "ExtracurricularEvent",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""세종대 포털 인증 클라이언트: 입력 검증 > 포털 로그인(재시도) > SSO > 데이터 조회 > 파싱 (흐름: spec/PORTAL_FLOW.md)"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import ssl
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import ErrorCode, SejongAuthError
|
|
13
|
+
from .models import SejongAuthResult, SejongDhcAuthResult, SejongSisAuthResult
|
|
14
|
+
from .parsers import ensure_portal_login_accepted, ensure_sis_logged_in, parse_classic_reading, parse_sis_user_info, parse_student_info
|
|
15
|
+
from .portal_main_parser import parse_parent_unit_name
|
|
16
|
+
from .transport import USER_AGENT, HttpResponse, UrllibSession, create_ssl_context
|
|
17
|
+
|
|
18
|
+
# 라이브러리 로거: 출력 방식은 사용하는 앱이 설정
|
|
19
|
+
LOGGER = logging.getLogger("sejongpeia")
|
|
20
|
+
|
|
21
|
+
PORTAL_LOGIN_URL = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"
|
|
22
|
+
PORTAL_REFERER = "https://portal.sejong.ac.kr"
|
|
23
|
+
# 포털이 요구하는 초기 쿠키 (없으면 로그인 폼 거부)
|
|
24
|
+
PORTAL_DEFAULT_COOKIE = "chknos=false"
|
|
25
|
+
|
|
26
|
+
# 로그인 후 SSO 대상 (rtUrl)
|
|
27
|
+
CLASSIC_RETURN_URL = "classic.sejong.ac.kr"
|
|
28
|
+
SIS_RETURN_URL = "sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
|
|
29
|
+
|
|
30
|
+
CLASSIC_SSO_URL = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp?returnUrl=https://classic.sejong.ac.kr/classic/index.do"
|
|
31
|
+
CLASSIC_STATUS_URL = "https://classic.sejong.ac.kr/classic/reading/status.do"
|
|
32
|
+
# 포털 메인 (소속 표기 조회), 세션 만료 시 돌아오는 로그인 페이지는 form 대상 문구로 판별
|
|
33
|
+
PORTAL_MAIN_URL = "https://portal.sejong.ac.kr/user/index.do"
|
|
34
|
+
PORTAL_LOGIN_PAGE_MARKER = "login_action.jsp"
|
|
35
|
+
SIS_SSO_URL = "https://sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
|
|
36
|
+
SIS_USER_INFO_URL = "https://sjpt.sejong.ac.kr/main/sys/UserInfo/initUserInfo.do"
|
|
37
|
+
SIS_ORIGIN = "https://sjpt.sejong.ac.kr"
|
|
38
|
+
# 브라우저 초기 호출과 같은 빈 addParam (JSON > URL 폼 인코딩 > Base64)
|
|
39
|
+
SIS_EMPTY_ADD_PARAM = base64.b64encode(
|
|
40
|
+
urllib.parse.quote_plus('{"_runIntgUsrNo":"","_runPgLoginDt":"","_runningSejong":""}').encode("utf-8")
|
|
41
|
+
).decode("ascii")
|
|
42
|
+
|
|
43
|
+
HTTP_UNAUTHORIZED = 401
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SejongAuthClient:
|
|
47
|
+
"""세종대 포털 인증 (설정: 제한 시간 초, 로그인 재시도 횟수, 인증서 검증)"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, timeout_seconds: float = 10, max_retry: int = 3, ssl_verification: bool = True,
|
|
50
|
+
_session_factory: Optional[Callable[[], object]] = None):
|
|
51
|
+
self.timeout_seconds = timeout_seconds
|
|
52
|
+
self.max_retry = max_retry
|
|
53
|
+
self.ssl_verification = ssl_verification
|
|
54
|
+
self._session_factory = _session_factory or self._create_session
|
|
55
|
+
|
|
56
|
+
def authenticate(self, student_id: str, password: str) -> SejongAuthResult:
|
|
57
|
+
"""통합 인증: 포털 로그인 1회 > DHC 학생 정보·고전독서 > 같은 세션으로 SIS 연락처, 포털 메인 상위 소속 (보조 조회 실패는 무시하고 None)"""
|
|
58
|
+
self._validate_credentials(student_id, password)
|
|
59
|
+
session = self._session_factory()
|
|
60
|
+
self._login(session, student_id, password, CLASSIC_RETURN_URL)
|
|
61
|
+
dhc_result = self._fetch_dhc_with_session(session, should_include_raw_html=False)
|
|
62
|
+
|
|
63
|
+
email = phone_number = english_name = user_category = None
|
|
64
|
+
try:
|
|
65
|
+
sis_user_info = parse_sis_user_info(self._fetch_sis_json_with_session(session))
|
|
66
|
+
email, phone_number, english_name = sis_user_info.email, sis_user_info.phone_number, sis_user_info.english_name
|
|
67
|
+
user_category = sis_user_info.user_category
|
|
68
|
+
except Exception as error: # SIS 는 연락처 보조 조회 > 어떤 실패든 통합 결과를 막지 않음 (Java 와 동일)
|
|
69
|
+
LOGGER.warning("[sejong auth] SIS contact lookup failed (ignored) : %s", type(error).__name__)
|
|
70
|
+
parent_unit_name = self._fetch_parent_unit_name(session, dhc_result.major)
|
|
71
|
+
|
|
72
|
+
return SejongAuthResult(
|
|
73
|
+
success=True, student_id=dhc_result.student_id, name=dhc_result.name, major=dhc_result.major,
|
|
74
|
+
grade=dhc_result.grade, status=dhc_result.status, classic_reading=dhc_result.classic_reading,
|
|
75
|
+
email=email, phone_number=phone_number, english_name=english_name, user_category=user_category,
|
|
76
|
+
parent_unit_name=parent_unit_name, authenticated_at=datetime.now(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def authenticate_dhc(self, student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
|
|
80
|
+
"""DHC 인증: 학생 정보 + 고전독서 (include_raw=True 면 원본 HTML 포함)"""
|
|
81
|
+
self._validate_credentials(student_id, password)
|
|
82
|
+
return self._fetch_dhc(student_id, password, should_include_raw_html=include_raw)
|
|
83
|
+
|
|
84
|
+
def authenticate_sis(self, student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
|
|
85
|
+
"""SIS 인증: 학생 정보 + 연락처 (학년·상태 빈 문자열, include_raw=True 면 원본 JSON 포함)"""
|
|
86
|
+
self._validate_credentials(student_id, password)
|
|
87
|
+
json_text = self._fetch_sis_json(student_id, password)
|
|
88
|
+
user_info = parse_sis_user_info(json_text)
|
|
89
|
+
return SejongSisAuthResult(
|
|
90
|
+
success=True, student_id=user_info.student_id, name=user_info.name, major=user_info.major,
|
|
91
|
+
grade="", status="", email=user_info.email, phone_number=user_info.phone_number,
|
|
92
|
+
english_name=user_info.english_name, user_category=user_info.user_category, authenticated_at=datetime.now(),
|
|
93
|
+
raw_json=json_text if include_raw else None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _fetch_dhc(self, student_id: str, password: str, should_include_raw_html: bool) -> SejongDhcAuthResult:
|
|
97
|
+
"""포털 로그인 > DHC 조회"""
|
|
98
|
+
session = self._session_factory()
|
|
99
|
+
self._login(session, student_id, password, CLASSIC_RETURN_URL)
|
|
100
|
+
return self._fetch_dhc_with_session(session, should_include_raw_html)
|
|
101
|
+
|
|
102
|
+
def _fetch_dhc_with_session(self, session, should_include_raw_html: bool) -> SejongDhcAuthResult:
|
|
103
|
+
"""로그인된 세션 > classic SSO > 고전독서 페이지 > 학생 정보·고전독서 파싱"""
|
|
104
|
+
self._expect_session_step(self._call(session, "GET", CLASSIC_SSO_URL), "SSO redirect failed")
|
|
105
|
+
html = self._expect_data(self._call(session, "GET", CLASSIC_STATUS_URL), "Classic reading page request failed")
|
|
106
|
+
student_info = parse_student_info(html)
|
|
107
|
+
return SejongDhcAuthResult(
|
|
108
|
+
success=True, student_id=student_info.student_id, name=student_info.name, major=student_info.major,
|
|
109
|
+
grade=student_info.grade, status=student_info.status, classic_reading=parse_classic_reading(html),
|
|
110
|
+
authenticated_at=datetime.now(), raw_html=html if should_include_raw_html else None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _fetch_sis_json(self, student_id: str, password: str) -> str:
|
|
114
|
+
"""포털 로그인 (rtUrl sjpt) > SIS 조회"""
|
|
115
|
+
session = self._session_factory()
|
|
116
|
+
self._login(session, student_id, password, SIS_RETURN_URL)
|
|
117
|
+
return self._fetch_sis_json_with_session(session)
|
|
118
|
+
|
|
119
|
+
def _fetch_sis_json_with_session(self, session) -> str:
|
|
120
|
+
"""로그인된 세션 > sjpt SSO > initUserInfo JSON"""
|
|
121
|
+
self._expect_session_step(
|
|
122
|
+
self._call(session, "GET", SIS_SSO_URL, headers={"Referer": PORTAL_REFERER}), "SIS SSO page request failed")
|
|
123
|
+
response = self._call(session, "POST", f"{SIS_USER_INFO_URL}?addParam={SIS_EMPTY_ADD_PARAM}", data=b"{}", headers={
|
|
124
|
+
"Accept": "application/json",
|
|
125
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
126
|
+
"Referer": SIS_SSO_URL,
|
|
127
|
+
"Origin": SIS_ORIGIN,
|
|
128
|
+
})
|
|
129
|
+
json_text = self._expect_data(response, "initUserInfo API request failed")
|
|
130
|
+
# 틀린 비밀번호는 401 이 아니라 200 + _SUBMIT_ERROR_(LOGOUT)
|
|
131
|
+
ensure_sis_logged_in(json_text)
|
|
132
|
+
return json_text
|
|
133
|
+
|
|
134
|
+
def _fetch_parent_unit_name(self, session, major: str) -> Optional[str]:
|
|
135
|
+
"""같은 세션으로 포털 메인 1회 조회 > 상위 소속명 (재시도 없음, 호출 실패·세션 만료·빈 본문은 None, 요소 없음은 빈 문자열)"""
|
|
136
|
+
try:
|
|
137
|
+
response = self._call(session, "GET", PORTAL_MAIN_URL, headers={"Referer": PORTAL_REFERER})
|
|
138
|
+
if not response.is_successful or not response.body:
|
|
139
|
+
return None
|
|
140
|
+
html = response.text()
|
|
141
|
+
if PORTAL_LOGIN_PAGE_MARKER in html:
|
|
142
|
+
return None
|
|
143
|
+
return parse_parent_unit_name(html, major)
|
|
144
|
+
except Exception as error: # 부가 정보 > 어떤 실패든 통합 결과를 막지 않음
|
|
145
|
+
LOGGER.warning("[sejong auth] portal main lookup failed (ignored) : %s", type(error).__name__)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _login(self, session, student_id: str, password: str, return_url: str) -> None:
|
|
149
|
+
"""포털 로그인 (실패 응답·시간 초과 시 max_retry 회까지) > 포털 판정값 확인 (틀린 비밀번호·잠금은 재시도 없이 즉시 실패)"""
|
|
150
|
+
form = urllib.parse.urlencode({"mainLogin": "N", "rtUrl": return_url, "id": student_id, "password": password})
|
|
151
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", "Referer": PORTAL_REFERER, "Cookie": PORTAL_DEFAULT_COOKIE}
|
|
152
|
+
for attempt in range(1, self.max_retry + 1):
|
|
153
|
+
try:
|
|
154
|
+
response = self._call_raw(session, "POST", PORTAL_LOGIN_URL, data=form.encode("utf-8"), headers=headers)
|
|
155
|
+
if response.is_successful:
|
|
156
|
+
ensure_portal_login_accepted(response.text())
|
|
157
|
+
return
|
|
158
|
+
except TimeoutError as error:
|
|
159
|
+
if attempt == self.max_retry:
|
|
160
|
+
raise SejongAuthError(ErrorCode.CONNECTION_TIMEOUT) from error
|
|
161
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED, f"Max retry count exceeded : {self.max_retry}")
|
|
162
|
+
|
|
163
|
+
def _call(self, session, method: str, url: str, **kwargs) -> HttpResponse:
|
|
164
|
+
"""로그인 외 요청 (시간 초과·네트워크 오류 > CONNECTION_FAILED)"""
|
|
165
|
+
try:
|
|
166
|
+
return self._call_raw(session, method, url, **kwargs)
|
|
167
|
+
except TimeoutError as error:
|
|
168
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _call_raw(session, method: str, url: str, **kwargs) -> HttpResponse:
|
|
172
|
+
try:
|
|
173
|
+
return session.request(method, url, **kwargs)
|
|
174
|
+
except (TimeoutError, SejongAuthError):
|
|
175
|
+
raise
|
|
176
|
+
except ssl.SSLError as error:
|
|
177
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED, "TLS handshake with the portal failed") from error
|
|
178
|
+
except OSError as error:
|
|
179
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _expect_session_step(response: HttpResponse, failure_message: str) -> None:
|
|
183
|
+
if not response.is_successful:
|
|
184
|
+
raise SejongAuthError(ErrorCode.SESSION_ERROR, f"{failure_message} : {response.status}")
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _expect_data(response: HttpResponse, failure_message: str) -> str:
|
|
188
|
+
"""데이터 응답 판정: 401 > AUTHENTICATION_FAILED, 그 외 200 아님·빈 본문 > DATA_FETCH_FAILED"""
|
|
189
|
+
if response.status == HTTP_UNAUTHORIZED:
|
|
190
|
+
raise SejongAuthError(ErrorCode.AUTHENTICATION_FAILED)
|
|
191
|
+
if response.status != 200 or not response.body:
|
|
192
|
+
raise SejongAuthError(ErrorCode.DATA_FETCH_FAILED, f"{failure_message} : {response.status}")
|
|
193
|
+
return response.text()
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _validate_credentials(student_id: Optional[str], password: Optional[str]) -> None:
|
|
197
|
+
if not student_id or not str(student_id).strip():
|
|
198
|
+
raise SejongAuthError(ErrorCode.INVALID_INPUT, "Student ID is empty")
|
|
199
|
+
if not password or not str(password).strip():
|
|
200
|
+
raise SejongAuthError(ErrorCode.INVALID_INPUT, "Password is empty")
|
|
201
|
+
|
|
202
|
+
def _create_session(self) -> UrllibSession:
|
|
203
|
+
try:
|
|
204
|
+
ssl_context = create_ssl_context(self.ssl_verification)
|
|
205
|
+
except ssl.SSLError as error:
|
|
206
|
+
raise SejongAuthError(ErrorCode.SSL_CONFIGURATION_ERROR) from error
|
|
207
|
+
return UrllibSession(ssl_context, self.timeout_seconds, USER_AGENT)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""에러 코드·예외: Java SejongAuthErrorCode 와 같은 이름 (spec/RESULT_SCHEMA.md)"""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ErrorCode(str, Enum):
|
|
7
|
+
"""인증 실패 사유 코드 (값 = Java enum 이름)"""
|
|
8
|
+
|
|
9
|
+
INVALID_INPUT = "INVALID_INPUT"
|
|
10
|
+
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
|
|
11
|
+
CONNECTION_FAILED = "CONNECTION_FAILED"
|
|
12
|
+
CONNECTION_TIMEOUT = "CONNECTION_TIMEOUT"
|
|
13
|
+
SESSION_ERROR = "SESSION_ERROR"
|
|
14
|
+
DATA_FETCH_FAILED = "DATA_FETCH_FAILED"
|
|
15
|
+
PARSE_ERROR = "PARSE_ERROR"
|
|
16
|
+
SSL_CONFIGURATION_ERROR = "SSL_CONFIGURATION_ERROR"
|
|
17
|
+
ACCOUNT_LOCKED = "ACCOUNT_LOCKED"
|
|
18
|
+
ACCOUNT_UNAVAILABLE = "ACCOUNT_UNAVAILABLE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 코드별 기본 메시지 (Java 메시지와 같은 영어 문구)
|
|
22
|
+
DEFAULT_MESSAGE_BY_CODE = {
|
|
23
|
+
ErrorCode.INVALID_INPUT: "Invalid input.",
|
|
24
|
+
ErrorCode.AUTHENTICATION_FAILED: "Sejong University portal authentication failed. Check the student ID and password.",
|
|
25
|
+
ErrorCode.CONNECTION_FAILED: "Cannot connect to the Sejong University portal.",
|
|
26
|
+
ErrorCode.CONNECTION_TIMEOUT: "Connection to the Sejong University portal timed out.",
|
|
27
|
+
ErrorCode.SESSION_ERROR: "An error occurred while handling the portal session.",
|
|
28
|
+
ErrorCode.DATA_FETCH_FAILED: "Failed to fetch student information.",
|
|
29
|
+
ErrorCode.PARSE_ERROR: "Failed to parse student information.",
|
|
30
|
+
ErrorCode.SSL_CONFIGURATION_ERROR: "Failed to configure SSL.",
|
|
31
|
+
ErrorCode.ACCOUNT_LOCKED: "The portal account is locked after too many wrong passwords. "
|
|
32
|
+
"Reset the password at https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp",
|
|
33
|
+
ErrorCode.ACCOUNT_UNAVAILABLE: "The portal account cannot sign in (use period ended or blocked by the administrator).",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SejongAuthError(Exception):
|
|
38
|
+
"""세종 인증 실패 (code 로 분기, 메시지에 비밀번호·개인정보 없음)"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, code: ErrorCode, message: str = ""):
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message or DEFAULT_MESSAGE_BY_CODE[code]
|
|
43
|
+
super().__init__(f"{code.value}: {self.message}")
|