|
| 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 | +} |
0 commit comments