From 64c37c715450b73136a777f5974fd00db2ed1660 Mon Sep 17 00:00:00 2001 From: Edward Wang <37031713+ewang2002@users.noreply.github.com> Date: Fri, 3 Nov 2023 20:06:53 -0400 Subject: [PATCH] Add SMS Support to Login Script (#28) * feat: update config template to allow sms or push * refactor: reassign `isLoggedIn` var to inside promise instead - this might break, if so blame ruby * feat: add sms 2fa support * docs: update sample config files + README --- scripts/notifierbot/package.json | 2 +- scripts/webregautoin/README.md | 78 +++++++++- scripts/webregautoin/credentials.sample.json | 4 - .../webregautoin/credentials.sample_push.json | 10 ++ .../webregautoin/credentials.sample_sms.json | 17 +++ scripts/webregautoin/src/fns.ts | 140 +++++++++++++----- scripts/webregautoin/src/index.ts | 51 +++++-- scripts/webregautoin/src/types.ts | 34 ++++- 8 files changed, 276 insertions(+), 60 deletions(-) delete mode 100644 scripts/webregautoin/credentials.sample.json create mode 100644 scripts/webregautoin/credentials.sample_push.json create mode 100644 scripts/webregautoin/credentials.sample_sms.json diff --git a/scripts/notifierbot/package.json b/scripts/notifierbot/package.json index f76e3e8..9dd7155 100644 --- a/scripts/notifierbot/package.json +++ b/scripts/notifierbot/package.json @@ -1,6 +1,6 @@ { "name": "notifierbot", - "version": "0.1.0", + "version": "0.2.0", "description": "Automatically sends a message when the scraper is offline.", "main": "index.js", "scripts": { diff --git a/scripts/webregautoin/README.md b/scripts/webregautoin/README.md index 3418d28..a86dcfe 100644 --- a/scripts/webregautoin/README.md +++ b/scripts/webregautoin/README.md @@ -11,6 +11,22 @@ the new cookies. In the initial setup process, the headless Chrome browser will credentials and then automatically select the `Remember me for 7 days` checkbox when performing Duo authentication. That way, you don't need to worry about having to authenticate via Duo for the next 7 days. +## Authentication Modes +As of recently, this script supports either Push or SMS mode. The modes only really matter at the beginning (i.e., when +you start the script). +- Push mode essentially means that, when the script is starting up, the script will initially authenticate you using + Duo Push. +- SMS mode means that, when the script is starting up, the script will initially use the SMS code that best fits the + hint that is given (i.e., it will try to find the code you defined in the configuration file that satisfies the hint + "Your next SMS Passcode starts with XXX"). + +Push mode is probably the easiest to use short-term, but you'll need to restart the login script setup process every +6-7 days to ensure you can still keep yourself logged in. SMS mode is somewhat easy, and allows you to remember your +session for up to 70 days (10 codes times 7 days per code = 70 days). However, you probably won't be able to use SMS +mode _outside_ of this application. + +As a warning, SMS mode is not guaranteed to work as expected. Duo Push remains the most stable login process. + ## Requirements In order to ensure that you _can_ use this script, ensure that the following technical and non-technical requirements are satisfied. @@ -32,10 +48,11 @@ are satisfied. ## Setup To actually run this script, follow the directions below. -1. A sample configuration file has been provided for you; this file is called `credentials.example.json`. +1. Two sample configuration files have been provided for you: `credentials.sample_push.json` and `credentials.sample_sms.json`. 1. Rename this file to `credentials.json`. 2. Open the file and fill in your UC San Diego Active Directory username and password. - 3. Save your changes. + 3. Modify any other relevant settings (see the next section on the configuration file for more on this). + 4. Save your changes. 2. Next, install TypeScript globally: ``` @@ -67,4 +84,59 @@ To actually run this script, follow the directions below. complete, then the script is ready to serve future login requests. > **Warning:** -> You'll need to repeat this process every 6-7 days to ensure your scraper runs uninterrupted. \ No newline at end of file +> If you use `push` mode, you'll need to repeat this process every 6-7 days to ensure your scraper runs uninterrupted. +> +> If you use `sms` mode, you'll need to repeat this process every 70 days or so, but you must not use SMS mode outside +> of this program. In other words, if you use `sms` mode for this application, do not use Duo SMS outside of this app. + +## Configuration File Layout +There are two sample configuration files you can use; each of them correspond to the type of login process you can use +for this login script. + +Both configuration layouts will feature the same keys: +- `webreg.username` (`string`): Your UCSD Active Directory username. +- `webreg.password` (`string`): Your UCSD Active Directory password. +- `settings.loginType` (`sms` or `push`): The login process you want to use. This can only be `sms` or `push`. +- `settings.automaticPushEnabled` (`boolean`): Whether your account is configured to automatically sends a Duo Push on + login. If this value is `true`, then the login script will cancel the automatic push when setting itself up. + +### Duo Push +```json +{ + "webreg": { + "username": "", + "password": "" + }, + "settings": { + "loginType": "push", + "automaticPushEnabled": true + } +} +``` + +### Duo SMS +```json +{ + "webreg": { + "username": "", + "password": "" + }, + "settings": { + "loginType": "push", + "automaticPushEnabled": true, + "smsTokens": [ + "your", + "sms", + "tokens", + "as", + "strings" + ] + } +} +``` + +Additionally, Duo SMS configuration files have a third settings property: +- `settings.smsTokens` (`string[]`): a list of SMS tokens that Duo sent you. To obtain these tokens, log into your UCSD + account. When you reach the Duo 2FA screen, select "Enter a Passcode" and then click on "Text me new codes." You should + receive the tokens via text. When you do, just put the tokens into `settings.smsTokens`, ensuring that each element is + of type _string_ (**not** an integer). \ No newline at end of file diff --git a/scripts/webregautoin/credentials.sample.json b/scripts/webregautoin/credentials.sample.json deleted file mode 100644 index 714a17b..0000000 --- a/scripts/webregautoin/credentials.sample.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "username": "", - "password": "" -} \ No newline at end of file diff --git a/scripts/webregautoin/credentials.sample_push.json b/scripts/webregautoin/credentials.sample_push.json new file mode 100644 index 0000000..8bfe88f --- /dev/null +++ b/scripts/webregautoin/credentials.sample_push.json @@ -0,0 +1,10 @@ +{ + "webreg": { + "username": "", + "password": "" + }, + "settings": { + "loginType": "push", + "automaticPushEnabled": true + } +} \ No newline at end of file diff --git a/scripts/webregautoin/credentials.sample_sms.json b/scripts/webregautoin/credentials.sample_sms.json new file mode 100644 index 0000000..56cd2bb --- /dev/null +++ b/scripts/webregautoin/credentials.sample_sms.json @@ -0,0 +1,17 @@ +{ + "webreg": { + "username": "", + "password": "" + }, + "settings": { + "loginType": "push", + "automaticPushEnabled": true, + "smsTokens": [ + "your", + "sms", + "tokens", + "as", + "strings" + ] + } +} \ No newline at end of file diff --git a/scripts/webregautoin/src/fns.ts b/scripts/webregautoin/src/fns.ts index 447fd2f..3aca8cb 100644 --- a/scripts/webregautoin/src/fns.ts +++ b/scripts/webregautoin/src/fns.ts @@ -1,5 +1,8 @@ import * as puppeteer from "puppeteer"; -import {IContext} from "./types"; +import {Context, WebRegLoginResult} from "./types"; + +export const SMS = "sms"; +export const PUSH = "push"; export const NUM_ATTEMPTS_BEFORE_EXIT: number = 6; const WEBREG_URL: string = "https://act.ucsd.edu/webreg2/start"; @@ -104,8 +107,8 @@ export function waitFor(ms: number): Promise { * - `"ERROR UNABLE TO AUTHENTICATE."`, if the script is unable to log into WebReg * after a certain number of tries. */ -export async function fetchCookies(config: IContext, browser: puppeteer.Browser, isInit: boolean): Promise { - const termLog = config.termInfo?.termName ?? "ALL"; +export async function fetchCookies(ctx: Context, browser: puppeteer.Browser, isInit: boolean): Promise { + const termLog = ctx.termInfo?.termName ?? "ALL"; logNice(termLog, "GetCookies function called."); let numFailedAttempts = 0; @@ -146,8 +149,8 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, if (content.includes("Signing on Using:") && content.includes("TritonLink user name")) { logNice(termLog, "Attempting to sign in to TritonLink."); // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors - await page.type('#ssousername', config.credentials.username); - await page.type('#ssopassword', config.credentials.password); + await page.type('#ssousername', ctx.webreg.username); + await page.type('#ssopassword', ctx.webreg.password); await page.click('button[type="submit"]'); } @@ -156,7 +159,7 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, let loggedIn = false; - const r = await Promise.race([ + const r: WebRegLoginResult = await Promise.race([ // Either wait for the 'Go' button to show up, which implies that we // have an authenticated session, **OR** wait for the Duo frame // to show up. @@ -173,9 +176,11 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, await page.waitForSelector("#startpage-button-go", {visible: true, timeout: 30 * 1000}); } catch (_) { // conveniently ignore the error - return 2; + return WebRegLoginResult.UNKNOWN_ERROR; } - return 0; + + loggedIn = true; + return WebRegLoginResult.LOGGED_IN; })(), // Here, we *repeatedly* check to see if the Duo 2FA frame is visible AND some components of // the frame (in our case, the "Remember Me" checkbox) are visible. @@ -211,12 +216,12 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, }); clearInterval(interval); - return 1; + return WebRegLoginResult.NEEDS_DUO; })() ]); // If we hit this, then we just try again. - if (r === 2) { + if (r === WebRegLoginResult.UNKNOWN_ERROR) { // If too many failed attempts, then notify the caller. // After all, we don't want to make too many Duo pushes and get // the AD account blocked by ITS :) @@ -232,23 +237,18 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, logNice( termLog, - r === 0 + r === WebRegLoginResult.LOGGED_IN ? "'Go' button found. No 2FA needed." : "Duo 2FA frame found. Ignore the initial 2FA request; i.e., do not" - + " accept the 2FA request until you are told to do so." + + " accept the 2FA request until you are told to do so." ); - if (r === 0) { - loggedIn = true; - } - // Wait an additional 4 seconds to make sure everything loads up. await waitFor(4 * 1000); // No go button means we need to log in. - // We could just check if (r === 1) though - if (!(await page.$("#startpage-button-go"))) { - if (!isInit) { + if (r === WebRegLoginResult.NEEDS_DUO) { + if (!isInit && ctx.loginType === PUSH) { logNice(termLog, "Attempting to send request to Duo, but this wasn't supposed to happen"); throw new Error("ruby is bad"); } @@ -259,23 +259,25 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, if (!possDuoFrame) { logNice(termLog, "No possible Duo frame found. Returning empty string."); console.info(); - return ""; + throw new Error(); } const duoFrame = await possDuoFrame.contentFrame(); if (!duoFrame) { logNice(termLog, "Duo frame not attached. Returning empty string."); console.info(); - return ""; + throw new Error(); } - // it's possible that we might need to cancel our existing authentication request, - // especially if we have duo push automatically send upon logging in - await waitFor(1000); - const cancelButton = await duoFrame.$(".btn-cancel"); - if (cancelButton) { - await cancelButton.click(); - logNice(termLog, "Clicked the CANCEL button to cancel initial 2FA request. Do not respond to 2FA request."); + if (ctx.automaticPushEnabled) { + // it's possible that we might need to cancel our existing authentication request, + // especially if we have duo push automatically send upon logging in + await waitFor(1000); + const cancelButton = await duoFrame.$(".btn-cancel"); + if (cancelButton) { + await cancelButton.click(); + logNice(termLog, "Clicked the CANCEL button to cancel initial 2FA request. Do not respond to 2FA request."); + } } await waitFor(1000); @@ -283,9 +285,73 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, await duoFrame.click('#remember_me_label_text'); logNice(termLog, "Checked the 'Remember me for 7 days' box."); await waitFor(1000); - // Send me a push - await duoFrame.click('#auth_methods > fieldset > div.row-label.push-label > button'); - logNice(termLog, "A Duo push was sent. Please respond to the new 2FA request."); + + if (ctx.loginType === SMS) { + // Click on the 'enter a passcode' button + await duoFrame.click("#passcode"); + await waitFor(1500); + const smsCodeHint = await duoFrame.$("#auth_methods > fieldset > div.passcode-label.row-label > div > div"); + if (!smsCodeHint) { + logNice(termLog, "No SMS code hint found. That might be a problem."); + console.info(); + throw new Error(); + } + + const smsPasscodeHint = await smsCodeHint.evaluate(elem => elem.textContent); + if (!smsPasscodeHint || !smsPasscodeHint.startsWith("Your next SMS Passcode starts with")) { + logNice(termLog, "SMS code hint element found, but no text found or bad (ruby) text found."); + console.info(); + throw new Error(); + } + + logNice(termLog, `Found SMS passcode hint '${smsPasscodeHint}'`); + const hint = smsPasscodeHint.split(" ").at(-1)!; + const codeToUse = ctx.tokens.find(token => token.startsWith(hint)); + if (!codeToUse) { + logNice(termLog, `No SMS code could not be found that satisfies the hint ('${smsPasscodeHint}')`); + console.info(); + throw new Error(); + } + + logNice(termLog, `Code should start with number '${hint}'. Using code '${codeToUse}'`); + await waitFor(1500); + // Put the SMS code into the text box + const smsTextBox = await duoFrame.$("#auth_methods > fieldset > div.passcode-label.row-label > div > input"); + if (!smsTextBox) { + logNice(termLog, "Could not find the SMS text box to put your SMS token in."); + console.info(); + throw new Error(); + } + + await smsTextBox.type(codeToUse.toString()); + await waitFor(1500); + + // Then, press the "Log In" button + await duoFrame.click("#passcode"); + logNice(termLog, `Entered SMS code '${codeToUse}' and clicked the 'Log In' button.`); + await waitFor(1500); + + try { + // See if we used an incorrect code. + // + // NOTE: If we have the correct code, then the frame will no longer exist, so a try/catch + // is used to ensure that possibility happens. + const frameContent = await duoFrame.content(); + if (frameContent.includes("Incorrect passcode. Enter a passcode from Duo Mobile or a text.")) { + logNice(termLog, "The passcode is incorrect. This is so sad."); + console.info(); + throw new Error(); + } + } + catch (_) { + // conveniently ignore this error, too + } + } + else { + // Send me a push + await duoFrame.click('#auth_methods > fieldset > div.row-label.push-label > button'); + logNice(termLog, "A Duo push was sent. Please respond to the new 2FA request."); + } } try { @@ -310,9 +376,9 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, logNice(termLog, "Logged into WebReg successfully."); let urlToFetch: string = "https://act.ucsd.edu/webreg2/svc/wradapter/get-term"; - if (config.termInfo) { - const termName = config.termInfo.termName; - const termSelector = `${config.termInfo.seqId}:::${termName}`; + if (ctx.termInfo) { + const termName = ctx.termInfo.termName; + const termSelector = `${ctx.termInfo.seqId}:::${termName}`; await page.select("#startpage-select-term", termSelector); // Get cookies ready to load. await page.click('#startpage-button-go'); @@ -322,10 +388,10 @@ export async function fetchCookies(config: IContext, browser: puppeteer.Browser, const cookies = await page.cookies(urlToFetch); logNice(termLog, `Extracted cookies for term '${termLog}' and responding back with them.\n`); - if (config.session.start === 0) { - config.session.start = Date.now(); + if (ctx.session.start === 0) { + ctx.session.start = Date.now(); } else { - config.session.callHistory.push(Date.now()); + ctx.session.callHistory.push(Date.now()); } return cookies.map(x => `${x.name}=${x.value}`).join("; "); diff --git a/scripts/webregautoin/src/index.ts b/scripts/webregautoin/src/index.ts index 837eb50..139ccbd 100644 --- a/scripts/webregautoin/src/index.ts +++ b/scripts/webregautoin/src/index.ts @@ -9,9 +9,9 @@ import * as fs from "fs"; import * as path from "path"; import * as puppeteer from "puppeteer"; import * as http from "http"; -import {parseArgs} from 'node:util'; -import {fetchCookies, getTermSeqId, logNice, printHelpMessage} from "./fns"; -import {IContext, ICredentials, ITermInfo} from "./types"; +import { parseArgs } from 'node:util'; +import { PUSH, SMS, fetchCookies, getTermSeqId, logNice, printHelpMessage } from "./fns"; +import { IConfig, Context, ICredentials, ITermInfo } from "./types"; async function main(): Promise { const args = parseArgs({ @@ -44,7 +44,7 @@ async function main(): Promise { headless: !debug }); - const credentials: ICredentials = JSON.parse( + const config: IConfig = JSON.parse( fs.readFileSync(path.join(__dirname, "..", "credentials.json")).toString()); const term = args.values.term?.toUpperCase(); @@ -59,14 +59,41 @@ async function main(): Promise { } } - const context: IContext = { - credentials, - session: { - start: 0, - callHistory: [] - }, - termInfo - }; + let context: Context; + if (config.settings.loginType === SMS) { + if (!config.settings.smsTokens || config.settings.smsTokens.length === 0) { + console.error("If your login type is 'sms' then you must provide SMS tokens."); + process.exit(1); + } + + context = { + webreg: config.webreg, + session: { + start: 0, + callHistory: [] + }, + termInfo, + automaticPushEnabled: config.settings.automaticPushEnabled, + loginType: SMS, + tokens: config.settings.smsTokens + }; + } + else if (config.settings.loginType === PUSH) { + context = { + webreg: config.webreg, + session: { + start: 0, + callHistory: [] + }, + termInfo, + automaticPushEnabled: config.settings.automaticPushEnabled, + loginType: PUSH + }; + } + else { + console.error("Your login type must either be 'sms' or 'push'"); + process.exit(1); + } // Very basic server. const server = http.createServer(async (req, res) => { diff --git a/scripts/webregautoin/src/types.ts b/scripts/webregautoin/src/types.ts index ab9b22b..64cbc5e 100644 --- a/scripts/webregautoin/src/types.ts +++ b/scripts/webregautoin/src/types.ts @@ -1,8 +1,9 @@ -export interface IContext { - credentials: ICredentials; +export type Context = { + webreg: ICredentials; termInfo: ITermInfo | null; session: ISession; -} + automaticPushEnabled: boolean; +} & ({ loginType: "sms", tokens: string[] } | { loginType: "push" }); export interface ISession { /** @@ -17,6 +18,16 @@ export interface ISession { callHistory: number[]; } +export interface IConfig { + webreg: ICredentials; + settings: { + // Should be "sms" or "push" + loginType: string; + automaticPushEnabled: boolean; + smsTokens?: string[]; + }; +} + export interface ICredentials { username: string; password: string; @@ -25,4 +36,21 @@ export interface ICredentials { export interface ITermInfo { seqId: number; termName: string; +} + +export enum WebRegLoginResult { + /** + * Whether we're able to log into WebReg without any additional help. + */ + LOGGED_IN, + + /** + * Whether Duo 2FA is required for login. + */ + NEEDS_DUO, + + /** + * Whether an unknown error occurred. + */ + UNKNOWN_ERROR } \ No newline at end of file