Skip to content

Commit 20344fd

Browse files
committed
KAIST 로그인 구현 변경
1 parent 8700299 commit 20344fd

File tree

6 files changed

+1140
-786
lines changed

6 files changed

+1140
-786
lines changed

.eslintrc.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
env: {
33
browser: true,
4-
es2020: true,
4+
es2022: true,
55
},
66
extends: [
77
'airbnb-base',
@@ -12,13 +12,20 @@ module.exports = {
1212
],
1313
parser: '@typescript-eslint/parser',
1414
parserOptions: {
15-
ecmaVersion: 11,
15+
ecmaVersion: 2022,
1616
sourceType: 'module',
1717
},
1818
plugins: [
1919
'@typescript-eslint',
2020
],
2121
rules: {
2222
indent: ['error', 4],
23+
'import/extensions': [
24+
'error',
25+
'ignorePackages',
26+
{
27+
ts: 'never',
28+
},
29+
],
2330
},
2431
};

auth.ts

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import axios, { AxiosError } from 'axios';
2+
import {
3+
pki, util, random, pkcs5, md, cipher,
4+
} from 'node-forge';
5+
import { CookieJar, MemoryCookieStore } from 'tough-cookie';
6+
import { wrapper } from 'axios-cookiejar-support';
7+
import { JSDOM } from 'jsdom';
8+
9+
const cookieStore = new MemoryCookieStore();
10+
const cookieJar = new CookieJar(cookieStore);
11+
12+
const http = axios.create();
13+
http.defaults.baseURL = 'https://iam2.kaist.ac.kr';
14+
http.defaults.withCredentials = true;
15+
http.defaults.jar = cookieJar;
16+
wrapper(http);
17+
http.interceptors.request.use((config) => {
18+
const token = cookieJar
19+
.getCookiesSync('https://iam2.kaist.ac.kr')
20+
.find((cookie) => cookie.key === 'XSRF-TOKEN')?.value;
21+
if (token) {
22+
// eslint-disable-next-line no-param-reassign
23+
config.headers['X-XSRF-TOKEN'] = token;
24+
}
25+
return config;
26+
});
27+
28+
async function getPublicKey(): Promise<string> {
29+
await http.get('/'); // XSRF-TOKEN
30+
const result = await http.post<string>('getPublicKey');
31+
return result.data;
32+
}
33+
34+
interface SymmetricKey {
35+
length: number;
36+
key: string;
37+
iv: string;
38+
keyStr: string;
39+
}
40+
41+
function generateSymmetricKey(): SymmetricKey {
42+
const length = 32;
43+
const entropy = util.encode64(random.getBytesSync(0x40));
44+
const password = `${entropy.substring(0x0, 0x4)
45+
}b${entropy.substring(0x5, 0x9)
46+
}a${entropy.substring(0xa, 0xe)
47+
}n${entropy.substring(0xf, 0x13)
48+
}d${entropy.substring(0x14, 0x18)
49+
}i${entropy.substring(0x19)}`;
50+
const salt = password.substring(password.length - 16);
51+
const pbkdf2 = pkcs5.pbkdf2(password, salt, 1024, length);
52+
const iv = pbkdf2.slice(pbkdf2.length - 16);
53+
return {
54+
length,
55+
key: pbkdf2,
56+
iv,
57+
keyStr: password,
58+
};
59+
}
60+
61+
function encryptPKIWithPublicKey(payload: string, publicKey: pki.rsa.PublicKey): string {
62+
const encoded = util.encodeUtf8(payload);
63+
const encrypted = publicKey.encrypt(encoded, 'RSAES-PKCS1-V1_5', { md: md.sha256.create() });
64+
return util.encode64(encrypted);
65+
}
66+
67+
function encryptAES(paylod: string, keyInfo: SymmetricKey): string {
68+
const encoded = encodeURIComponent(paylod);
69+
const aes = cipher.createCipher('AES-CBC', keyInfo.key);
70+
aes.start({ iv: keyInfo.iv });
71+
aes.update(util.createBuffer(encoded));
72+
aes.finish();
73+
return util.encode64(aes.output.bytes());
74+
}
75+
76+
function encryptPayloadComponent(
77+
payload: string | null | undefined,
78+
keyInfo: SymmetricKey,
79+
): string | null {
80+
if (payload === null || payload === undefined) {
81+
return null;
82+
}
83+
if (payload === '') {
84+
return '';
85+
}
86+
return encryptAES(payload, keyInfo);
87+
}
88+
89+
interface CryptoConfiguration {
90+
keyInfo: SymmetricKey;
91+
encKey: string;
92+
}
93+
94+
async function loginIdPassword(
95+
userId: string,
96+
password: string,
97+
cryptoConfig: CryptoConfiguration,
98+
) {
99+
const encryptedUserId = encryptPayloadComponent(userId, cryptoConfig.keyInfo);
100+
const encryptedPassword = encryptPayloadComponent(password, cryptoConfig.keyInfo);
101+
if (encryptedUserId === null || encryptedPassword === null) {
102+
throw new Error('Invalid input');
103+
}
104+
const formData = {
105+
user_id: encryptedUserId,
106+
login_page: 'L_P_IAMPS',
107+
pw: encryptedPassword,
108+
};
109+
const body = new URLSearchParams(formData);
110+
try {
111+
await http.post('/api/sso/login', body, { headers: { encsymka: cryptoConfig.encKey } });
112+
} catch (e) {
113+
if (!axios.isAxiosError(e)) {
114+
throw e;
115+
}
116+
const error = e as AxiosError<any>;
117+
const { response } = error;
118+
if (!response || response.status !== 500 || !response.data) {
119+
throw e;
120+
}
121+
if (typeof response.data.errorCode !== 'string') {
122+
throw e;
123+
}
124+
if (response.data.errorCode !== 'SSO_OTP_NEED_OTP_CHECK') {
125+
throw Error(response.data.errorCode);
126+
}
127+
}
128+
}
129+
130+
async function loginOtp(userId: string, otp: string, cryptoConfig: CryptoConfiguration) {
131+
const encryptedUserId = encryptPayloadComponent(userId, cryptoConfig.keyInfo);
132+
if (encryptedUserId === null) {
133+
throw new Error('Invalid input');
134+
}
135+
const formData = {
136+
user_id: encryptedUserId,
137+
login_page: 'L_P_IAMPS',
138+
otp,
139+
param_id: '',
140+
auth_type_2nd: 'motp',
141+
alrdln: 'T',
142+
};
143+
const body = new URLSearchParams(formData);
144+
await http.post('/api/sso/login', body, { headers: { encsymka: cryptoConfig.encKey } });
145+
}
146+
147+
function generateCryptoConfig(publicKey: pki.rsa.PublicKey): CryptoConfiguration {
148+
const symmetricKey = generateSymmetricKey();
149+
const encsymka = encryptPKIWithPublicKey(symmetricKey.keyStr, publicKey);
150+
return {
151+
keyInfo: symmetricKey,
152+
encKey: encsymka,
153+
};
154+
}
155+
156+
export interface GetKaistCookieRunOptions {
157+
userId: string;
158+
password: string;
159+
generateOtp: () => Promise<string>;
160+
}
161+
162+
export async function loginToPortal() {
163+
const redirectPage = await http.get('https://portal.kaist.ac.kr/user/ssoLoginProcess.face');
164+
const html = redirectPage.data;
165+
const dom = new JSDOM(html);
166+
const { document } = dom.window;
167+
const action = document.querySelector('form')?.action;
168+
if (!action || !action.startsWith('https://portal.kaist.ac.kr/')) {
169+
throw new Error('Invalid action');
170+
}
171+
const inputElements = document.querySelectorAll('input[type="hidden"]');
172+
const formData = new URLSearchParams();
173+
inputElements.forEach((input) => {
174+
const name = input.getAttribute('name');
175+
const value = input.getAttribute('value');
176+
if (name && value) {
177+
formData.append(name, value);
178+
}
179+
});
180+
await http.post(action, formData);
181+
await http.get('https://portal.kaist.ac.kr/index.html');
182+
}
183+
184+
export async function getKaistCookie(options: GetKaistCookieRunOptions): Promise<CookieJar> {
185+
const { userId, password, generateOtp } = options;
186+
const serverPublicKey = await getPublicKey();
187+
const publicKey = pki.publicKeyFromPem(`-----BEGIN PUBLIC KEY-----\n${serverPublicKey}\n-----END PUBLIC KEY-----`);
188+
await loginIdPassword(userId, password, generateCryptoConfig(publicKey));
189+
const otp = await generateOtp();
190+
await loginOtp(userId, otp, generateCryptoConfig(publicKey));
191+
await loginToPortal();
192+
return cookieJar;
193+
}

index.ts

+55-78
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
1-
import puppeteer, {
2-
BrowserConnectOptions, BrowserLaunchArgumentOptions, LaunchOptions, Product,
3-
} from 'puppeteer';
4-
5-
export type PuppeteerLaunchOptions =
6-
LaunchOptions & BrowserLaunchArgumentOptions & BrowserConnectOptions & {
7-
product?: Product;
8-
extraPrefsFirefox?: Record<string, unknown>;
9-
};
1+
import axios from 'axios';
2+
import { wrapper } from 'axios-cookiejar-support';
3+
import { JSDOM } from 'jsdom';
4+
import { getKaistCookie } from './auth';
105

116
export interface KaistTodayNoticeRunOptions {
127
id: string;
138
password: string;
149
generateOtp: () => Promise<string>;
1510
size?: number;
16-
puppeteerLaunchOptions?: PuppeteerLaunchOptions;
1711
lang?: 'ko' | 'en';
1812
}
1913

@@ -31,82 +25,65 @@ function normalizeDate(date: string): string {
3125
return `${digits.substring(0, 4)}-${digits.substring(4, 6)}-${digits.substring(6, 8)}`;
3226
}
3327

28+
function extractNotices(html: string): KaistTodayNotice[] | null {
29+
const dom = new JSDOM(html);
30+
const { document } = dom.window;
31+
32+
const table = document.querySelector<HTMLTableElement>('.req_tbl_01');
33+
if (table === null) {
34+
return null;
35+
}
36+
const rows = table.querySelectorAll<HTMLTableRowElement>('tr');
37+
38+
const notices: KaistTodayNotice[] = [];
39+
rows.forEach((row) => {
40+
const a = row.querySelector<HTMLAnchorElement>('.req_tit>a');
41+
const meta = row.querySelectorAll<HTMLLabelElement>('.ellipsis');
42+
if (a === null) {
43+
return;
44+
}
45+
if (meta.length !== 4) {
46+
return;
47+
}
48+
notices.push({
49+
title: a.text.trim(),
50+
link: new URL(a.href, 'https://portal.kaist.ac.kr/').href,
51+
organization: (meta[0].textContent ?? '').trim(),
52+
author: (meta[1].textContent ?? '').trim(),
53+
views: Number((meta[2].textContent ?? '').trim()),
54+
date: normalizeDate((meta[3].textContent ?? '').trim()),
55+
});
56+
});
57+
58+
return notices;
59+
}
60+
3461
export default async function run(
3562
options: KaistTodayNoticeRunOptions,
3663
): Promise<KaistTodayNotice[] | null> {
3764
const {
38-
id, password, puppeteerLaunchOptions, lang, generateOtp,
65+
id, password, lang, generateOtp,
3966
} = options;
4067
const size = options.size ?? 10;
4168

42-
let browser = null;
43-
try {
44-
browser = await puppeteer.launch(puppeteerLaunchOptions);
45-
const page = await browser.newPage();
46-
await page.goto('https://iam2.kaist.ac.kr/#/userLogin');
47-
await page.waitForSelector('input[type=text]');
48-
await page.waitForSelector('input[type=password]');
49-
await page.type('input[type=text]', id);
50-
await page.waitForSelector('input[type=submit]');
51-
const loginMethods = await page.$$('input[type=submit]');
52-
await loginMethods[1].click();
53-
await page.type('input[type=password]', password);
54-
await page.waitForSelector('.loginbtn');
55-
await page.click('.loginbtn');
69+
const cookie = await getKaistCookie({
70+
userId: id,
71+
password,
72+
generateOtp,
73+
});
5674

57-
await page.waitForSelector('input[id=motp]');
58-
await page.click('input[id=motp]');
59-
await page.waitForSelector('.pass > input[type=password]');
60-
await page.type('.pass > input[type=password]', await generateOtp());
61-
await page.waitForSelector('.log > input[type=submit]');
62-
await page.click('.log > input[type=submit]');
75+
const http = axios.create();
76+
http.defaults.jar = cookie;
77+
http.defaults.withCredentials = true;
78+
wrapper(http);
6379

64-
await page.waitForSelector('.navbar-nav');
65-
await page.goto('https://portal.kaist.ac.kr/index.html');
66-
await page.waitForSelector('.ptl_search');
67-
if (lang) {
68-
await page.goto(`https://portal.kaist.ac.kr/lang/changeLang.face?langKnd=${lang}`);
69-
await page.waitForSelector('.ptl_search');
70-
}
71-
await page.goto('https://portal.kaist.ac.kr/index.html');
72-
await page.waitForSelector('.ptl_search');
73-
await page.goto(`https://portal.kaist.ac.kr/board/list.brd?boardId=today_notice&pageSize=${size}`);
74-
await page.waitForSelector('.req_btn_wrap');
75-
await page.waitForSelector('.req_tbl_01');
76-
const result = await page.evaluate(() => {
77-
const table = document.querySelector<HTMLTableElement>('.req_tbl_01');
78-
if (table === null) {
79-
return null;
80-
}
81-
const rows = table.querySelectorAll<HTMLTableRowElement>('tr');
82-
const notices: KaistTodayNotice[] = [];
83-
rows.forEach((row) => {
84-
const a = row.querySelector<HTMLAnchorElement>('.req_tit>a');
85-
const meta = row.querySelectorAll<HTMLLabelElement>('.ellipsis');
86-
if (a === null) {
87-
return;
88-
}
89-
if (meta.length !== 4) {
90-
return;
91-
}
92-
notices.push({
93-
title: a.text.trim(),
94-
link: a.href,
95-
organization: meta[0].innerText.trim(),
96-
author: meta[1].innerText.trim(),
97-
views: Number(meta[2].innerText.trim()),
98-
date: meta[3].innerText.trim(),
99-
});
100-
});
101-
return notices;
102-
});
103-
return result?.map((notice) => ({
104-
...notice,
105-
date: normalizeDate(notice.date),
106-
})) ?? null;
107-
} finally {
108-
if (browser) {
109-
await browser.close();
110-
}
80+
await http.get('https://portal.kaist.ac.kr/index.html');
81+
if (lang) {
82+
await http.get(`https://portal.kaist.ac.kr/lang/changeLang.face?langKnd=${lang}`);
11183
}
84+
85+
const boardPage = await http.get<string>(`https://portal.kaist.ac.kr/board/list.brd?boardId=today_notice&pageSize=${size}`);
86+
const html = boardPage.data;
87+
const notices = extractNotices(html);
88+
return notices;
11289
}

0 commit comments

Comments
 (0)