Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Web Login for SSO based on #21 #77

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules/
.DS_STORE
.credentials
.browser-data
.cookies-file
books/
.idea/
package-lock.json
111 changes: 81 additions & 30 deletions lib/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const program = require('commander');
const logger = require('../logger');
const Safari = require('../safari');
const SafariWeb = require('../safari-web');
const EbookWriter = require('../ebook-writer');
const debug = require('debug')('cli');
const fs = require('fs-promise');
Expand All @@ -16,18 +17,40 @@ program
.option('-b, --bookid <bookid>','the book id of the SafariBooksOnline ePub to be generated')
.option('-u, --username <username>','username of the SafariBooksOnline user - must have a **paid/trial membership**, otherwise will not be able to access the books')
.option('-p, --password <password>','password of the SafariBooksOnline user')
.option('-w, --weblogin', 'use web browser to authenticate instead of username/password - useful for Single Sign On (SSO)')
.option('-c, --clear', 'clear previously cached credentials/cookies')
.option('-o, --output <output>','output path the epub file should be saved to')
.option('-d, --debug','activate request debugging')
.parse(process.argv);

var username, password;
const browserDataDir = process.cwd() + '/.browser-data'
const cookiesFile = process.cwd() + '/.cookies-file'
const credentialsFile = process.cwd() + '/.credentials'

if(program.clear) {
logger.log("Deleting cached credentials/cookies")
if(fs.existsSync(credentialsFile))
fs.removeSync(credentialsFile);
if(fs.existsSync(cookiesFile))
fs.removeSync(cookiesFile);
if(fs.existsSync(browserDataDir))
fs.removeSync(browserDataDir);
logger.log("Deleted successfully")
}

var username, password, cookies;

// ## see whether .credentials already exists
if (fs.existsSync(__dirname + '/.credentials')) {
var credentials = JSON.parse(fs.readFileSync(__dirname + '/.credentials', 'utf-8'));
username = credentials.username;
password = credentials.password;
debug("there is an existing user cached with username: " + username);
if (fs.existsSync(credentialsFile)) {
var credentials = JSON.parse(fs.readFileSync(credentialsFile, 'utf-8'));
username = credentials.username;
password = credentials.password;
debug("there is an existing user cached with username: " + username);
}

if (fs.existsSync(cookiesFile)) {
cookies = JSON.parse(fs.readFileSync(cookiesFile, 'utf-8'));
debug("there is an existing cached cookie file");
}

// ## overwrite username and password if specified
Expand All @@ -36,31 +59,59 @@ if(program.password) password = program.password;

// ## Validate Input
if(!program.bookid) return console.log("error: option '-b' missing. please consider '--help' for more information.");
if(!username) return console.log("error: option '-u' missing. please consider '--help' for more information.");
if(!password) return console.log("error: option '-p' missing. please consider '--help' for more information.");
if(!program.output) return console.log("error: option '-o' missing. please consider '--help' for more information.");
if(!program.weblogin && (!username || !password)) {
console.log("warning: option '-u' and '-p' missing. please consider '--help' for more information.")
console.log("Falling back to web login...");
program.weblogin = true
}

// ## Starting CLI
logger.log(`starting application...`);
if(program.weblogin) {
if(cookies) {
console.log("Using cached cookies")
start()
} else {
var safariWebClient = new SafariWeb(browserDataDir);
safariWebClient.login().then(fetchedCookies => {
cookies = fetchedCookies;
start()
})
}
} else {
start()
}

// ## writing credentials to file
let json = JSON.stringify({ "username": username, "password": password });
fs.writeFile(__dirname + '/.credentials', json).then( () => {
debug(`the username and password were successfully cached`);
}).catch( (err) => {
debug(`an error occurred trying to write the username and pass to the cache file`);
});
function start() {
// ## Starting CLI
logger.log(`starting application...`);
// ## writing credentials to file
if(program.weblogin) {
let json = JSON.stringify(cookies);
fs.writeFile(cookiesFile, json).then(() => {
debug(`cookies were successfully cached`);
}).catch( (err) => {
debug(`an error occurred trying to write cookies to the cache file`);
});
} else {
let json = JSON.stringify({ "username": username, "password": password });
fs.writeFile(credentialsFile, json).then( () => {
debug(`the username and password were successfully cached`);
}).catch( (err) => {
debug(`an error occurred trying to write the username and pass to the cache file`);
});
}

// ## Authorize User
var safariClient = new Safari(program.debug);
safariClient.fetchBookById(program.bookid, username, password).then( (bookJSON) => {
// console.log(bookJSON);
var ebook = new EbookWriter(bookJSON);
return ebook.save(program.output);
}).then( () => {
// ## finished saving
debug("the epub was successfully saved");
logger.log("epub successfully saved. exiting...");
}).catch( (err) => {
logger.log(err);
});
// ## Authorize User
var safariClient = new Safari(program.debug);
safariClient.fetchBookById(program.bookid, username, password, cookies, program.weblogin).then( (bookJSON) => {
// console.log(bookJSON);
var ebook = new EbookWriter(bookJSON);
return ebook.save(program.output);
}).then( () => {
// ## finished saving
debug("the epub was successfully saved");
logger.log("epub successfully saved. exiting...");
}).catch( (err) => {
logger.log(err);
});
}
69 changes: 69 additions & 0 deletions lib/safari-web/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use strict';

const Nightmare = require('nightmare');
const logger = require('../logger');

Nightmare.action(
'flushCookies',
(name, options, parent, win, renderer, done) => {
parent.respondTo('flushCookies', done => {
win.webContents.session.cookies.flushStore(done)
});
done();
},
function (done) {
this.child.call('flushCookies', done)
}
);

const SafariWeb = function SafariWeb(browserDataDir) {
this.nightmare = Nightmare({
show: true,
waitTimeout: 180 * 1000,
pollInterval: 5000,
webPreferences: { partition: null },
paths: { userData: browserDataDir },
openDevTools: { mode: 'detach' }
})
};

SafariWeb.prototype.login = function login() {
return new Promise((resolve) => {
this.nightmare
.on('console', (log, msg) => {
logger.log(msg)
})
.goto('https://learning.oreilly.com')
.wait(() => {
return new Promise(function (resolveWait) {
console.log("Checking if authenticated...");
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function () {
if (xmlhttp.readyState === XMLHttpRequest.DONE) { // XMLHttpRequest.DONE == 4
if (xmlhttp.status === 200) {
console.log("Authentication successful");
resolveWait(this.response)
} else if (xmlhttp.status === 401) {
console.log("Not authenticated yet");
resolveWait(false)
} else {
console.log("Error while checking");
resolveWait(false)
}
}
};
xmlhttp.open("GET", "https://learning.oreilly.com/api/v1", true);
xmlhttp.send();
})
})
.flushCookies()
.cookies.get()
.end()
.then((fetchedCookies) => {
resolve(fetchedCookies)
})
})
};

// # export for external use
module.exports = SafariWeb;
92 changes: 51 additions & 41 deletions lib/safari/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,43 +66,50 @@ Safari.prototype.fetchStylesheet = function fetchStylesheet(id) {
*
* @param {String} username - the username of the safaribooksonline account
* @param {String} password - the password of the safaribooksonline account
* @param {Array} cookies - an array containing the website cookies
* @param {Boolean} weblogin - indicates if authentication was done through web login
*
* @return {String} accessToken - returns the access token
*/
Safari.prototype.authorizeUser = function authorizeUser(username, password) {
debug(`authorizeUser called with user ${username} and password ${password}`);
if (!username || !password) return Promise.reject("username or password was not specified");
// ## prepare options for oauth request
let options = {
method: 'POST',
uri: `${this.baseUrl}/oauth2/access_token/`,
form: {
"client_id" : this.clientId,
"client_secret" : this.clientSecret,
"grant_type" : "password",
"username" : username,
"password" : password
},
json: true
};
// ## make API call in order to retrieve Bearer Authorization Token
return request(options)
.then( (body) => {
// ### the token was successfully generated
let accessToken = body["access_token"];
if (!accessToken) {
debug('the access_token is not present in the server response, even though it returned an 200 OK');
return Promise.reject("The access_token is not present in the server response. Please contact the developer to fix this problem.")
}
this.accessToken = accessToken;
debug(`the access_token is: ${accessToken}`);
return Promise.resolve(accessToken);
})
.catch( (err) => {
// ### an error occured
debug(`an error occured while trying to fetch authorization token (error: ${err})`);
return Promise.reject(err);
});
Safari.prototype.authorizeUser = function authorizeUser(username, password, cookies, weblogin) {
if(weblogin) {
this.cookieString = cookies.map(c => c.name + "=" + encodeURIComponent(c.value)).join(";");
return Promise.resolve(true)
} else {
debug(`authorizeUser called with user ${username} and password ${password}`);
if (!username || !password) return Promise.reject("username or password was not specified");
// ## prepare options for oauth request
let options = {
method: 'POST',
uri: `${this.baseUrl}/oauth2/access_token/`,
form: {
"client_id" : this.clientId,
"client_secret" : this.clientSecret,
"grant_type" : "password",
"username" : username,
"password" : password
},
json: true
};
// ## make API call in order to retrieve Bearer Authorization Token
return request(options)
.then( (body) => {
// ### the token was successfully generated
let accessToken = body["access_token"];
if (!accessToken) {
debug('the access_token is not present in the server response, even though it returned an 200 OK');
return Promise.reject("The access_token is not present in the server response. Please contact the developer to fix this problem.")
}
this.accessToken = accessToken;
debug(`the access_token is: ${accessToken}`);
return Promise.resolve(accessToken);
})
.catch( (err) => {
// ### an error occured
debug(`an error occured while trying to fetch authorization token (error: ${err})`);
return Promise.reject(err);
});
}
}


Expand All @@ -117,13 +124,13 @@ Safari.prototype.authorizeUser = function authorizeUser(username, password) {
*/
Safari.prototype.fetchResource = function fetchResource(url, options) {
debug(`fetchResource called with URL: ${url}`);
if(!url || !this.accessToken) return Promise.reject("url was not specified or user has not been authorized yet");
if(!url || !this.accessToken && !this.cookieString) return Promise.reject("url was not specified or user has not been authorized yet");
// ## prepare options for resource request
var uri = `${this.baseUrl}/${url}`;
var json = true;
var headers = {
"authorization": `Bearer ${this.accessToken}`
};
var headers = {}
if(this.access_token) headers["authorization"] = `Bearer ${this.accessToken}`
if(this.cookieString) headers["cookie"] = `${this.cookieString}`
if(options && options.json == false) json = false;
if(options && options.uri) uri = options.uri;
let settings = {
Expand Down Expand Up @@ -279,16 +286,19 @@ Safari.prototype.getBookById = function getBookById(id) {
* @param {String} id - the id of the specified book
* @param {String} username - the username of the safaribook user
* @param {String} password - the password of the safaribook user
* @param {Array} cookies - an array containing the website cookies
* @param {Boolean} weblogin - indicates if authentication was done through web login
*
* @return {Object} body - returns the book json
*/
Safari.prototype.fetchBookById = function fetchBookById(id, username, password) {
Safari.prototype.fetchBookById = function fetchBookById(id, username, password, cookies, weblogin) {
debug(`fetchBookById called with id: ${id}`);
if(!id) return Promise.reject("id was not specified");
if(!username || !password) return Promise.reject("username or password was not specified");
if(weblogin && !cookies) return Promise.reject("could not fetch cookies using web login")
if(!weblogin && (!username || !password)) return Promise.reject("username or password was not specified");
// ## validation successful
// ## fetch required content
return this.authorizeUser(username, password).then( (accessToken) => {
return this.authorizeUser(username, password, cookies, weblogin).then( (accessToken) => {
// ### user authorized, fetch meta
logger.log(`the user "${username}" was successfully authorized...`);
return this.fetchMeta(id);
Expand Down
Loading