summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLizzy Hunt <logan.hunt@usu.edu>2023-02-15 18:03:46 -0700
committerLizzy Hunt <logan.hunt@usu.edu>2023-02-15 18:05:55 -0700
commit32803c441678cd640e46153688d26c4c0746d7b3 (patch)
treef2f186df72073be9ca712d98dff7d180eaa34371
parent30cbc219e68ef5fc7da56e322e1aeca102bdb479 (diff)
downloadaggietimed-32803c441678cd640e46153688d26c4c0746d7b3.tar.gz
aggietimed-32803c441678cd640e46153688d26c4c0746d7b3.zip
We do a little logging, but cringe OpenAPI errors be making me want to shoot myself. We have some shit working though.
-rw-r--r--package-lock.json22
-rw-r--r--package.json2
-rw-r--r--src/actions.js11
-rw-r--r--src/aggietime.js62
-rw-r--r--src/constants.js17
-rw-r--r--src/exponential_retry.js35
-rw-r--r--src/main.js96
-rw-r--r--src/session.js76
8 files changed, 289 insertions, 32 deletions
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;
};