diff --git a/src/cli.ts b/src/cli.ts index bbf3774..cd6b68d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { loadConfFromToml, promptToGetConf, getSchoolInfos } from './conf' +import { sstore } from './index' ;(async () => { const argv = process.argv[2] || '' const argv2 = process.argv[3] @@ -18,18 +20,38 @@ All Commands: break } case 'user': { - break - } - case 'school': { + const config = await promptToGetConf() + if (config) { + const schoolInfos = getSchoolInfos(config) + if (schoolInfos) { + sstore.set('schools', schoolInfos) + } + sstore.set('users', config) + } break } case 'rm': { + sstore.delete(argv2) break } case 'sign': { + // get cookie + // load plugin + // close cea break } case 'load': { + const config = loadConfFromToml() + if (config) { + const schoolInfos = getSchoolInfos(config) + if (schoolInfos) { + sstore.set('schools', schoolInfos) + } + sstore.set('users', config) + sstore.set('config', config) + } } } + + sstore.close() })() diff --git a/src/compatibility/api.ts b/src/compatibility/api.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/compatibility/edge-case.ts b/src/compatibility/edge-case.ts new file mode 100644 index 0000000..21e121f --- /dev/null +++ b/src/compatibility/edge-case.ts @@ -0,0 +1,52 @@ +const schoolEdgeCases = { + 宁波大学: { + formIdx: 0, + rememberMe: 'on', + cookiePath: '/', + checkCaptchaPath: '/needCaptcha.html', + getCaptchaPath: '/captcha.html', + }, +} + +// we will using proxy to get the default properties +const defaultProps = { + rememberMe: true, + getCaptchaPath: '/getCaptcha.htl', + checkCaptchaPath: '/checkNeedCaptcha.htl', + cookiePath: '/authserver', + formIdx: 2, + pwdEncrypt: true, +} + +const iapDefaultProps = { + lt: '/security/lt', + rememberMe: true, + checkCaptchaPath: '/checkNeedCaptcha', + getCaptchaPath: '/generateCaptcha', +} + +export type EdgeCasesSchools = keyof typeof schoolEdgeCases +type NoIapDefaultProps = typeof defaultProps +type IapDefaultProps = typeof iapDefaultProps + +export type DefaultProps = NoIapDefaultProps & IapDefaultProps + +/** + * handle edge cases, proxy default properties + */ +export default (schoolName: EdgeCasesSchools, isIap: boolean) => + schoolName + ? (new Proxy(schoolEdgeCases[schoolName] || {}, { + get(target, prop, receiver) { + if ( + target[prop as keyof NoIapDefaultProps & keyof IapDefaultProps] === + undefined + ) { + return isIap + ? iapDefaultProps[prop as keyof IapDefaultProps] + : defaultProps[prop as keyof NoIapDefaultProps] + } + return Reflect.get(target, prop, receiver) + }, + }) as unknown as DefaultProps) + : ({} as DefaultProps) diff --git a/src/conf.ts b/src/conf.ts index 758af2a..9929836 100644 --- a/src/conf.ts +++ b/src/conf.ts @@ -1,49 +1,27 @@ -import { UsersConf, UserConfOpts } from './types/conf' -import { UserAction } from './constants' +import { UsersConf, UserConfOpts, SchoolConf } from './types/conf' import { parse } from '@iarna/toml' import { prompt } from 'enquirer' import { resolve } from 'path' +import { AnyObject } from './types/helper' +import { UserAction } from './constants' + +import fetch, { Response } from 'node-fetch' +import log from './utils/logger' import fs from 'fs' export function loadConfFromToml(): UsersConf | null { const path = resolve('./conf.toml') if (fs.existsSync(path)) { - const usersConf = parse(fs.readFileSync(path, 'utf8')) as UsersConf + const usersConf = parse(fs.readFileSync(path, 'utf8'))!.users as UsersConf return usersConf } return null } -export function loadConfFromEnv({ - users, -}: { - users: string -}): UsersConf | null { - if (users) { - const loadedUsers = users.split('\n').map((user) => { - let addr: string | Array = user.split('home ')[1] - addr = addr ? addr.split(' ') : [''] - - const [school, username, password, alias] = user.split(' ') - const userConfOpts: UserConfOpts = { - school, - username, - password, - alias, - addr, - } - return userConfOpts - }) - return { users: loadedUsers } - } else { - return null - } -} - export async function promptToGetConf(): Promise { const toml = loadConfFromToml() - const loadedUsers = toml ? toml.users : [] + const loadedUsers = toml ? toml : [] const actionNaire = { type: 'list', @@ -85,7 +63,7 @@ export async function promptToGetConf(): Promise { { name: 'school', message: - '学校的英文简称(推荐,仅部分学校支持使用简称)\n请参阅 https://github.com/beetcb/cea/blob/master/docs/abbrList.sh 自行判断\n或中文全称(备用选项,所有学校均支持):', + '学校的英文简称(推荐,仅部分学校支持使用简称)\n其它学校请参阅 https://github.com/beetcb/cea/blob/master/docs/abbrList.sh 寻找简称', initial: 'whu', }, { @@ -97,7 +75,7 @@ export async function promptToGetConf(): Promise { ], } const { addUser } = (await prompt([form])) as { addUser: UserConfOpts } - return { users: [...loadedUsers, addUser] } + return [...loadedUsers, addUser] } case UserAction.DELETE: { const deleteUserNaire = { @@ -107,12 +85,50 @@ export async function promptToGetConf(): Promise { choices: loadedUsers.map((e) => e.alias), } const res = (await prompt([deleteUserNaire])) as { deleteUser: string } - return { - users: [...loadedUsers.filter((val) => val.alias !== res.deleteUser)], - } + return [...loadedUsers.filter((val) => val.alias !== res.deleteUser)] } case UserAction.CANCEL: { return null } } } + +export async function getSchoolInfos( + users: UsersConf +): Promise { + let res: Response, + schoolInfos = {} as SchoolConf + const schoolNamesSet = new Set(...users.map((e) => e.school)) + for (const abbreviation in schoolNamesSet) { + res = (await fetch( + `https://mobile.campushoy.com/v6/config/guest/tenant/info?ids=${abbreviation}` + ).catch((err) => log.error(err))) as Response + + const data = JSON.parse( + (await res.text().catch((err) => log.error(err))) as string + ).data[0] as AnyObject + + let origin = new URL(data.ampUrl).origin + const casOrigin = data.idsUrl + + // fall back to ampUrl2 when campusphere not included in the `origin` + if (!origin.includes('campusphere')) { + origin = new URL(data.ampUrl2).origin + } + + schoolInfos[abbreviation] = { + loginStartEndpoint: `${origin}/iap/login?service=${encodeURIComponent( + `${origin}/portal/login` + )}`, + swms: new URL(casOrigin), + chineseName: data.name, + campusphere: new URL(origin), + isIap: data.joinType !== 'NOTCLOUD', + } + + log.success({ message: `你的学校 ${data.name} 已完成设定` }) + return schoolInfos + } + log.error('未配置学校信息') + return null +} diff --git a/src/crawler/capcha.ts b/src/crawler/capcha.ts new file mode 100644 index 0000000..35b4147 --- /dev/null +++ b/src/crawler/capcha.ts @@ -0,0 +1,54 @@ +import { createWorker } from 'tesseract.js' +import fetch from 'node-fetch' +import fs from 'fs' +const tessdataPath = '/tmp/eng.traineddata.gz' + +async function downloadTessdata() { + process.env.TESSDATA_PREFIX = '/tmp' + // check folder exists + if (!fs.existsSync('/tmp')) { + fs.mkdirSync('/tmp') + } else { + // check file exists + if (fs.existsSync(tessdataPath)) { + return + } + } + download( + 'https://beetcb.gitee.io/filetransfer/tmp/eng.traineddata.gz', + tessdataPath + ) +} + +async function download(url: string, filename: string): string { + const stream = fs.createWriteStream(filename) + const res = await fetch(url) + const result = await new Promise((resolve, reject) => { + res.body.pipe(stream) + res.body.on('error', reject) + stream.on('close', () => resolve(`Downloaded tess data as ${filename}`)) + }) + return result as string +} + +async function ocr(captchaUrl: string) { + await downloadTessdata() + const worker = createWorker({ + langPath: '/tmp', + cachePath: '/tmp', + }) + await worker.load() + await worker.loadLanguage('eng') + await worker.initialize('eng') + await worker.setParameters({ + tessedit_char_whitelist: + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', + }) + const { + data: { text }, + } = await worker.recognize(captchaUrl) + await worker.terminate() + return text +} + +export default ocr diff --git a/src/crawler/login.ts b/src/crawler/login.ts new file mode 100644 index 0000000..f611bea --- /dev/null +++ b/src/crawler/login.ts @@ -0,0 +1,213 @@ +import cheerio from 'cheerio' +import crypto from 'crypto' +import ocr from './capcha' +import log from '../utils/logger' +import getEdgeCases from '../compatibility/edge-case' + +import { + SchoolConfOpts, + UserConfOpts, + EdgeCasesSchools, + DefaultProps, +} from '../types/conf' +import { AnyObject } from '../types/helper' +import { FetchWithCookie } from '../utils/fetch-helper' +import { Response } from 'node-fetch' + +/** + * login to SWMS(stu work magagement system) process + * @param {object} school api info + * @param {object} user user info for login + * @return {map} cookie for cas and campusphere + */ +export async function login(school: SchoolConfOpts, user: UserConfOpts) { + // improve school campatibility with defaults and edge-cases + const schoolEdgeCases: DefaultProps = getEdgeCases( + school.chineseName as EdgeCasesSchools, + school.isIap + ) + + const headers: AnyObject = { + 'Cache-Control': 'max-age=0', + 'Accept-Encoding': 'gzip, deflate', + 'Upgrade-Insecure-Requests': '1', + 'User-agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + } + + const fetch = new FetchWithCookie(headers) + const name = user.alias + const cookiePath = schoolEdgeCases.cookiePath + + let res: Response + // get base session + res = await fetch.get(school.loginStartEndpoint) + // redirect to auth + res = await fetch.follow() + + // grab hidden input name-value, this maybe error-prone, but compatible + const hiddenInputNameValueMap: AnyObject = {} + let pwdSalt + if (schoolEdgeCases.formIdx !== undefined) { + // create document for crawling + const body = await res.text() + const $ = cheerio.load(body) + const form = $('form[method=post]').get(schoolEdgeCases.formIdx) + const hiddenInputList = $('input[type=hidden]', form!) + + hiddenInputList.each((e: number) => { + const hiddenInputNode = hiddenInputList[e] + const [name, value] = [ + hiddenInputNode.attribs.name, + hiddenInputNode.attribs.value, + ] + if (name && value) { + hiddenInputNameValueMap[name] = value + } else if (value) { + // password salt here + pwdSalt = value + } + }) + } else { + // if we got here, this site definitely uses AJAX to get those props (`iap`) + // we need to request those properties manually + headers.Referer = res.headers.get('location')! + const ltWrapper = new URL(headers.Referer).search + if (Object.keys(hiddenInputNameValueMap).length === 0) { + res = await fetch.get( + `${school.swms.origin}${schoolEdgeCases.lt}${ltWrapper}` + ) + const { result } = await res.json() + Object.defineProperties(hiddenInputNameValueMap, { + lt: { value: result._lt, enumerable: true }, + needCaptcha: { value: result.needCapt, enumerable: true }, + dllt: { value: '', enumerable: true }, + iap: { value: true, enumerable: true }, + }) + // seems dcampus forgot to impl _encryptSalt, comment it out temporarily + // pwdSalt = result._encryptSalt + } + } + + // construct login form + const auth = new URLSearchParams({ + username: user.username, + password: pwdSalt + ? new AES(user.password, pwdSalt).encrypt() + : user.password, + ...hiddenInputNameValueMap, + rememberMe: String(schoolEdgeCases.rememberMe), + }) + + // check captcha is needed + const addtionalParams = `?username=${user.username}<Id=${hiddenInputNameValueMap.lt}` + const needCaptcha = + hiddenInputNameValueMap.needCaptcha === undefined + ? ( + await ( + await fetch.get( + `${school.swms.origin}${schoolEdgeCases.checkCaptchaPath}${addtionalParams}`, + { cookiePath } + ) + ).text() + ).includes('true') + : hiddenInputNameValueMap.needCaptcha + + if (needCaptcha) { + log.warn({ + message: '登录需要验证码,正在用 OCR 识别', + suffix: `@${name}`, + }) + const captcha = ( + await ocr( + `${school.swms.origin}${schoolEdgeCases.getCaptchaPath}${addtionalParams}` + ) + ).replace(/\s/g, '') + + if (captcha.length >= 4) { + log.success({ + message: `使用验证码 ${captcha} 登录`, + suffix: `@${name}`, + }) + auth.append('captcha', captcha) + } else { + log.error({ + message: `验证码识别失败,长度${captcha.length}错误`, + suffix: `@${name}`, + }) + return + } + } + + res = await fetch.follow({ + type: 'form', + body: auth.toString(), + cookiePath: cookiePath, + }) + + const isRedirect = res.headers.get('location') + if (!isRedirect) { + log.error({ message: `登录失败,${res.statusText}`, suffix: `@${name}` }) + return + } else if (isRedirect.includes('.do')) { + log.error({ + message: `登录失败,密码安全等级低,需要修改`, + suffix: `@${name}`, + }) + return + } + + // redirect to campus + res = await fetch.follow({ cookiePath }) + + // get MOD_AUTH_CAS + res = await fetch.follow({ cookiePath }) + + if (/30(1|2|7|8)/.test(res.status + '')) { + log.success({ + message: `登录成功`, + suffix: `@${name}`, + }) + } else { + log.error({ + message: `登录失败,${res.statusText}`, + suffix: `@${name}`, + }) + return + } + + return fetch.getCookieObj() +} + +class AES { + private pwd: string + private key: string + constructor(pwd: string, key: string) { + this.pwd = pwd + this.key = key + } + + encrypt() { + const { key, pwd } = this + + const rs = this.randomString + const data = rs(64) + pwd + + const algorithm = 'aes-128-cbc' + const iv = Buffer.alloc(16, 0) + + const cipher = crypto.createCipheriv(algorithm, key, iv) + let en = cipher.update(data, 'utf8', 'base64') + en += cipher.final('base64') + return en + } + + randomString(len: number) { + const str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678' + return Array(len) + .fill(null) + .reduce((s = '') => (s += str.charAt(Math.floor(Math.random() * 48))), '') + } +} diff --git a/src/index.ts b/src/index.ts index e69de29..00494da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1 @@ +export const sstore = require('@beetcb/sstore') diff --git a/src/types/conf.ts b/src/types/conf.ts index 5816a40..d3bf229 100644 --- a/src/types/conf.ts +++ b/src/types/conf.ts @@ -1,6 +1,7 @@ -export type UsersConf = { - users: Array -} +import { CookieRawObject } from './cookie' +export { EdgeCasesSchools, DefaultProps } from '../compatibility/edge-case' + +export type UsersConf = Array export type UserConfOpts = { username: string @@ -8,4 +9,20 @@ export type UserConfOpts = { alias: string school: string addr: Array + cookie?: CookieRawObject +} + +export type SchoolConf = { + [school: string]: SchoolConfOpts +} + +export type SchoolConfOpts = { + // idsUrl + campusphere: URL + // ampUrl 1 or 2 + swms: URL + // `${compusphere.origin}/iap/login?service=${encodeURIComponent(`${campusphere}/portal/login`)}` + loginStartEndpoint: string + chineseName: string + isIap: boolean } diff --git a/src/types/cookie.ts b/src/types/cookie.ts index 5bbcbca..9330062 100644 --- a/src/types/cookie.ts +++ b/src/types/cookie.ts @@ -1,5 +1,9 @@ export type CookieMap = Map> +export type CookieRawObject = { + [key in `${'campusphere::' | 'swms::'}${string}`]: string +} + export interface FetchCookieOptions { type?: 'json' | 'form' cookiePath?: string diff --git a/src/types/helper.ts b/src/types/helper.ts new file mode 100644 index 0000000..5758f18 --- /dev/null +++ b/src/types/helper.ts @@ -0,0 +1,3 @@ +export type AnyObject = { + [key: string]: string +} diff --git a/src/utils/fetch-helper.ts b/src/utils/fetch-helper.ts index 25104d4..852cd78 100644 --- a/src/utils/fetch-helper.ts +++ b/src/utils/fetch-helper.ts @@ -1,23 +1,24 @@ import fetch from 'node-fetch' import { CookieMap, FetchCookieOptions } from '../types/cookie' import { cookieParse, cookieStr } from './cookie-helper' -import { Headers, Response } from 'node-fetch' +import { Response } from 'node-fetch' +import { AnyObject } from '../types/helper' export class FetchWithCookie { - private headers: { [key: string]: any } + private headers: AnyObject private cookieMap?: CookieMap private redirectUrl?: string - constructor(headers: Headers) { + constructor(headers: AnyObject) { this.headers = headers this.cookieMap = undefined this.redirectUrl = undefined } - async get(url: string, options = {}) { + async get(url: string, options = {}): Promise { return await this.fetch(url, options) } - async post(url: string, options: FetchCookieOptions) { + async post(url: string, options: FetchCookieOptions): Promise { options.isPost = true return await this.fetch(url, options) } @@ -26,10 +27,10 @@ export class FetchWithCookie { * keep requesting last request url * @param {} options */ - async follow(options: FetchCookieOptions) { + async follow(options?: FetchCookieOptions): Promise { return new Promise((resolve, reject) => this.redirectUrl - ? resolve(this.fetch(this.redirectUrl, options)) + ? resolve(this.fetch(this.redirectUrl, options || {})) : reject({ status: 555 }) ) } @@ -43,7 +44,7 @@ export class FetchWithCookie { headers.referer = origin headers.host = host headers.cookie = this.cookieMap - ? cookieStr(host, cookiePath!, this.cookieMap) + ? cookieStr(host, cookiePath!, this.cookieMap)! : '' headers['Content-Type'] = @@ -66,7 +67,7 @@ export class FetchWithCookie { } getCookieObj() { - let obj: { [key: string]: string } = {} + let obj: AnyObject = {} for (const [key, val] of this.cookieMap!.entries()) { const [_, feild, path] = key.match(/(.*)(::.*)/)! obj[`${feild.includes('campusphere') ? 'campusphere' : 'swms'}${path}`] =