Skip to content

Commit 6a660d8

Browse files
fix(login): fetch school captcha via GetCaptcha endpoint
1 parent f93682b commit 6a660d8

6 files changed

Lines changed: 198 additions & 13 deletions

File tree

app/routes/auth.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def login():
2828
data = data or {}
2929
username = data.get('username')
3030
password = data.get('password')
31+
captcha_code = (data.get('captcha_code') or '').strip()
3132

3233
masked_username = (username[:3] + "***") if username and len(username) > 3 else "***"
3334
logger.info(f'Login attempt for user: {masked_username}')
@@ -63,17 +64,43 @@ def login():
6364
if not username or not password:
6465
return jsonify({'success': False, 'message': '請輸入帳號密碼'}), 400
6566

67+
if not captcha_code:
68+
return jsonify({'success': False, 'message': '請輸入驗證碼'}), 400
69+
6670
fetcher = current_app.config['GRADE_FETCHER']
67-
success, message, payload = login_and_build_session_payload(fetcher, username, password)
71+
school_login_context = session.get('school_login_context')
72+
success, message, payload = login_and_build_session_payload(
73+
fetcher,
74+
username,
75+
password,
76+
captcha_code=captcha_code,
77+
login_context=school_login_context,
78+
)
79+
80+
# 驗證碼一次性使用,避免重放
81+
session.pop('school_login_context', None)
82+
6883
if not success:
69-
return jsonify({'success': False, 'message': message}), 401
84+
need_refresh_captcha = '驗證碼' in (message or '')
85+
return jsonify({'success': False, 'message': message, 'need_refresh_captcha': need_refresh_captcha}), 401
7086

7187
session.update(payload)
7288
logger.info('Login successful')
7389

7490
return jsonify({'success': True, 'message': message})
7591

7692

93+
@bp.route('/api/school-captcha')
94+
def school_captcha():
95+
fetcher = current_app.config['GRADE_FETCHER']
96+
success, message, payload = fetcher.prepare_login_captcha()
97+
if not success:
98+
return jsonify({'success': False, 'message': message}), 502
99+
100+
session['school_login_context'] = payload['context']
101+
return jsonify({'success': True, 'image_data_url': payload['image_data_url']})
102+
103+
77104
@bp.route('/api/logout', methods=['POST'])
78105
def logout():
79106
session.clear()

app/services/auth_service.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11

22

3-
def login_and_build_session_payload(fetcher, username, password):
4-
success, message, cookies, student_no, token = fetcher.login_and_get_tokens(username, password)
3+
def login_and_build_session_payload(fetcher, username, password, captcha_code=None, login_context=None):
4+
success, message, cookies, student_no, token = fetcher.login_and_get_tokens(
5+
username,
6+
password,
7+
captcha_code=captcha_code,
8+
login_context=login_context,
9+
)
510

611
if not success:
712
return False, message, None

fetcher.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from bs4 import BeautifulSoup
33
from concurrent.futures import ThreadPoolExecutor
44
from flask import g, has_request_context
5+
import base64
6+
import time
57

68
logger = logging.getLogger('SchoolGradesServer.Fetcher')
79

@@ -42,6 +44,7 @@ def _log(level: str, user_id: str, msg: str, req_id: str = None):
4244
class GradeFetcher:
4345
BASE = "https://shcloud2.k12ea.gov.tw/CLHSTYC"
4446
LOGIN_PAGE = f"{BASE}/Auth/Auth/CloudLogin"
47+
CAPTCHA_ENDPOINT = f"{BASE}/Auth/Auth/GetCaptcha"
4548
DO_CHECK = f"{BASE}/Auth/Auth/DoCloudLoginCheck"
4649
GRADES_PAGE = f"{BASE}/ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
4750
API_BASE = f"{BASE}/ICampus"
@@ -62,16 +65,83 @@ def _get_hidden_token(self, html: str) -> str:
6265
raise RuntimeError("找不到 __RequestVerificationToken hidden input")
6366
return el["value"]
6467

65-
def login_and_get_tokens(self, username, password):
68+
def _extract_hidden_input(self, html: str, field_name: str, default: str = "") -> str:
69+
soup = BeautifulSoup(html, "html.parser")
70+
el = soup.select_one(f'input[name="{field_name}"]')
71+
if not el:
72+
return default
73+
return (el.get("value") or default).strip()
74+
75+
def _build_captcha_url(self) -> str:
76+
# 學校系統使用 t query 參數避免快取
77+
ts = int(time.time() * 1000)
78+
return f"{self.CAPTCHA_ENDPOINT}?t={ts}"
79+
80+
def prepare_login_captcha(self):
81+
"""Prepare school login captcha and context for subsequent login POST."""
82+
try:
83+
s = self.session_factory()
84+
r = s.get(self.LOGIN_PAGE)
85+
r.raise_for_status()
86+
87+
html = r.text
88+
login_token = self._get_hidden_token(html)
89+
shcaptcha_gen_code = self._extract_hidden_input(html, "ShCaptchaGenCode", "10")
90+
captcha_url = self._build_captcha_url()
91+
92+
image_resp = s.get(
93+
captcha_url,
94+
headers={
95+
"Referer": self.LOGIN_PAGE,
96+
"X-Requested-With": "XMLHttpRequest",
97+
}
98+
)
99+
image_resp.raise_for_status()
100+
content_type = image_resp.headers.get("Content-Type", "image/png")
101+
102+
if "image" not in content_type.lower():
103+
return False, "學校驗證碼回應格式異常", None
104+
105+
image_b64 = base64.b64encode(image_resp.content).decode("ascii")
106+
107+
context = {
108+
"login_token": login_token,
109+
"shcaptcha_gen_code": shcaptcha_gen_code,
110+
"cookies": {c.name: c.value for c in s.cookies if c.name and c.value is not None},
111+
}
112+
113+
payload = {
114+
"image_data_url": f"data:{content_type};base64,{image_b64}",
115+
"context": context,
116+
}
117+
return True, "OK", payload
118+
except Exception as e:
119+
_log('error', '', f"Prepare captcha exception: {e}")
120+
return False, f"取得學校驗證碼失敗: {str(e)}", None
121+
122+
def login_and_get_tokens(self, username, password, captcha_code=None, login_context=None):
66123
"""Login via requests session, return (success, message, cookies_dict, student_no, token)."""
67124
try:
68125
_log('info', username, "Attempting login (requests mode)")
69126
s = self.session_factory()
70127

71-
# 1) GET login page to obtain cookies + hidden token
72-
r = s.get(self.LOGIN_PAGE)
73-
r.raise_for_status()
74-
login_token = self._get_hidden_token(r.text)
128+
login_token = None
129+
shcaptcha_gen_code = "10"
130+
131+
# 優先使用前端先取得的驗證碼上下文,確保驗證碼與登入請求同步
132+
if login_context:
133+
login_token = login_context.get("login_token")
134+
shcaptcha_gen_code = login_context.get("shcaptcha_gen_code") or "10"
135+
for k, v in (login_context.get("cookies") or {}).items():
136+
if k and v is not None:
137+
s.cookies.set(k, v)
138+
139+
# fallback:若未提供 context,沿用舊流程直接抓 login page
140+
if not login_token:
141+
r = s.get(self.LOGIN_PAGE)
142+
r.raise_for_status()
143+
login_token = self._get_hidden_token(r.text)
144+
shcaptcha_gen_code = self._extract_hidden_input(r.text, "ShCaptchaGenCode", "10")
75145

76146
# 2) POST login
77147
headers = {
@@ -90,7 +160,8 @@ def login_and_get_tokens(self, username, password):
90160
"SchoolName": "國立中大壢中",
91161
"GoogleToken": "8",
92162
"isRegistration": "false",
93-
"ShCaptchaGenCode": "10",
163+
"ShCaptchaGenCode": shcaptcha_gen_code,
164+
"ShCaptcha": (captcha_code or "").strip(),
94165
"__RequestVerificationToken": login_token,
95166
}
96167

frontend/styles/modal.css

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,36 @@ visibility: visible;
738738

739739
}
740740

741+
.captcha-row {
742+
743+
display: flex;
744+
745+
align-items: center;
746+
747+
gap: 10px;
748+
749+
}
750+
751+
.school-captcha-image {
752+
753+
flex: 1;
754+
755+
min-height: 48px;
756+
757+
max-height: 56px;
758+
759+
border: 1px solid var(--md-sys-color-outline);
760+
761+
border-radius: var(--radius-sm);
762+
763+
object-fit: contain;
764+
765+
background: var(--color-surface);
766+
767+
padding: 4px;
768+
769+
}
770+
741771

742772

743773
.toggle-password-btn {
@@ -813,4 +843,3 @@ visibility: visible;
813843
}
814844

815845

816-

frontend/sync.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export function setupSyncFeature() {
2626
const passwordInput = document.getElementById('passwordInput');
2727
const demoFillLoginBtn = document.getElementById('demoFillLoginBtn');
2828
const loginStatus = document.getElementById('loginStatus');
29+
const captchaInput = document.getElementById('captchaInput');
30+
const schoolCaptchaImage = document.getElementById('schoolCaptchaImage');
31+
const refreshSchoolCaptcha = document.getElementById('refreshSchoolCaptcha');
2932

3033
// Select Exam Form
3134
const closeSelectModal = document.getElementById('closeSelectModal');
@@ -59,6 +62,27 @@ export function setupSyncFeature() {
5962
emitOnboardingEvent(ONBOARDING_EVENTS.DEMO_CREDENTIALS_FILLED);
6063
};
6164

65+
const loadSchoolCaptcha = async () => {
66+
if (!schoolCaptchaImage) return;
67+
schoolCaptchaImage.src = '';
68+
schoolCaptchaImage.alt = '學校系統驗證碼載入中';
69+
showStatus(loginStatus, '正在載入學校驗證碼...', 'normal');
70+
try {
71+
const res = await fetch(`${API_BASE}/school-captcha`, {
72+
credentials: 'include'
73+
});
74+
const data = await res.json();
75+
if (!res.ok || !data.success || !data.image_data_url) {
76+
throw new Error(data.message || '驗證碼載入失敗');
77+
}
78+
schoolCaptchaImage.src = data.image_data_url;
79+
schoolCaptchaImage.alt = '學校系統驗證碼';
80+
showStatus(loginStatus, '請輸入圖中的驗證碼。', 'normal');
81+
} catch (error) {
82+
showStatus(loginStatus, `驗證碼載入失敗:${error.message}`, 'error');
83+
}
84+
};
85+
6286
const openLoginModal = () => {
6387
toggleModal(loginModal, true);
6488
if (demoFillLoginBtn) {
@@ -67,7 +91,8 @@ export function setupSyncFeature() {
6791
if (isDemoModeEnabled()) {
6892
showStatus(loginStatus, '教學模式已啟用,先點「一鍵填入教學帳密」。', 'normal');
6993
} else {
70-
loginStatus.textContent = '';
94+
captchaInput.value = '';
95+
loadSchoolCaptcha();
7196
}
7297
usernameInput.focus();
7398
emitOnboardingEvent(ONBOARDING_EVENTS.LOGIN_MODAL_OPEN);
@@ -133,12 +158,18 @@ export function setupSyncFeature() {
133158
const handleLogin = async () => {
134159
const username = usernameInput.value.trim();
135160
const password = passwordInput.value.trim();
161+
const captchaCode = captchaInput.value.trim();
136162

137163
if (!username || !password) {
138164
showStatus(loginStatus, '請輸入帳號密碼', 'error');
139165
return;
140166
}
141167

168+
if (!isDemoModeEnabled() && !captchaCode) {
169+
showStatus(loginStatus, '請輸入驗證碼', 'error');
170+
return;
171+
}
172+
142173
if (isDemoModeEnabled()) {
143174
showStatus(loginStatus, '登入中...', 'normal');
144175
confirmLogin.disabled = true;
@@ -173,7 +204,7 @@ export function setupSyncFeature() {
173204
method: 'POST',
174205
headers: { 'Content-Type': 'application/json' },
175206
credentials: 'include',
176-
body: JSON.stringify({ username, password, turnstile_token: turnstileToken })
207+
body: JSON.stringify({ username, password, captcha_code: captchaCode, turnstile_token: turnstileToken })
177208
});
178209
const data = await res.json();
179210

@@ -184,10 +215,15 @@ export function setupSyncFeature() {
184215
toggleModal(loginModal, false);
185216
openSelectExamModal();
186217
passwordInput.value = '';
218+
captchaInput.value = '';
187219
loginStatus.textContent = '';
188220
}, 500);
189221
} else {
190222
showStatus(loginStatus, data.message || '登入失敗', 'error');
223+
if (data.need_refresh_captcha) {
224+
captchaInput.value = '';
225+
await loadSchoolCaptcha();
226+
}
191227

192228
}
193229
} catch (error) {
@@ -212,6 +248,13 @@ export function setupSyncFeature() {
212248
demoFillLoginBtn.addEventListener('click', fillDemoCredentials);
213249
}
214250

251+
if (refreshSchoolCaptcha) {
252+
refreshSchoolCaptcha.addEventListener('click', async () => {
253+
captchaInput.value = '';
254+
await loadSchoolCaptcha();
255+
});
256+
}
257+
215258
// Close Login Modal
216259
closeLoginModal.addEventListener('click', () => toggleModal(loginModal, false));
217260
cancelLogin.addEventListener('click', () => toggleModal(loginModal, false));

public/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,16 @@ <h3 class="flex-center-gap-8">
201201
</button>
202202
</div>
203203
</div>
204+
<div class="form-group">
205+
<div class="captcha-row">
206+
<img id="schoolCaptchaImage" class="school-captcha-image" alt="學校系統驗證碼" />
207+
<button type="button" class="modal-btn cancel" id="refreshSchoolCaptcha">重新整理</button>
208+
</div>
209+
</div>
210+
<div class="form-group">
211+
<input type="text" id="captchaInput" name="captcha" autocomplete="off" placeholder=" ">
212+
<label for="captchaInput">驗證碼</label>
213+
</div>
204214
<button type="button" class="modal-btn reload demo-fill-login-btn hidden-full-center"
205215
id="demoFillLoginBtn" data-tour="demo-fill-login">
206216
一鍵填入教學帳密

0 commit comments

Comments
 (0)