Skip to content

Commit

Permalink
Add SMS Support to Login Script (#28)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ewang2002 authored Nov 4, 2023
1 parent 01689d1 commit 64c37c7
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 60 deletions.
2 changes: 1 addition & 1 deletion scripts/notifierbot/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
78 changes: 75 additions & 3 deletions scripts/webregautoin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
```
Expand Down Expand Up @@ -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.
> 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).
4 changes: 0 additions & 4 deletions scripts/webregautoin/credentials.sample.json

This file was deleted.

10 changes: 10 additions & 0 deletions scripts/webregautoin/credentials.sample_push.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"webreg": {
"username": "",
"password": ""
},
"settings": {
"loginType": "push",
"automaticPushEnabled": true
}
}
17 changes: 17 additions & 0 deletions scripts/webregautoin/credentials.sample_sms.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"webreg": {
"username": "",
"password": ""
},
"settings": {
"loginType": "push",
"automaticPushEnabled": true,
"smsTokens": [
"your",
"sms",
"tokens",
"as",
"strings"
]
}
}
140 changes: 103 additions & 37 deletions scripts/webregautoin/src/fns.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -104,8 +107,8 @@ export function waitFor(ms: number): Promise<void> {
* - `"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<string> {
const termLog = config.termInfo?.termName ?? "ALL";
export async function fetchCookies(ctx: Context, browser: puppeteer.Browser, isInit: boolean): Promise<string> {
const termLog = ctx.termInfo?.termName ?? "ALL";
logNice(termLog, "GetCookies function called.");

let numFailedAttempts = 0;
Expand Down Expand Up @@ -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"]');
}

Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 :)
Expand All @@ -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");
}
Expand All @@ -259,33 +259,99 @@ 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);
// Remember me for 7 days
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 {
Expand All @@ -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');
Expand All @@ -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("; ");
Expand Down
Loading

0 comments on commit 64c37c7

Please sign in to comment.