Feat: Cookie authentication #130

Merged
KevinMidboe merged 21 commits from feat/cookie-authentication into master 2022-08-15 21:59:12 +00:00
6 changed files with 295 additions and 236 deletions
Showing only changes of commit 5b6a2c2651 - Show all commits

View File

@@ -11,15 +11,15 @@ class Cache {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
client.get(key, (error, reply) => { client.get(key, (error, reply) => {
if (reply == null) { if (reply == null) {
return reject() return reject();
} }
resolve(JSON.parse(reply)) resolve(JSON.parse(reply));
}) });
}) });
} }
/** /**
* Insert cache entry with key and value. * Insert cache entry with key and value.
* @param {String} key of the cache entry * @param {String} key of the cache entry
* @param {String} value of the cache entry * @param {String} value of the cache entry
@@ -27,22 +27,25 @@ class Cache {
* @returns {Object} * @returns {Object}
*/ */
set(key, value, timeToLive = 10800) { set(key, value, timeToLive = 10800) {
if (value == null || key == null) if (value == null || key == null) return null;
return null
const json = JSON.stringify(value); const json = JSON.stringify(value);
client.set(key, json, (error, reply) => { client.set(key, json, (error, reply) => {
if (reply == 'OK') { if (reply == "OK") {
// successfully set value with key, now set TTL for key // successfully set value with key, now set TTL for key
client.expire(key, timeToLive, (e) => { client.expire(key, timeToLive, e => {
if (e) if (e)
console.error('Unexpected error while setting expiration for key:', key, '. Error:', error) console.error(
}) "Unexpected error while setting expiration for key:",
key,
". Error:",
error
);
});
} }
}) });
return value return value;
} }
} }

View File

@@ -1,4 +1,4 @@
const fetch = require('node-fetch'); const fetch = require("node-fetch");
class Tautulli { class Tautulli {
constructor(apiKey, ip, port) { constructor(apiKey, ip, port) {
@@ -8,50 +8,66 @@ class Tautulli {
} }
buildUrlWithCmdAndUserid(cmd, user_id) { buildUrlWithCmdAndUserid(cmd, user_id) {
const url = new URL('api/v2', `http://${this.ip}:${this.port}`) const url = new URL("api/v2", `http://${this.ip}:${this.port}`);
url.searchParams.append('apikey', this.apiKey) url.searchParams.append("apikey", this.apiKey);
url.searchParams.append('cmd', cmd) url.searchParams.append("cmd", cmd);
url.searchParams.append('user_id', user_id) url.searchParams.append("user_id", user_id);
return url return url;
}
logTautulliError(error) {
console.error("error fetching from tautulli");
throw error;
} }
getPlaysByDayOfWeek(plex_userid, days, y_axis) { getPlaysByDayOfWeek(plex_userid, days, y_axis) {
const url = this.buildUrlWithCmdAndUserid('get_plays_by_dayofweek', plex_userid) const url = this.buildUrlWithCmdAndUserid(
url.searchParams.append('time_range', days) "get_plays_by_dayofweek",
url.searchParams.append('y_axis', y_axis) plex_userid
);
url.searchParams.append("time_range", days);
url.searchParams.append("y_axis", y_axis);
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => this.logTautulliError(error));
} }
getPlaysByDays(plex_userid, days, y_axis) { getPlaysByDays(plex_userid, days, y_axis) {
const url = this.buildUrlWithCmdAndUserid('get_plays_by_date', plex_userid) const url = this.buildUrlWithCmdAndUserid("get_plays_by_date", plex_userid);
url.searchParams.append('time_range', days) url.searchParams.append("time_range", days);
url.searchParams.append('y_axis', y_axis) url.searchParams.append("y_axis", y_axis);
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => this.logTautulliError(error));
} }
watchTimeStats(plex_userid) { watchTimeStats(plex_userid) {
const url = this.buildUrlWithCmdAndUserid('get_user_watch_time_stats', plex_userid) const url = this.buildUrlWithCmdAndUserid(
url.searchParams.append('grouping', 0) "get_user_watch_time_stats",
plex_userid
);
url.searchParams.append("grouping", 0);
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
} .catch(error => this.logTautulliError(error));
}
viewHistory(plex_userid) { viewHistory(plex_userid) {
const url = this.buildUrlWithCmdAndUserid('get_history', plex_userid) const url = this.buildUrlWithCmdAndUserid("get_history", plex_userid);
url.searchParams.append('start', 0)
url.searchParams.append('length', 50)
console.log('fetching url', url.href) url.searchParams.append("start", 0);
url.searchParams.append("length", 50);
console.log("fetching url", url.href);
return fetch(url.href) return fetch(url.href)
.then(resp => resp.json()) .then(resp => resp.json())
.catch(error => this.logTautulliError(error));
} }
} }

View File

@@ -1,219 +1,256 @@
const assert = require('assert'); const assert = require("assert");
const establishedDatabase = require('src/database/database'); const establishedDatabase = require("src/database/database");
class UserRepository { class UserRepository {
constructor(database) { constructor(database) {
this.database = database || establishedDatabase; this.database = database || establishedDatabase;
this.queries = { this.queries = {
read: 'select * from user where lower(user_name) = lower(?)', read: "select * from user where lower(user_name) = lower(?)",
create: 'insert into user (user_name) values (?)', create: "insert into user (user_name) values (?)",
change: 'update user set password = ? where user_name = ?', change: "update user set password = ? where user_name = ?",
retrieveHash: 'select * from user where user_name = ?', retrieveHash: "select * from user where user_name = ?",
getAdminStateByUser: 'select admin from user where user_name = ?', getAdminStateByUser: "select admin from user where user_name = ?",
link: 'update settings set plex_userid = ? where user_name = ?', link: "update settings set plex_userid = ? where user_name = ?",
unlink: 'update settings set plex_userid = null where user_name = ?', unlink: "update settings set plex_userid = null where user_name = ?",
createSettings: 'insert into settings (user_name) values (?)', createSettings: "insert into settings (user_name) values (?)",
updateSettings: 'update settings set user_name = ?, dark_mode = ?, emoji = ?', updateSettings:
getSettings: 'select * from settings where user_name = ?' "update settings set user_name = ?, dark_mode = ?, emoji = ?",
getSettings: "select * from settings where user_name = ?"
}; };
} }
/** /**
* Create a user in a database. * Create a user in a database.
* @param {User} user the user you want to create * @param {User} user the user you want to create
* @returns {Promise} * @returns {Promise}
*/ */
create(user) { create(user) {
return this.database.get(this.queries.read, user.username) return this.database
.get(this.queries.read, user.username)
.then(() => this.database.run(this.queries.create, user.username)) .then(() => this.database.run(this.queries.create, user.username))
.catch((error) => { .catch(error => {
if (error.name === 'AssertionError' || error.message.endsWith('user_name')) { if (
throw new Error('That username is already registered'); error.name === "AssertionError" ||
error.message.endsWith("user_name")
) {
throw new Error("That username is already registered");
} }
throw Error(error) throw Error(error);
}); });
} }
/** /**
* Retrieve a password from a database. * Retrieve a password from a database.
* @param {User} user the user you want to retrieve the password * @param {User} user the user you want to retrieve the password
* @returns {Promise} * @returns {Promise}
*/ */
retrieveHash(user) { retrieveHash(user) {
return this.database.get(this.queries.retrieveHash, user.username) return this.database
.get(this.queries.retrieveHash, user.username)
.then(row => { .then(row => {
assert(row, 'The user does not exist.'); assert(row, "The user does not exist.");
return row.password; return row.password;
}) })
.catch(err => { console.log(error); throw new Error('Unable to find your user.'); }) .catch(err => {
console.log(error);
throw new Error("Unable to find your user.");
});
} }
/** /**
* Change a user's password in a database. * Change a user's password in a database.
* @param {User} user the user you want to create * @param {User} user the user you want to create
* @param {String} password the new password you want to change * @param {String} password the new password you want to change
* @returns {Promise} * @returns {Promise}
*/ */
changePassword(user, password) { changePassword(user, password) {
return this.database.run(this.queries.change, [password, user.username]) return this.database.run(this.queries.change, [password, user.username]);
} }
/** /**
* Link plex userid with seasoned user * Link plex userid with seasoned user
* @param {String} username the user you want to lunk plex userid with * @param {String} username the user you want to lunk plex userid with
* @param {Number} plexUserID plex unique id * @param {Number} plexUserID plex unique id
* @returns {Promsie} * @returns {Promsie}
*/ */
linkPlexUserId(username, plexUserID) { linkPlexUserId(username, plexUserID) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.database.run(this.queries.link, [plexUserID, username]) this.database
.run(this.queries.link, [plexUserID, username])
.then(row => resolve(row)) .then(row => resolve(row))
.catch(error => { .catch(error => {
// TODO log this unknown db error // TODO log this unknown db error
console.log('db error', error) console.error("db error", error);
reject({ reject({
status: 500, status: 500,
message: 'An unexpected error occured while linking plex and seasoned accounts', message:
source: 'seasoned database' "An unexpected error occured while linking plex and seasoned accounts",
}) source: "seasoned database"
}) });
}) });
});
} }
/** /**
* Unlink plex userid with seasoned user * Unlink plex userid with seasoned user
* @param {User} user the user you want to lunk plex userid with * @param {User} user the user you want to lunk plex userid with
* @returns {Promsie} * @returns {Promsie}
*/ */
unlinkPlexUserId(username) { unlinkPlexUserId(username) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.database.run(this.queries.unlink, username) this.database
.run(this.queries.unlink, username)
.then(row => resolve(row)) .then(row => resolve(row))
.catch(error => { .catch(error => {
// TODO log this unknown db error // TODO log this unknown db error
console.log('db error', error) console.log("db error", error);
reject({ reject({
status: 500, status: 500,
message: 'An unexpected error occured while unlinking plex and seasoned accounts', message:
source: 'seasoned database' "An unexpected error occured while unlinking plex and seasoned accounts",
}) source: "seasoned database"
}) });
}) });
});
} }
/** /**
* Check if the user has boolean flag set for admin in database * Check if the user has boolean flag set for admin in database
* @param {User} user object * @param {User} user object
* @returns {Promsie} * @returns {Promsie}
*/ */
checkAdmin(user) { checkAdmin(user) {
return this.database.get(this.queries.getAdminStateByUser, user.username) return this.database
.then((row) => row.admin); .get(this.queries.getAdminStateByUser, user.username)
.then(row => row.admin);
} }
/** /**
* Get settings for user matching string username * Get settings for user matching string username
* @param {String} username * @param {String} username
* @returns {Promsie} * @returns {Promsie}
*/ */
getSettings(username) { getSettings(username) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.database.get(this.queries.getSettings, username) this.database
.then(async (row) => { .get(this.queries.getSettings, username)
.then(async row => {
if (row == null) { if (row == null) {
console.log(`settings do not exist for user: ${username}. Creating settings entry.`) console.debug(
`settings do not exist for user: ${username}. Creating settings entry.`
);
const userExistsWithUsername = await this.database.get('select * from user where user_name is ?', username) const userExistsWithUsername = await this.database.get(
"select * from user where user_name is ?",
username
);
if (userExistsWithUsername !== undefined) { if (userExistsWithUsername !== undefined) {
try { try {
resolve(this.dbCreateSettings(username)) resolve(this.dbCreateSettings(username));
} catch (error) { } catch (error) {
reject(error) reject(error);
} }
} else { } else {
reject({ reject({
status: 404, status: 404,
message: 'User not found, no settings to get' message: "User not found, no settings to get"
}) });
} }
} }
resolve(row) resolve(row);
}) })
.catch(error => { .catch(error => {
console.error('Unexpected error occured while fetching settings for your account. Error:', error) console.error(
"Unexpected error occured while fetching settings for your account. Error:",
error
);
reject({ reject({
status: 500, status: 500,
message: 'An unexpected error occured while fetching settings for your account', message:
source: 'seasoned database' "An unexpected error occured while fetching settings for your account",
}) source: "seasoned database"
}) });
}) });
});
} }
/** /**
* Update settings values for user matching string username * Update settings values for user matching string username
* @param {String} username * @param {String} username
* @param {String} dark_mode * @param {String} dark_mode
* @param {String} emoji * @param {String} emoji
* @returns {Promsie} * @returns {Promsie}
*/ */
updateSettings(username, dark_mode=undefined, emoji=undefined) { updateSettings(username, dark_mode = undefined, emoji = undefined) {
const settings = this.getSettings(username) const settings = this.getSettings(username);
dark_mode = dark_mode !== undefined ? dark_mode : settings.dark_mode dark_mode = dark_mode !== undefined ? dark_mode : settings.dark_mode;
emoji = emoji !== undefined ? emoji : settings.emoji emoji = emoji !== undefined ? emoji : settings.emoji;
return this.dbUpdateSettings(username, dark_mode, emoji) return this.dbUpdateSettings(username, dark_mode, emoji).catch(error => {
.catch(error => { if (error.status && error.message) {
if (error.status && error.message) { return error;
return error }
}
return { return {
status: 500, status: 500,
message: 'An unexpected error occured while updating settings for your account' message:
} "An unexpected error occured while updating settings for your account"
}) };
});
} }
/** /**
* Helper function for creating settings in the database * Helper function for creating settings in the database
* @param {String} username * @param {String} username
* @returns {Promsie} * @returns {Promsie}
*/ */
dbCreateSettings(username) { dbCreateSettings(username) {
return this.database.run(this.queries.createSettings, username) return this.database
.run(this.queries.createSettings, username)
.then(() => this.database.get(this.queries.getSettings, username)) .then(() => this.database.get(this.queries.getSettings, username))
.catch(error => rejectUnexpectedDatabaseError('Unexpected error occured while creating settings', 503, error)) .catch(error =>
rejectUnexpectedDatabaseError(
"Unexpected error occured while creating settings",
503,
error
)
);
} }
/** /**
* Helper function for updating settings in the database * Helper function for updating settings in the database
* @param {String} username * @param {String} username
* @returns {Promsie} * @returns {Promsie}
*/ */
dbUpdateSettings(username, dark_mode, emoji) { dbUpdateSettings(username, dark_mode, emoji) {
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
this.database.run(this.queries.updateSettings, [username, dark_mode, emoji]) this.database
.then(row => resolve(row))) .run(this.queries.updateSettings, [username, dark_mode, emoji])
.then(row => resolve(row))
);
} }
} }
const rejectUnexpectedDatabaseError = (
const rejectUnexpectedDatabaseError = (message, status, error, reject=null) => { message,
console.error(error) status,
error,
reject = null
) => {
console.error(error);
const body = { const body = {
status, status,
message, message,
source: 'seasoned database' source: "seasoned database"
} };
if (reject == null) { if (reject == null) {
return new Promise((resolve, reject) => reject(body)) return new Promise((resolve, reject) => reject(body));
} }
reject(body) reject(body);
} };
module.exports = UserRepository; module.exports = UserRepository;

View File

@@ -1,10 +1,10 @@
const bcrypt = require('bcrypt'); const bcrypt = require("bcrypt");
const UserRepository = require('src/user/userRepository'); const UserRepository = require("src/user/userRepository");
class UserSecurity { class UserSecurity {
constructor(database) { constructor(database) {
this.userRepository = new UserRepository(database); this.userRepository = new UserRepository(database);
} }
/** /**
* Create a new user in PlanFlix. * Create a new user in PlanFlix.
@@ -13,15 +13,15 @@ class UserSecurity {
* @returns {Promise} * @returns {Promise}
*/ */
createNewUser(user, clearPassword) { createNewUser(user, clearPassword) {
if (user.username.trim() === '') { if (user.username.trim() === "") {
throw new Error('The username is empty.'); throw new Error("The username is empty.");
} else if (clearPassword.trim() === '') { } else if (clearPassword.trim() === "") {
throw new Error('The password is empty.'); throw new Error("The password is empty.");
} else { } else {
return Promise.resolve() return Promise.resolve()
.then(() => this.userRepository.create(user)) .then(() => this.userRepository.create(user))
.then(() => UserSecurity.hashPassword(clearPassword)) .then(() => UserSecurity.hashPassword(clearPassword))
.then(hash => this.userRepository.changePassword(user, hash)) .then(hash => this.userRepository.changePassword(user, hash));
} }
} }
@@ -35,21 +35,22 @@ class UserSecurity {
return Promise.resolve() return Promise.resolve()
.then(() => this.userRepository.retrieveHash(user)) .then(() => this.userRepository.retrieveHash(user))
.then(hash => UserSecurity.compareHashes(hash, clearPassword)) .then(hash => UserSecurity.compareHashes(hash, clearPassword))
.catch(() => { throw new Error('Incorrect username or password.'); }); .catch(() => {
throw new Error("Incorrect username or password.");
});
} }
/** /**
* Compare between a password and a hash password from database. * Compare between a password and a hash password from database.
* @param {String} hash the hash password from database * @param {String} hash the hash password from database
* @param {String} clearPassword the user's password * @param {String} clearPassword the user's password
* @returns {Promise} * @returns {Promise}
*/ */
static compareHashes(hash, clearPassword) { static compareHashes(hash, clearPassword) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
bcrypt.compare(clearPassword, hash, (error, match) => { bcrypt.compare(clearPassword, hash, (error, match) => {
if (match) if (match) resolve(true);
resolve() reject(false);
reject()
}); });
}); });
} }
@@ -60,7 +61,7 @@ class UserSecurity {
* @returns {Promise} * @returns {Promise}
*/ */
static hashPassword(clearPassword) { static hashPassword(clearPassword) {
return new Promise((resolve) => { return new Promise(resolve => {
const saltRounds = 10; const saltRounds = 10;
bcrypt.hash(clearPassword, saltRounds, (error, hash) => { bcrypt.hash(clearPassword, saltRounds, (error, hash) => {
resolve(hash); resolve(hash);

View File

@@ -1,47 +1,52 @@
const configuration = require('src/config/configuration').getInstance(); const configuration = require("src/config/configuration").getInstance();
const Tautulli = require('src/tautulli/tautulli'); const Tautulli = require("src/tautulli/tautulli");
const apiKey = configuration.get('tautulli', 'apiKey'); const apiKey = configuration.get("tautulli", "apiKey");
const ip = configuration.get('tautulli', 'ip'); const ip = configuration.get("tautulli", "ip");
const port = configuration.get('tautulli', 'port'); const port = configuration.get("tautulli", "port");
const tautulli = new Tautulli(apiKey, ip, port); const tautulli = new Tautulli(apiKey, ip, port);
function handleError(error, res) { function handleError(error, res) {
const { status, message } = error; const { status, message } = error;
if (status && message) { if (status && message) {
res.status(status).send({ success: false, message }) return res.status(status).send({ success: false, message });
} else { } else {
console.log('caught view history controller error', error) console.log("caught view history controller error", error);
res.status(500).send({ message: 'An unexpected error occured while fetching view history'}) return res.status(500).send({
message: "An unexpected error occured while fetching view history"
});
} }
} }
function watchTimeStatsController(req, res) { function watchTimeStatsController(req, res) {
const user = req.loggedInUser; const user = req.loggedInUser;
tautulli.watchTimeStats(user.plex_userid) return tautulli
.watchTimeStats(user.plex_userid)
.then(data => { .then(data => {
console.log('data', data, JSON.stringify(data.response.data))
return res.send({ return res.send({
success: true, success: true,
data: data.response.data, data: data.response.data,
message: 'watch time successfully fetched from tautulli' message: "watch time successfully fetched from tautulli"
}) });
}) })
.catch(error => handleError(error, res));
} }
function getPlaysByDayOfWeekController(req, res) { function getPlaysByDayOfWeekController(req, res) {
const user = req.loggedInUser; const user = req.loggedInUser;
const { days, y_axis } = req.query; const { days, y_axis } = req.query;
tautulli.getPlaysByDayOfWeek(user.plex_userid, days, y_axis) return tautulli
.then(data => res.send({ .getPlaysByDayOfWeek(user.plex_userid, days, y_axis)
success: true, .then(data =>
data: data.response.data, res.send({
message: 'play by day of week successfully fetched from tautulli' success: true,
}) data: data.response.data,
message: "play by day of week successfully fetched from tautulli"
})
) )
.catch(error => handleError(error, res));
} }
function getPlaysByDaysController(req, res) { function getPlaysByDaysController(req, res) {
@@ -52,49 +57,46 @@ function getPlaysByDaysController(req, res) {
return res.status(422).send({ return res.status(422).send({
success: false, success: false,
message: "Missing parameter: days (number)" message: "Missing parameter: days (number)"
}) });
} }
const allowedYAxisDataType = ['plays', 'duration']; const allowedYAxisDataType = ["plays", "duration"];
if (!allowedYAxisDataType.includes(y_axis)) { if (!allowedYAxisDataType.includes(y_axis)) {
return res.status(422).send({ return res.status(422).send({
success: false, success: false,
message: `Y axis parameter must be one of values: [${ allowedYAxisDataType }]` message: `Y axis parameter must be one of values: [${allowedYAxisDataType}]`
}) });
} }
tautulli.getPlaysByDays(user.plex_userid, days, y_axis) return tautulli
.then(data => res.send({ .getPlaysByDays(user.plex_userid, days, y_axis)
.then(data =>
res.send({
success: true, success: true,
data: data.response.data data: data.response.data
})) })
)
.catch(error => handleError(error, res));
} }
function userViewHistoryController(req, res) { function userViewHistoryController(req, res) {
const user = req.loggedInUser; const user = req.loggedInUser;
console.log('user', user) // TODO here we should check if we can init tau
// and then return 501 Not implemented
return tautulli
.viewHistory(user.plex_userid)
.then(data => {
return res.send({
success: true,
data: data.response.data.data,
message: "view history successfully fetched from tautulli"
});
})
.catch(error => handleError(error, res));
// TODO here we should check if we can init tau // const username = user.username;
// and then return 501 Not implemented
tautulli.viewHistory(user.plex_userid)
.then(data => {
console.log('data', data, JSON.stringify(data.response.data.data))
return res.send({
success: true,
data: data.response.data.data,
message: 'view history successfully fetched from tautulli'
})
})
.catch(error => handleError(error))
// const username = user.username;
} }
module.exports = { module.exports = {

View File

@@ -1,11 +1,11 @@
const mustBeAuthenticated = (req, res, next) => { const mustBeAuthenticated = (req, res, next) => {
if (req.loggedInUser === undefined) { if (req.loggedInUser === undefined) {
return res.status(401).send({ return res.status(401).send({
success: false, success: false,
message: 'You must be logged in.', message: "You must be logged in."
}); });
} }
return next(); return next();
}; };
module.exports = mustBeAuthenticated; module.exports = mustBeAuthenticated;