diff --git a/package-lock.json b/package-lock.json index 4f19135..6c470c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,11 @@ "packages": { "": { "dependencies": { + "argparse": "^2.0.1", "axios": "^1.3.3", "axios-cookiejar-support": "^4.0.6", "dotenv": "^16.0.3", + "expire-cache": "^1.0.0", "node-html-parser": "^6.1.4", "tough-cookie": "^4.1.2" } @@ -23,6 +25,11 @@ "node": ">= 6.0.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -192,6 +199,11 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/expire-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/expire-cache/-/expire-cache-1.0.0.tgz", + "integrity": "sha512-5qSqF7yhqxeZ/G0JFppVq+rQsh1Lx49jI9uaO7oye93eOXmsGQ0B9dmgCybKeGS2GHNyeg6O0kbeNiadWfH1WQ==" + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -372,6 +384,11 @@ "debug": "4" } }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -481,6 +498,11 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" }, + "expire-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/expire-cache/-/expire-cache-1.0.0.tgz", + "integrity": "sha512-5qSqF7yhqxeZ/G0JFppVq+rQsh1Lx49jI9uaO7oye93eOXmsGQ0B9dmgCybKeGS2GHNyeg6O0kbeNiadWfH1WQ==" + }, "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", diff --git a/package.json b/package.json index 65d0489..9524710 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "dependencies": { + "argparse": "^2.0.1", "axios": "^1.3.3", "axios-cookiejar-support": "^4.0.6", "dotenv": "^16.0.3", + "expire-cache": "^1.0.0", "node-html-parser": "^6.1.4", "tough-cookie": "^4.1.2" }, diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000..37b64f4 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,11 @@ +import * as aggietime from "./aggietime.js"; + +const ACTIONS = { + "clock-in": aggietime.clock_in, +}; + +export const do_action = async (body) => { + const { action, rest } = body; + + return await ACTIONS[action](rest); +}; diff --git a/src/aggietime.js b/src/aggietime.js new file mode 100644 index 0000000..793481c --- /dev/null +++ b/src/aggietime.js @@ -0,0 +1,62 @@ +import { + AGGIETIME_URI, + AGGIETIME_DOMAIN, + USER_PATH, + USER_CACHE_EXP_SEC, + CLOCKIN_PATH, +} from "./constants.js"; + +import { client } from "./axios_client.js"; + +import expireCache from "expire-cache"; + +const replace_path_args = (path, map) => + path.replaceAll(/:([a-zA-Z0-9_]+)/g, (_, key) => map[key]); + +const get_user_position_or_specified = async (position) => { + const { positions } = await get_user_info(); + + if (!position && positions.length != 1) { + throw "Must specify a position when there's not only one to choose from"; + } else if (!position) { + position = positions[0]; + } + + return position; +}; + +export const get_user_info = async () => { + if (!expireCache.get("user")) { + const user = await client + .get(`${AGGIETIME_URI}/${USER_PATH}`) + .then(({ data, config }) => { + const csrf_token = config.jar + .toJSON() + .cookies.find( + ({ domain, key }) => + domain === AGGIETIME_DOMAIN && key === "XSRF-TOKEN" + ).value; + expireCache.set("aggietime-csrf", csrf_token); + return data; + }); + + expireCache.set("user", user, USER_CACHE_EXP_SEC); + } + return expireCache.get("user"); +}; + +export const clock_in = async ({ position } = {}) => { + position = await get_user_position_or_specified(position); + + return await client.post( + `${AGGIETIME_URI}/${replace_path_args(CLOCKIN_PATH, { position })}`, + { + comment: "", + }, + { + headers: { + "X-XSRF-TOKEN": expireCache.get("aggietime-csrf"), + }, + } + ); +}; diff --git a/src/constants.js b/src/constants.js index a0b09e5..1044d40 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,6 +1,14 @@ -export const AGGIETIME_URI = "https://aggietimeultra.usu.edu"; +export const DEFAULT_SOCKET_PATH = "/tmp/aggietimed.sock"; +export const KILL_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT"]; + +export const AGGIETIME_DOMAIN = "aggietimeultra.usu.edu"; +export const AGGIETIME_URI = `https://${AGGIETIME_DOMAIN}`; export const LOGIN_PATH = "api/v1/auth/login"; +export const LOGOUT_PATH = "api/v1/auth/logout"; +export const CLOCKIN_PATH = "api/v1/positions/:position/clock_in"; export const USER_PATH = "api/v1/auth/get_user_info"; +export const REFRESH_JWT_MS = 5 * 1000 * 60; + export const EXECUTION_SELECTOR = "input[type=hidden][name=execution]"; export const DUO_IFRAME_SELECTOR = "#duo_iframe"; export const DUO_FACTOR = "Duo Push"; @@ -11,3 +19,10 @@ export const DUO_INPUT_FIELD_SELECTORS = [ "input[type=hidden][name=days_to_block]", "input[type=hidden][name=preferred_device]", ]; + +export const USER_CACHE_EXP_SEC = 30; + +export const MAX_DEFAULT_RETRY_AMOUNT = 3; +export const WAIT_MS = 2000; +export const RETRY_EXPONENT = 1.2; +export const RETRY_EXPONENTIAL_FACTOR = 1.1; diff --git a/src/exponential_retry.js b/src/exponential_retry.js new file mode 100644 index 0000000..96ca979 --- /dev/null +++ b/src/exponential_retry.js @@ -0,0 +1,35 @@ +import { + MAX_DEFAULT_RETRY_AMOUNT, + WAIT_MS, + RETRY_EXPONENT, + RETRY_EXPONENTIAL_FACTOR, +} from "./constants.js"; + +const wait_for = (ms) => new Promise((rs) => setTimeout(rs, ms)); + +export const with_exponential_retry = async ( + promise_fn, + validation_fn = (x) => Promise.resolve(!!x), + max_retries = MAX_DEFAULT_RETRY_AMOUNT, + retries = 0 +) => { + try { + if (retries) + await wait_for( + WAIT_MS * Math.pow(RETRY_EXPONENT, RETRY_EXPONENTIAL_FACTOR * retries) + ); + + const res = await promise_fn(); + if (await validation_fn(res)) return res; + + throw new Error("Validation predicate not satisfied"); + } catch (e) { + if (retries >= max_retries) throw e; + return with_exponential_retry( + promise_fn, + validation_fn, + max_retries, + retries + 1 + ); + } +}; diff --git a/src/main.js b/src/main.js index 80243cd..099e4af 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,94 @@ -import { login } from "./session.js"; +import { + DEFAULT_SOCKET_PATH, + KILL_SIGNALS, + REFRESH_JWT_MS, +} from "./constants.js"; +import * as actions from "./actions.js"; +import * as session from "./session.js"; +import * as argparse from "argparse"; +import * as net from "net"; import * as dotenv from "dotenv"; +import * as fs from "fs"; -dotenv.config(); +const main = async () => { + dotenv.config(); + const args = build_args(); -(async () => { - await login(process.env.A_NUMBER, process.env.PASSWORD); -})(); + if (args.daemon) { + try { + start_server(args.socket_path, session.logout); + } catch { + fs.unlinkSync(args.socket_path); + } + } +}; + +const build_args = () => { + const parser = new argparse.ArgumentParser({ description: "AggieTime CLI" }); + + parser.add_argument("-d", "--daemon", { + help: "Start server as a process blocking daemon", + action: argparse.BooleanOptionalAction, + default: true, + }); + + parser.add_argument("-s", "--socket_path", { + default: DEFAULT_SOCKET_PATH, + help: `Set server socket path, defaults to ${DEFAULT_SOCKET_PATH}`, + }); + + return parser.parse_args(); +}; + +const kill_server = (server, socket_path) => { + server.close(); + + try { + fs.unlinkSync(socket_path); + } finally { + process.exit(); + } +}; + +const start_server = async (socket_path, on_exit = () => {}) => { + if (fs.existsSync(socket_path)) { + console.error( + `ERR: Socket '${socket_path}' already exists. +If no server process is running, remove it (this should've been done automatically, except in the event of a catastrophic failure) +OR +specify another socket path with --socket_path` + ); + process.exit(1); + } + + await session.login(process.env.A_NUMBER, process.env.PASSWORD); + session.refresh_jwt(); + setInterval(session.refresh_jwt, REFRESH_JWT_MS); + + const unix_server = net.createServer((client) => { + client.on("data", (data) => { + // 4096 byte limitation since we don't buffer here :3 + let body; + try { + body = JSON.parse(data); + } catch { + console.error("Client provided invalid JSON data"); + return; + } + + actions.do_action(body); + }); + }); + + unix_server.on("close", () => kill_server(unix_server, socket_path)); + + console.log(`Server listening on socket ${socket_path}...`); + unix_server.listen(socket_path); + + // Attempt to clean up socket before process gets killed + KILL_SIGNALS.forEach((signal) => + process.on(signal, () => kill_server(unix_server, socket_path)) + ); +}; + +main(); diff --git a/src/session.js b/src/session.js index 0206731..a127ac2 100644 --- a/src/session.js +++ b/src/session.js @@ -1,17 +1,17 @@ import { AGGIETIME_URI, LOGIN_PATH, + LOGOUT_PATH, USER_PATH, DUO_IFRAME_SELECTOR, DUO_FACTOR, DUO_INPUT_FIELD_SELECTORS, EXECUTION_SELECTOR, } from "./constants.js"; - +import * as aggietime from "./aggietime.js"; import { client } from "./axios_client.js"; import { parse } from "node-html-parser"; -//import axios from "axios"; const make_auth_params = (username, password, execution) => new URLSearchParams({ @@ -22,6 +22,22 @@ const make_auth_params = (username, password, execution) => geolocation: "", }); +const make_duo_push_params = ( + sid, + out_of_date, + days_out_of_date, + days_to_block, + device +) => + new URLSearchParams({ + sid, + out_of_date, + days_out_of_date, + days_to_block, + device, + factor: DUO_FACTOR, + }); + const push_duo_get_cookie = async ( duo_iframe_obj, response_url, @@ -34,49 +50,43 @@ const push_duo_get_cookie = async ( "data-sig-request", "src", ].map((attr) => duo_iframe_obj.getAttribute(attr)); - const transaction_id = duo_sig.split(":").at(0); - const app = duo_sig.split(":APP").at(-1); const duo = client.create({ baseURL: `https://${duo_host}`, }); + const transaction_id = duo_sig.split(":").at(0); + const app = duo_sig.split(":APP").at(-1); + console.log("Retrieving DUO frame DOM for this transaction..."); const duo_frame = await duo .post( `/frame/web/v1/auth?tx=${transaction_id}&parent=${response_url}&v=2.6` ) .then(({ data }) => parse(data)); - const [sid, out_of_date, days_out_of_date, days_to_block, device] = - DUO_INPUT_FIELD_SELECTORS.map((selector) => - duo_frame.querySelector(selector).getAttribute("value") - ); - - const push_params = new URLSearchParams({ - sid, - out_of_date, - days_out_of_date, - days_to_block, - device, - factor: DUO_FACTOR, - }); + const push_param_list = DUO_INPUT_FIELD_SELECTORS.map((selector) => + duo_frame.querySelector(selector).getAttribute("value") + ); + let [sid, _] = push_param_list; const { response: { txid }, - } = await duo.post("/frame/prompt", push_params).then(({ data }) => data); + } = await duo + .post("/frame/prompt", make_duo_push_params.apply(null, push_param_list)) + .then(({ data }) => data); + console.log("Waiting for approval..."); const { cookie, parent } = await wait_approve_duo_cookie_resp(duo, sid, txid); return { duo_signed_resp: cookie + ":APP" + app, parent }; }; const wait_approve_duo_cookie_resp = async (duo, sid, txid) => { + // First status to confirm device was pushed to, + // Second to create a long-poll connection-alive socket for approval status :3 const status_params = new URLSearchParams({ sid, txid, }); - - // First status to confirm device was pushed to - // Second to long-poll for approval :3 const { response: { result_url }, } = await duo.post("/frame/status", status_params).then(async ({ data }) => { @@ -93,33 +103,48 @@ const wait_approve_duo_cookie_resp = async (duo, sid, txid) => { .post(result_url, new URLSearchParams({ sid })) .then(({ data }) => data); + if (!cookie) throw "Unable to retrieve signed cookie from DUO"; + return { cookie, parent }; }; -const get_execution = (cas_root) => {}; +export const refresh_jwt = () => { + console.log("Refreshing JWT..."); + + return aggietime.get_user_info(); +}; + +export const logout = () => client.get(`${AGGIETIME_URI}/${LOGOUT_PATH}`); export const login = async (username, password) => { const login_page_promise = client.get(`${AGGIETIME_URI}/${LOGIN_PATH}`); + console.log("Retreiving login page..."); const { request: { res: { responseUrl: response_url }, }, } = await login_page_promise; + let cas_root = await login_page_promise.then(({ data }) => parse(data)); + + console.log("Parsing DOM for spring execution token..."); const login_execution = cas_root .querySelector(EXECUTION_SELECTOR) .getAttribute("value"); + console.log("Sending CAS credentials..."); cas_root = await client .post(response_url, make_auth_params(username, password, login_execution)) .then(({ data }) => parse(data)); + + console.log("Parsing DOM for authenticated spring execution token..."); const authed_execution = cas_root .querySelector(EXECUTION_SELECTOR) .getAttribute("value"); const duo_iframe_obj = cas_root.querySelector(DUO_IFRAME_SELECTOR); - + console.log("Starting DUO authentication..."); const { duo_signed_resp, parent: signed_response_url } = await push_duo_get_cookie( duo_iframe_obj, @@ -129,7 +154,8 @@ export const login = async (username, password) => { login_execution ); - const jwt_cookie_set = await client.post( + console.log("Sending DUO signed response back to CAS..."); + return await client.post( signed_response_url, new URLSearchParams({ execution: authed_execution, @@ -137,6 +163,4 @@ export const login = async (username, password) => { _eventId: "submit", }) ); - - return jwt_cookie_set; };