@nbtca/prompt 1.3.2 → 1.4.1
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.
- package/README.md +44 -0
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +457 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +236 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +472 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +40 -15
- package/dist/core/menu.js +24 -6
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +127 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +5 -29
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +258 -55
- package/dist/features/links.js +7 -4
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +574 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +260 -0
- package/dist/features/settings.js +41 -30
- package/dist/features/status.js +37 -13
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/update.js +16 -8
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +59 -5
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +10 -7
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import makeFetchCookie from 'fetch-cookie';
|
|
2
|
+
import { CookieJar } from 'tough-cookie';
|
|
3
|
+
import { AuthError, SessionExpiredError } from './errors.js';
|
|
4
|
+
const WEBVPN_HOST = 'webvpn.nbt.edu.cn';
|
|
5
|
+
const AUTH_HOST = 'authserver-443.webvpn.nbt.edu.cn';
|
|
6
|
+
export const JWXT_HOST = 'jwxt-443.webvpn.nbt.edu.cn';
|
|
7
|
+
const WEBVPN_PATHS = new Set([
|
|
8
|
+
'/',
|
|
9
|
+
'/users/sign_in',
|
|
10
|
+
'/users/auth/cas',
|
|
11
|
+
'/users/auth/cas/callback',
|
|
12
|
+
'/vpn_key/update',
|
|
13
|
+
]);
|
|
14
|
+
const AUTH_PATHS = new Set([
|
|
15
|
+
'/authserver/login',
|
|
16
|
+
'/authserver/checkNeedCaptcha.htl',
|
|
17
|
+
]);
|
|
18
|
+
const JWXT_EXACT_PATHS = new Set([
|
|
19
|
+
'/sso/jziotlogin',
|
|
20
|
+
'/jwglxt/ticketlogin',
|
|
21
|
+
'/jwglxt/xtgl/login_slogin.html',
|
|
22
|
+
'/jwglxt/xtgl/index_initMenu.html',
|
|
23
|
+
'/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html',
|
|
24
|
+
'/jwglxt/kbcx/xskbcx_cxXsgrkb.html',
|
|
25
|
+
'/jwglxt/kbcx/xskbcx_cxRjc.html',
|
|
26
|
+
]);
|
|
27
|
+
function isAllowedJwxtPath(pathname) {
|
|
28
|
+
return JWXT_EXACT_PATHS.has(pathname);
|
|
29
|
+
}
|
|
30
|
+
function assertAllowedVpnOrigin(value) {
|
|
31
|
+
let nested;
|
|
32
|
+
try {
|
|
33
|
+
nested = new URL(value);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
|
|
37
|
+
}
|
|
38
|
+
if (nested.protocol !== 'https:'
|
|
39
|
+
|| (nested.port !== '' && nested.port !== '443')
|
|
40
|
+
|| nested.username !== ''
|
|
41
|
+
|| nested.password !== '') {
|
|
42
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
|
|
43
|
+
}
|
|
44
|
+
const host = nested.hostname.toLowerCase();
|
|
45
|
+
const allowed = (host === JWXT_HOST && isAllowedJwxtPath(nested.pathname)) || (host === AUTH_HOST && nested.pathname === '/authserver/login') || (host === WEBVPN_HOST && [
|
|
46
|
+
'/',
|
|
47
|
+
'/users/sign_in',
|
|
48
|
+
'/users/auth/cas',
|
|
49
|
+
'/users/auth/cas/callback',
|
|
50
|
+
].includes(nested.pathname));
|
|
51
|
+
if (!allowed) {
|
|
52
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function assertAllowedCasService(value) {
|
|
56
|
+
let service;
|
|
57
|
+
try {
|
|
58
|
+
service = new URL(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
|
|
62
|
+
}
|
|
63
|
+
const allowed = service.protocol === 'https:'
|
|
64
|
+
&& (service.port === '' || service.port === '443')
|
|
65
|
+
&& service.username === ''
|
|
66
|
+
&& service.password === ''
|
|
67
|
+
&& ((service.hostname.toLowerCase() === WEBVPN_HOST
|
|
68
|
+
&& service.pathname === '/users/auth/cas/callback') || (service.hostname.toLowerCase() === JWXT_HOST
|
|
69
|
+
&& service.pathname === '/sso/jziotlogin'));
|
|
70
|
+
if (!allowed) {
|
|
71
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service returned an untrusted redirect.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function assertAllowedCampusUrl(url) {
|
|
75
|
+
const hostname = url.hostname.toLowerCase();
|
|
76
|
+
if (url.protocol !== 'https:'
|
|
77
|
+
|| (url.port !== '' && url.port !== '443')
|
|
78
|
+
|| url.username !== ''
|
|
79
|
+
|| url.password !== '') {
|
|
80
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service URL is not allowed.');
|
|
81
|
+
}
|
|
82
|
+
if (hostname === WEBVPN_HOST && WEBVPN_PATHS.has(url.pathname)) {
|
|
83
|
+
if (url.pathname === '/vpn_key/update') {
|
|
84
|
+
const origin = url.searchParams.get('origin');
|
|
85
|
+
// The post-CAS callback currently uses this route without `origin`;
|
|
86
|
+
// unauthenticated JWXT redirects include one and are validated below.
|
|
87
|
+
if (origin)
|
|
88
|
+
assertAllowedVpnOrigin(origin);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (hostname === AUTH_HOST && AUTH_PATHS.has(url.pathname)) {
|
|
93
|
+
const service = url.searchParams.get('service');
|
|
94
|
+
if (service)
|
|
95
|
+
assertAllowedCasService(service);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (hostname === JWXT_HOST && isAllowedJwxtPath(url.pathname))
|
|
99
|
+
return;
|
|
100
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service URL is not allowed.');
|
|
101
|
+
}
|
|
102
|
+
function safeHeaders(headers) {
|
|
103
|
+
const result = new Headers(headers);
|
|
104
|
+
for (const forbidden of ['authorization', 'cookie', 'host']) {
|
|
105
|
+
if (result.has(forbidden)) {
|
|
106
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'Caller-supplied authentication headers are not allowed.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return Object.fromEntries(result.entries());
|
|
110
|
+
}
|
|
111
|
+
function abortSignal(signal, timeoutMs) {
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
let didTimeout = false;
|
|
114
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
115
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
116
|
+
if (signal?.aborted)
|
|
117
|
+
onAbort();
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
didTimeout = true;
|
|
120
|
+
controller.abort();
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
timer.unref?.();
|
|
123
|
+
return {
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
cleanup() {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
signal?.removeEventListener('abort', onAbort);
|
|
128
|
+
},
|
|
129
|
+
timedOut: () => didTimeout,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function safeFetchError(error, stage, didTimeout) {
|
|
133
|
+
if (error instanceof AuthError)
|
|
134
|
+
return error;
|
|
135
|
+
if (didTimeout)
|
|
136
|
+
return new AuthError('TIMEOUT', stage, 'The campus service request timed out.', { retryable: true });
|
|
137
|
+
if (typeof error === 'object' && error !== null && Reflect.get(error, 'name') === 'AbortError') {
|
|
138
|
+
return new DOMException('The campus service request was aborted.', 'AbortError');
|
|
139
|
+
}
|
|
140
|
+
return new AuthError('NETWORK', stage, 'The campus service request failed.', { retryable: true });
|
|
141
|
+
}
|
|
142
|
+
export function createCampusCookieSession(options = {}) {
|
|
143
|
+
// Tough Cookie's secure defaults reject public suffixes and use strict
|
|
144
|
+
// parsing while tolerating legacy prefix mistakes. The campus WebVPN relies
|
|
145
|
+
// on its default special-domain handling during the CAS callback.
|
|
146
|
+
const jar = options.jar ?? new CookieJar();
|
|
147
|
+
const baseFetch = options.baseFetch ?? globalThis.fetch;
|
|
148
|
+
const guardedFetch = async (input, init) => {
|
|
149
|
+
const url = new URL(input instanceof Request ? input.url : input.toString());
|
|
150
|
+
assertAllowedCampusUrl(url);
|
|
151
|
+
// Caller headers were checked before fetch-cookie received them. A Cookie
|
|
152
|
+
// header at this layer was added by the private jar and is expected.
|
|
153
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
154
|
+
for (const [name, value] of new Headers(init?.headers))
|
|
155
|
+
headers.set(name, value);
|
|
156
|
+
if (headers.has('authorization') || headers.has('host')) {
|
|
157
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'Authentication headers are not allowed.');
|
|
158
|
+
}
|
|
159
|
+
return baseFetch(input, init);
|
|
160
|
+
};
|
|
161
|
+
const cookieFetch = makeFetchCookie(guardedFetch, jar, false);
|
|
162
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
163
|
+
async function request(url, init = {}, stage = 'session') {
|
|
164
|
+
assertAllowedCampusUrl(url);
|
|
165
|
+
if (init.method && init.method !== 'GET' && init.method !== 'POST') {
|
|
166
|
+
throw new AuthError('UNTRUSTED_URL', stage, 'Only read and login requests are allowed.');
|
|
167
|
+
}
|
|
168
|
+
const controlled = abortSignal(init.signal, timeoutMs);
|
|
169
|
+
try {
|
|
170
|
+
return await cookieFetch(url, {
|
|
171
|
+
...init,
|
|
172
|
+
headers: safeHeaders(init.headers),
|
|
173
|
+
signal: controlled.signal,
|
|
174
|
+
maxRedirect: 8,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw safeFetchError(error, stage, controlled.timedOut());
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
controlled.cleanup();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function timetableTransport(url, init) {
|
|
185
|
+
if (url.hostname.toLowerCase() !== JWXT_HOST || !isAllowedJwxtPath(url.pathname)) {
|
|
186
|
+
throw new AuthError('UNTRUSTED_URL', 'session', 'The timetable transport only accepts JWXT routes.');
|
|
187
|
+
}
|
|
188
|
+
const response = await request(url, init, 'session');
|
|
189
|
+
try {
|
|
190
|
+
const finalUrl = new URL(response.url);
|
|
191
|
+
if (finalUrl.hostname.toLowerCase() !== JWXT_HOST
|
|
192
|
+
|| finalUrl.pathname.includes('/authserver/login')
|
|
193
|
+
|| finalUrl.pathname.includes('/users/sign_in')
|
|
194
|
+
|| finalUrl.pathname === '/vpn_key/update')
|
|
195
|
+
throw new SessionExpiredError();
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error instanceof AuthError)
|
|
199
|
+
throw error;
|
|
200
|
+
throw new SessionExpiredError();
|
|
201
|
+
}
|
|
202
|
+
if (response.status === 401 || response.status === 403)
|
|
203
|
+
throw new SessionExpiredError();
|
|
204
|
+
return response;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
request,
|
|
208
|
+
timetableTransport,
|
|
209
|
+
serialize: () => jar.serialize(),
|
|
210
|
+
close: () => jar.removeAllCookies(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export async function cookieSessionFromSerialized(serialized, options = {}) {
|
|
214
|
+
let jar;
|
|
215
|
+
try {
|
|
216
|
+
jar = await CookieJar.deserialize(serialized);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new SessionExpiredError();
|
|
220
|
+
}
|
|
221
|
+
return createCampusCookieSession({ ...options, jar });
|
|
222
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class AuthError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
stage;
|
|
4
|
+
retryable;
|
|
5
|
+
constructor(code, stage, message, options = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'AuthError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.stage = stage;
|
|
10
|
+
this.retryable = options.retryable ?? false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class SessionExpiredError extends AuthError {
|
|
14
|
+
constructor() {
|
|
15
|
+
super('SESSION_EXPIRED', 'session', 'The campus login session has expired.', { retryable: true });
|
|
16
|
+
this.name = 'SessionExpiredError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createCipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import { load } from 'cheerio';
|
|
3
|
+
import { AuthError } from './errors.js';
|
|
4
|
+
import { JWXT_HOST, cookieSessionFromSerialized, createCampusCookieSession, } from './cookie-transport.js';
|
|
5
|
+
const WEBVPN_ENTRY = new URL('https://webvpn.nbt.edu.cn/');
|
|
6
|
+
const SSO_ENTRY = new URL(`https://${JWXT_HOST}/sso/jziotlogin`);
|
|
7
|
+
const JWXT_MENU = new URL(`https://${JWXT_HOST}/jwglxt/xtgl/index_initMenu.html`);
|
|
8
|
+
const TIMETABLE_INDEX = new URL(`https://${JWXT_HOST}/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default`);
|
|
9
|
+
const RANDOM_ALPHABET = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
|
|
10
|
+
const MAX_AUTH_HTML_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
function randomCharacters(length, source) {
|
|
13
|
+
const bytes = source(length);
|
|
14
|
+
if (bytes.length < length)
|
|
15
|
+
throw new AuthError('LOGIN_PAGE_CHANGED', 'credentials', 'Secure login initialization failed.');
|
|
16
|
+
let result = '';
|
|
17
|
+
for (let index = 0; index < length; index += 1) {
|
|
18
|
+
result += RANDOM_ALPHABET[bytes[index] % RANDOM_ALPHABET.length];
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
export function encryptCampusPassword(password, salt, source = randomBytes) {
|
|
23
|
+
const key = Buffer.from(salt, 'utf8');
|
|
24
|
+
if (key.length !== 16) {
|
|
25
|
+
key.fill(0);
|
|
26
|
+
throw new AuthError('LOGIN_PAGE_CHANGED', 'credentials', 'The campus login page changed unexpectedly.');
|
|
27
|
+
}
|
|
28
|
+
const ivText = randomCharacters(16, source);
|
|
29
|
+
const prefix = randomCharacters(64, source);
|
|
30
|
+
const iv = Buffer.from(ivText, 'utf8');
|
|
31
|
+
const plaintext = Buffer.from(prefix + password, 'utf8');
|
|
32
|
+
try {
|
|
33
|
+
const cipher = createCipheriv('aes-128-cbc', key, iv);
|
|
34
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]).toString('base64');
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
key.fill(0);
|
|
38
|
+
iv.fill(0);
|
|
39
|
+
plaintext.fill(0);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function parseLoginForm(html, responseUrl) {
|
|
43
|
+
const $ = load(html);
|
|
44
|
+
const form = $('#pwdFromId');
|
|
45
|
+
const execution = String(form.find('input[name="execution"], #execution').first().val() ?? '').trim();
|
|
46
|
+
const salt = String(form.find('#pwdEncryptSalt, input[name="pwdEncryptSalt"]').first().val() ?? '').trim();
|
|
47
|
+
const actionValue = form.attr('action');
|
|
48
|
+
if (form.length === 0 || !execution || !salt || !actionValue) {
|
|
49
|
+
throw new AuthError('LOGIN_PAGE_CHANGED', 'login-page', 'The campus login page changed unexpectedly.');
|
|
50
|
+
}
|
|
51
|
+
let current;
|
|
52
|
+
let action;
|
|
53
|
+
try {
|
|
54
|
+
current = new URL(responseUrl);
|
|
55
|
+
action = new URL(actionValue, current);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new AuthError('LOGIN_PAGE_CHANGED', 'login-page', 'The campus login page changed unexpectedly.');
|
|
59
|
+
}
|
|
60
|
+
const service = current.searchParams.get('service');
|
|
61
|
+
if (service && !action.searchParams.has('service'))
|
|
62
|
+
action.searchParams.set('service', service);
|
|
63
|
+
return { action, execution, salt };
|
|
64
|
+
}
|
|
65
|
+
function hasLoginFingerprint(html) {
|
|
66
|
+
return /(?:id=["']pwdFromId["']|id=["']pwdEncryptSalt["']|name=["']execution["'])/i.test(html);
|
|
67
|
+
}
|
|
68
|
+
function classifyRejectedLogin(html) {
|
|
69
|
+
const $ = load(html);
|
|
70
|
+
const visibleError = [
|
|
71
|
+
$('#showErrorTip').text(),
|
|
72
|
+
$('#showWarnTip').text(),
|
|
73
|
+
$('#errorMsg').text(),
|
|
74
|
+
$('.alert-danger').text(),
|
|
75
|
+
].join(' ').replace(/\s+/g, ' ').trim();
|
|
76
|
+
if (/锁定|冻结|次数过多|稍后再试/.test(visibleError)) {
|
|
77
|
+
return new AuthError('ACCOUNT_LOCKED', 'credentials', 'The campus account is temporarily locked.');
|
|
78
|
+
}
|
|
79
|
+
if (/激活|未启用/.test(visibleError)) {
|
|
80
|
+
return new AuthError('ACCOUNT_INACTIVE', 'credentials', 'The campus account must be activated first.');
|
|
81
|
+
}
|
|
82
|
+
if (/验证码|滑块|captcha/i.test(visibleError)) {
|
|
83
|
+
return new AuthError('INTERACTIVE_CHALLENGE', 'credentials', 'The campus login requires an interactive browser challenge.');
|
|
84
|
+
}
|
|
85
|
+
if (/用户名|账号|密码|credential|password/i.test(visibleError)) {
|
|
86
|
+
return new AuthError('INVALID_CREDENTIALS', 'credentials', 'The student id or password was rejected.');
|
|
87
|
+
}
|
|
88
|
+
return new AuthError('UNEXPECTED_RESPONSE', 'credentials', 'Campus login could not be confirmed.');
|
|
89
|
+
}
|
|
90
|
+
async function readText(response, stage) {
|
|
91
|
+
if (response.status < 200 || response.status >= 300) {
|
|
92
|
+
throw new AuthError('HTTP_ERROR', stage, 'The campus service returned an error.', { retryable: response.status >= 500 });
|
|
93
|
+
}
|
|
94
|
+
const length = Number.parseInt(response.headers.get('content-length') ?? '', 10);
|
|
95
|
+
if (Number.isFinite(length) && length > MAX_AUTH_HTML_BYTES) {
|
|
96
|
+
throw new AuthError('UNEXPECTED_RESPONSE', stage, 'The campus service returned an unexpected response.');
|
|
97
|
+
}
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_AUTH_HTML_BYTES) {
|
|
100
|
+
throw new AuthError('UNEXPECTED_RESPONSE', stage, 'The campus service returned an unexpected response.');
|
|
101
|
+
}
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
async function challengeRequired(cookies, loginUrl, username, signal) {
|
|
105
|
+
const endpoint = new URL('/authserver/checkNeedCaptcha.htl', loginUrl);
|
|
106
|
+
endpoint.searchParams.set('username', username);
|
|
107
|
+
const response = await cookies.request(endpoint, {
|
|
108
|
+
method: 'GET',
|
|
109
|
+
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
110
|
+
signal,
|
|
111
|
+
}, 'challenge-check');
|
|
112
|
+
const text = await readText(response, 'challenge-check');
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(text);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new AuthError('LOGIN_PAGE_CHANGED', 'challenge-check', 'The campus challenge response changed unexpectedly.');
|
|
119
|
+
}
|
|
120
|
+
if (typeof parsed === 'boolean')
|
|
121
|
+
return parsed;
|
|
122
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
123
|
+
const value = Reflect.get(parsed, 'isNeed') ?? Reflect.get(parsed, 'needCaptcha');
|
|
124
|
+
if (value === true || value === 'true' || value === 1 || value === '1')
|
|
125
|
+
return true;
|
|
126
|
+
if (value === false || value === 'false' || value === 0 || value === '0')
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
throw new AuthError('LOGIN_PAGE_CHANGED', 'challenge-check', 'The campus challenge response changed unexpectedly.');
|
|
130
|
+
}
|
|
131
|
+
function maskAccountId(username) {
|
|
132
|
+
const characters = [...username];
|
|
133
|
+
if (characters.length <= 2)
|
|
134
|
+
return '•'.repeat(characters.length);
|
|
135
|
+
return `${'•'.repeat(characters.length - 2)}${characters.slice(-2).join('')}`;
|
|
136
|
+
}
|
|
137
|
+
function createAuthenticatedSession(cookies, metadata) {
|
|
138
|
+
return {
|
|
139
|
+
accountHint: metadata.accountHint,
|
|
140
|
+
timetableTransport: (url, init) => cookies.timetableTransport(url, init),
|
|
141
|
+
async snapshot(validatedAt = new Date()) {
|
|
142
|
+
const validatedAtText = validatedAt.toISOString();
|
|
143
|
+
return {
|
|
144
|
+
version: 1,
|
|
145
|
+
provider: 'nbt-webvpn',
|
|
146
|
+
jar: await cookies.serialize(),
|
|
147
|
+
accountHint: metadata.accountHint,
|
|
148
|
+
authenticatedAt: metadata.authenticatedAt,
|
|
149
|
+
validatedAt: validatedAtText,
|
|
150
|
+
expiresAt: new Date(validatedAt.getTime() + SESSION_MAX_AGE_MS).toISOString(),
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
close: () => cookies.close(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function verifyJwxtSession(cookies, signal) {
|
|
157
|
+
await readText(await cookies.request(SSO_ENTRY, { method: 'GET', signal }, 'sso'), 'sso');
|
|
158
|
+
await readText(await cookies.request(JWXT_MENU, { method: 'GET', signal }, 'sso'), 'sso');
|
|
159
|
+
const response = await cookies.request(TIMETABLE_INDEX, { method: 'GET', signal }, 'sso');
|
|
160
|
+
const html = await readText(response, 'sso');
|
|
161
|
+
let finalUrl;
|
|
162
|
+
try {
|
|
163
|
+
finalUrl = new URL(response.url);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new AuthError('UNEXPECTED_RESPONSE', 'sso', 'Campus login could not be confirmed.');
|
|
167
|
+
}
|
|
168
|
+
if (finalUrl.hostname.toLowerCase() !== JWXT_HOST
|
|
169
|
+
|| hasLoginFingerprint(html)
|
|
170
|
+
|| !/<select\b[^>]*(?:id|name)=["']xnm["']/i.test(html)
|
|
171
|
+
|| !/<select\b[^>]*(?:id|name)=["']xqm["']/i.test(html)) {
|
|
172
|
+
throw new AuthError('UNEXPECTED_RESPONSE', 'sso', 'Campus login could not be confirmed.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function loginWithStudentPassword(username, password, options = {}) {
|
|
176
|
+
if (!username.trim() || !password) {
|
|
177
|
+
throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Student id and password are required.');
|
|
178
|
+
}
|
|
179
|
+
const normalizedUsername = username.trim();
|
|
180
|
+
const cookies = createCampusCookieSession(options);
|
|
181
|
+
try {
|
|
182
|
+
const loginResponse = await cookies.request(WEBVPN_ENTRY, {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
headers: {
|
|
185
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
186
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
187
|
+
},
|
|
188
|
+
signal: options.signal,
|
|
189
|
+
}, 'login-page');
|
|
190
|
+
const loginHtml = await readText(loginResponse, 'login-page');
|
|
191
|
+
const form = parseLoginForm(loginHtml, loginResponse.url);
|
|
192
|
+
if (await challengeRequired(cookies, form.action, normalizedUsername, options.signal)) {
|
|
193
|
+
throw new AuthError('INTERACTIVE_CHALLENGE', 'challenge-check', 'The campus login requires an interactive browser challenge.');
|
|
194
|
+
}
|
|
195
|
+
const encryptedPassword = encryptCampusPassword(password, form.salt, options.randomBytes ?? randomBytes);
|
|
196
|
+
const body = new URLSearchParams({
|
|
197
|
+
username: normalizedUsername,
|
|
198
|
+
password: encryptedPassword,
|
|
199
|
+
_eventId: 'submit',
|
|
200
|
+
cllt: 'userNameLogin',
|
|
201
|
+
dllt: 'generalLogin',
|
|
202
|
+
execution: form.execution,
|
|
203
|
+
});
|
|
204
|
+
const credentialResponse = await cookies.request(form.action, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: {
|
|
207
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
208
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
209
|
+
Referer: loginResponse.url,
|
|
210
|
+
},
|
|
211
|
+
body: body.toString(),
|
|
212
|
+
signal: options.signal,
|
|
213
|
+
}, 'credentials');
|
|
214
|
+
const credentialHtml = await readText(credentialResponse, 'credentials');
|
|
215
|
+
if (hasLoginFingerprint(credentialHtml))
|
|
216
|
+
throw classifyRejectedLogin(credentialHtml);
|
|
217
|
+
await verifyJwxtSession(cookies, options.signal);
|
|
218
|
+
const authenticatedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
219
|
+
return createAuthenticatedSession(cookies, {
|
|
220
|
+
accountHint: maskAccountId(normalizedUsername),
|
|
221
|
+
authenticatedAt,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
await cookies.close();
|
|
226
|
+
if (error instanceof AuthError)
|
|
227
|
+
throw error;
|
|
228
|
+
if (typeof error === 'object' && error !== null && Reflect.get(error, 'name') === 'AbortError')
|
|
229
|
+
throw error;
|
|
230
|
+
throw new AuthError('NETWORK', 'credentials', 'The campus login request failed.', { retryable: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export async function restoreNbtSession(persisted, options = {}) {
|
|
234
|
+
const cookies = await cookieSessionFromSerialized(persisted.jar, options);
|
|
235
|
+
return createAuthenticatedSession(cookies, {
|
|
236
|
+
accountHint: persisted.accountHint,
|
|
237
|
+
authenticatedAt: persisted.authenticatedAt,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { getStateDir, getWritableStateDir } from '../config/paths.js';
|
|
5
|
+
export const SESSION_SCHEMA_VERSION = 1;
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function isIsoDate(value) {
|
|
10
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
11
|
+
}
|
|
12
|
+
function isSerializedJar(value) {
|
|
13
|
+
return isRecord(value) && Array.isArray(value['cookies']);
|
|
14
|
+
}
|
|
15
|
+
function parseSession(value) {
|
|
16
|
+
if (!isRecord(value))
|
|
17
|
+
return null;
|
|
18
|
+
if (value['version'] !== SESSION_SCHEMA_VERSION || value['provider'] !== 'nbt-webvpn')
|
|
19
|
+
return null;
|
|
20
|
+
if (!isSerializedJar(value['jar']))
|
|
21
|
+
return null;
|
|
22
|
+
if (!isIsoDate(value['authenticatedAt']) || !isIsoDate(value['validatedAt']))
|
|
23
|
+
return null;
|
|
24
|
+
if (value['expiresAt'] !== undefined && !isIsoDate(value['expiresAt']))
|
|
25
|
+
return null;
|
|
26
|
+
if (value['accountHint'] !== undefined
|
|
27
|
+
&& (typeof value['accountHint'] !== 'string'
|
|
28
|
+
|| value['accountHint'].length > 64
|
|
29
|
+
|| /[\u0000-\u001f\u007f]/.test(value['accountHint'])))
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
version: SESSION_SCHEMA_VERSION,
|
|
33
|
+
provider: 'nbt-webvpn',
|
|
34
|
+
jar: value['jar'],
|
|
35
|
+
accountHint: value['accountHint'],
|
|
36
|
+
authenticatedAt: value['authenticatedAt'],
|
|
37
|
+
validatedAt: value['validatedAt'],
|
|
38
|
+
expiresAt: value['expiresAt'],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function removeFile(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
fs.unlinkSync(filePath);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!isRecord(error) || error['code'] !== 'ENOENT')
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function clearTemporaryFiles(filePath) {
|
|
51
|
+
const directory = path.dirname(filePath);
|
|
52
|
+
const prefix = `${path.basename(filePath)}.`;
|
|
53
|
+
try {
|
|
54
|
+
for (const entry of fs.readdirSync(directory)) {
|
|
55
|
+
if (entry.startsWith(prefix) && entry.endsWith('.tmp'))
|
|
56
|
+
removeFile(path.join(directory, entry));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!isRecord(error) || error['code'] !== 'ENOENT')
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function createSessionStore(options = {}) {
|
|
65
|
+
const now = options.now ?? (() => new Date());
|
|
66
|
+
const filePath = options.filePath ?? path.join(getStateDir(), 'session.json');
|
|
67
|
+
function clear() {
|
|
68
|
+
removeFile(filePath);
|
|
69
|
+
clearTemporaryFiles(filePath);
|
|
70
|
+
}
|
|
71
|
+
function load() {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = parseSession(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
74
|
+
if (!parsed || (parsed.expiresAt && Date.parse(parsed.expiresAt) <= now().getTime())) {
|
|
75
|
+
clear();
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (isRecord(error) && error['code'] === 'ENOENT')
|
|
82
|
+
return null;
|
|
83
|
+
clear();
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function save(session) {
|
|
88
|
+
const validated = parseSession(session);
|
|
89
|
+
if (!validated)
|
|
90
|
+
throw new TypeError('Invalid persisted session.');
|
|
91
|
+
const directory = options.filePath ? path.dirname(filePath) : getWritableStateDir();
|
|
92
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
93
|
+
try {
|
|
94
|
+
fs.chmodSync(directory, 0o700);
|
|
95
|
+
}
|
|
96
|
+
catch { /* Best effort on non-POSIX filesystems. */ }
|
|
97
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
98
|
+
try {
|
|
99
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(validated)}\n`, {
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
flag: 'wx',
|
|
102
|
+
mode: 0o600,
|
|
103
|
+
});
|
|
104
|
+
fs.renameSync(temporaryPath, filePath);
|
|
105
|
+
try {
|
|
106
|
+
fs.chmodSync(filePath, 0o600);
|
|
107
|
+
}
|
|
108
|
+
catch { /* Best effort on non-POSIX filesystems. */ }
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
try {
|
|
112
|
+
removeFile(temporaryPath);
|
|
113
|
+
}
|
|
114
|
+
catch { /* The main write result is authoritative. */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { filePath, load, save, clear };
|
|
118
|
+
}
|
package/dist/config/paths.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'fs';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
|
-
import { join } from 'path';
|
|
3
|
+
import { isAbsolute, join } from 'path';
|
|
4
|
+
function absoluteEnvPath(name, fallback) {
|
|
5
|
+
const value = process.env[name];
|
|
6
|
+
return value && isAbsolute(value) ? value : fallback;
|
|
7
|
+
}
|
|
4
8
|
function getXdgConfigDir() {
|
|
5
|
-
const xdgHome =
|
|
9
|
+
const xdgHome = absoluteEnvPath('XDG_CONFIG_HOME', join(homedir(), '.config'));
|
|
6
10
|
return join(xdgHome, 'nbtca');
|
|
7
11
|
}
|
|
8
12
|
function getLegacyConfigDir() {
|
|
9
13
|
return join(homedir(), '.nbtca');
|
|
10
14
|
}
|
|
15
|
+
function getDefaultStateDir() {
|
|
16
|
+
if (process.platform === 'win32' && process.env['LOCALAPPDATA']) {
|
|
17
|
+
return join(process.env['LOCALAPPDATA'], 'nbtca');
|
|
18
|
+
}
|
|
19
|
+
const xdgHome = absoluteEnvPath('XDG_STATE_HOME', join(homedir(), '.local', 'state'));
|
|
20
|
+
return join(xdgHome, 'nbtca');
|
|
21
|
+
}
|
|
11
22
|
export function getConfigDir() {
|
|
12
23
|
const xdgDir = getXdgConfigDir();
|
|
13
24
|
if (existsSync(xdgDir))
|
|
@@ -22,3 +33,12 @@ export function getWritableConfigDir() {
|
|
|
22
33
|
mkdirSync(dir, { recursive: true });
|
|
23
34
|
return dir;
|
|
24
35
|
}
|
|
36
|
+
/** User-level runtime state shared by npx, local and global installations. */
|
|
37
|
+
export function getStateDir() {
|
|
38
|
+
return getDefaultStateDir();
|
|
39
|
+
}
|
|
40
|
+
export function getWritableStateDir() {
|
|
41
|
+
const dir = getDefaultStateDir();
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const CSI = '\x1b[';
|
|
2
|
+
export const ansi = {
|
|
3
|
+
hideCursor: `${CSI}?25l`,
|
|
4
|
+
showCursor: `${CSI}?25h`,
|
|
5
|
+
eraseDown: `${CSI}0J`,
|
|
6
|
+
cursorToCol0: '\r',
|
|
7
|
+
cursorUp: (n) => (n > 0 ? `${CSI}${n}A` : ''),
|
|
8
|
+
enterAlt: `${CSI}?1049h`,
|
|
9
|
+
leaveAlt: `${CSI}?1049l`,
|
|
10
|
+
home: `${CSI}H`,
|
|
11
|
+
clearAll: `${CSI}2J`,
|
|
12
|
+
};
|
|
13
|
+
let registered = false;
|
|
14
|
+
export function ensureCursorRestored() {
|
|
15
|
+
if (registered)
|
|
16
|
+
return;
|
|
17
|
+
registered = true;
|
|
18
|
+
const restore = () => {
|
|
19
|
+
if (process.stdout.isTTY)
|
|
20
|
+
process.stdout.write(ansi.showCursor);
|
|
21
|
+
};
|
|
22
|
+
process.on('exit', restore);
|
|
23
|
+
}
|