diff --git a/seasoned_api/package.json b/seasoned_api/package.json index 94cd8bd..74a5699 100644 --- a/seasoned_api/package.json +++ b/seasoned_api/package.json @@ -5,9 +5,11 @@ "start": "cross-env SEASONED_CONFIG=conf/development.json NODE_PATH=. node src/webserver/server.js" }, "dependencies": { + "bcrypt-nodejs": "^0.0.3", "body-parser": "~1.0.1", "cross-env": "^3.1.3", "express": "~4.0.0", + "jsonwebtoken": "^8.0.1", "mongoose": "^3.6.13", "moviedb": "^0.2.10", "node-cache": "^4.1.1", diff --git a/seasoned_api/src/searchHistory/searchHistory.js b/seasoned_api/src/searchHistory/searchHistory.js new file mode 100644 index 0000000..fbe2f35 --- /dev/null +++ b/seasoned_api/src/searchHistory/searchHistory.js @@ -0,0 +1,39 @@ +const establishedDatabase = require('src/database/database'); + +class SearchHistory { + + constructor(database) { + this.database = database || establishedDatabase; + this.queries = { + 'create': 'insert into search_history (search_query, user_name) values (?, ?)', + 'read': 'select search_query from search_history where user_name = ? order by id desc', + }; + } + + /** + * Retrive a search queries for a user from the database. + * @param {User} user existing user + * @returns {Promise} + */ + read(user) { + return this.database.all(this.queries.read, user.username) + .then(rows => rows.map(row => row.search_query)); + } + + /** + * Creates a new search entry in the database. + * @param {User} user a new user + * @param {String} searchQuery the query the user searched for + * @returns {Promise} + */ + create(user, searchQuery) { + return this.database.run(this.queries.create, [searchQuery, user.username]).catch((error) => { + if (error.message.includes('FOREIGN')) { + throw new Error('Could not create search history.'); + } + }); + } + +} + +module.exports = SearchHistory; diff --git a/seasoned_api/src/user/token.js b/seasoned_api/src/user/token.js new file mode 100644 index 0000000..bce5c84 --- /dev/null +++ b/seasoned_api/src/user/token.js @@ -0,0 +1,38 @@ +const User = require('src/user/user'); +const jwt = require('jsonwebtoken'); + +class Token { + + constructor(user) { + this.user = user; + } + + /** + * Generate a new token. + * @param {String} secret a cipher of the token + * @returns {String} + */ + toString(secret) { + return jwt.sign({ username: this.user.username }, secret); + } + + /** + * Decode a token. + * @param {Token} jwtToken an encrypted token + * @param {String} secret a cipher of the token + * @returns {Token} + */ + static fromString(jwtToken, secret) { + let username = null; + + try { + username = jwt.verify(jwtToken, secret).username; + } catch (error) { + throw new Error('The token is invalid.'); + } + const user = new User(username); + return new Token(user); + } +} + +module.exports = Token; diff --git a/seasoned_api/src/user/user.js b/seasoned_api/src/user/user.js new file mode 100644 index 0000000..a4bb44c --- /dev/null +++ b/seasoned_api/src/user/user.js @@ -0,0 +1,8 @@ +class User { + constructor(username, email) { + this.username = username; + this.email = email; + } +} + +module.exports = User; \ No newline at end of file diff --git a/seasoned_api/src/user/userRepository.js b/seasoned_api/src/user/userRepository.js new file mode 100644 index 0000000..b81ef30 --- /dev/null +++ b/seasoned_api/src/user/userRepository.js @@ -0,0 +1,59 @@ +const assert = require('assert'); +const establishedDatabase = require('src/database/database'); + +class UserRepository { + + constructor(database) { + this.database = database || establishedDatabase; + this.queries = { + read: 'select * from user where lower(user_name) = lower(?)', + create: 'insert into user (user_name, email) values(?, ?)', + change: 'update user set password = ? where user_name = ?', + retrieveHash: 'select * from user where user_name = ?', + }; + } + + /** + * Create a user in a database. + * @param {User} user the user you want to create + * @returns {Promise} + */ + create(user) { + return Promise.resolve() + .then(() => this.database.get(this.queries.read, user.username)) + .then(row => assert.equal(row, undefined)) + .then(() => this.database.run(this.queries.create, [user.username, user.email])) + .catch((error) => { + if (error.message.endsWith('email')) { + throw new Error('That email is already taken'); + } else if (error.name === 'AssertionError' || error.message.endsWith('user_name')) { + throw new Error('That username is already taken'); + } + }); + } + + /** + * Retrieve a password from a database. + * @param {User} user the user you want to retrieve the password + * @returns {Promise} + */ + retrieveHash(user) { + return this.database.get(this.queries.retrieveHash, user.username).then((row) => { + assert(row, 'The user does not exist.'); + return row.password; + }); + } + + /** + * Change a user's password in a database. + * @param {User} user the user you want to create + * @param {String} password the new password you want to change + * @returns {Promise} + */ + changePassword(user, password) { + return this.database.run(this.queries.change, [password, user.username]); + } + +} + +module.exports = UserRepository; diff --git a/seasoned_api/src/user/userSecurity.js b/seasoned_api/src/user/userSecurity.js new file mode 100644 index 0000000..185b1bd --- /dev/null +++ b/seasoned_api/src/user/userSecurity.js @@ -0,0 +1,76 @@ +const bcrypt = require('bcrypt-nodejs'); +const UserRepository = require('src/user/userRepository'); + +class UserSecurity { + + constructor(database) { + this.userRepository = new UserRepository(database); + } + + /** + * Create a new user in PlanFlix. + * @param {User} user the new user you want to create + * @param {String} clearPassword a password of the user + * @returns {Promise} + */ + createNewUser(user, clearPassword) { + if (user.username.trim() === '') { + throw new Error('The username is empty.'); + } else if (user.email.trim() === '') { + throw new Error('The email is empty.'); + } else if (clearPassword.trim() === '') { + throw new Error('The password is empty.'); + } else { + return Promise.resolve() + .then(() => this.userRepository.create(user)) + .then(() => UserSecurity.hashPassword(clearPassword)) + .then(hash => this.userRepository.changePassword(user, hash)); + } + } + + /** + * Login into PlanFlix. + * @param {User} user the user you want to login + * @param {String} clearPassword the user's password + * @returns {Promise} + */ + login(user, clearPassword) { + return Promise.resolve() + .then(() => this.userRepository.retrieveHash(user)) + .then(hash => UserSecurity.compareHashes(hash, clearPassword)) + .catch(() => { throw new Error('Wrong username or password.'); }); + } + + /** + * Compare between a password and a hash password from database. + * @param {String} hash the hash password from database + * @param {String} clearPassword the user's password + * @returns {Promise} + */ + static compareHashes(hash, clearPassword) { + return new Promise((resolve, reject) => { + bcrypt.compare(clearPassword, hash, (error, matches) => { + if (matches === true) { + resolve(); + } else { + reject(); + } + }); + }); + } + + /** + * Hashes a password. + * @param {String} clearPassword the user's password + * @returns {Promise} + */ + static hashPassword(clearPassword) { + return new Promise((resolve) => { + bcrypt.hash(clearPassword, null, null, (error, hash) => { + resolve(hash); + }); + }); + } +} + +module.exports = UserSecurity; diff --git a/seasoned_api/src/webserver/app.js b/seasoned_api/src/webserver/app.js index 9bd47fe..b09f078 100644 --- a/seasoned_api/src/webserver/app.js +++ b/seasoned_api/src/webserver/app.js @@ -1,7 +1,9 @@ -var express = require('express'); // call express -var app = express(); // define our app using express +var express= require('express'); // call express +var app = express(); // define our app using express var bodyParser = require('body-parser'); +const tokenToUser = require('./middleware/tokenToUser'); +const mustBeAuthenticated = require('./middleware/mustBeAuthenticated'); // this will let us get the data from a POST // configure app to use bodyParser() @@ -9,6 +11,9 @@ app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); +/* Decode the Authorization header if provided */ +app.use(tokenToUser); + var port = 31459; // set our port var router = express.Router(); var allowedOrigins = ['https://kevinmidboe.com', 'http://localhost:8080'] @@ -28,11 +33,23 @@ router.get('/', function(req, res) { res.json({ message: 'hooray! welcome to this api!' }); }); +/** + * User + */ +app.post('/api/v1/user', require('./controllers/user/register.js')); +app.post('/api/v1/user/login', require('./controllers/user/login.js')); +app.get('/api/v1/user/history', mustBeAuthenticated, require('./controllers/user/history.js')); +/** + * Seasoned + */ router.get('/v1/seasoned/all', require('./controllers/seasoned/readStrays.js')); router.get('/v1/seasoned/:strayId', require('./controllers/seasoned/strayById.js')); router.post('/v1/seasoned/verify/:strayId', require('./controllers/seasoned/verifyStray.js')); +/** + * Plex + */ router.get('/v1/plex/search', require('./controllers/plex/searchMedia.js')); router.get('/v1/plex/playing', require('./controllers/plex/plexPlaying.js')); router.get('/v1/plex/request', require('./controllers/plex/searchRequest.js')); @@ -40,6 +57,9 @@ router.get('/v1/plex/request/:mediaId', require('./controllers/plex/readRequest. router.post('/v1/plex/request/:mediaId', require('./controllers/plex/submitRequest.js')); router.get('/v1/plex/hook', require('./controllers/plex/hookDump.js')); +/** + * TMDB + */ router.get('/v1/tmdb/search', require('./controllers/tmdb/searchMedia.js')); router.get('/v1/tmdb/discover', require('./controllers/tmdb/discoverMedia.js')); router.get('/v1/tmdb/popular', require('./controllers/tmdb/popularMedia.js')); @@ -49,6 +69,9 @@ router.get('/v1/tmdb/upcoming', require('./controllers/tmdb/getUpcoming.js')); router.get('/v1/tmdb/similar/:mediaId', require('./controllers/tmdb/searchSimilar.js')); router.get('/v1/tmdb/:mediaId', require('./controllers/tmdb/readMedia.js')); +/** + * git + */ router.post('/v1/git/dump', require('./controllers/git/dumpHook.js')); diff --git a/seasoned_api/src/webserver/controllers/user/history.js b/seasoned_api/src/webserver/controllers/user/history.js new file mode 100644 index 0000000..e619d19 --- /dev/null +++ b/seasoned_api/src/webserver/controllers/user/history.js @@ -0,0 +1,22 @@ +const SearchHistory = require('src/searchHistory/searchHistory'); +const searchHistory = new SearchHistory(); + +/** + * Controller: Retrieves search history of a logged in user + * @param {Request} req http request variable + * @param {Response} res + * @returns {Callback} + */ +function historyController(req, res) { + const user = req.loggedInUser; + + searchHistory.read(user) + .then((searchQueries) => { + res.send({ success: true, searchQueries }); + }) + .catch((error) => { + res.status(401).send({ success: false, error: error.message }); + }); +} + +module.exports = historyController; diff --git a/seasoned_api/src/webserver/controllers/user/login.js b/seasoned_api/src/webserver/controllers/user/login.js new file mode 100644 index 0000000..4fe7755 --- /dev/null +++ b/seasoned_api/src/webserver/controllers/user/login.js @@ -0,0 +1,28 @@ +const User = require('src/user/user'); +const Token = require('src/user/token'); +const UserSecurity = require('src/user/userSecurity'); +const configuration = require('src/config/configuration').getInstance(); +const secret = configuration.get('authentication', 'secret'); +const userSecurity = new UserSecurity(); + +/** + * Controller: Log in a user provided correct credentials. + * @param {Request} req http request variable + * @param {Response} res + * @returns {Callback} + */ +function loginController(req, res) { + const user = new User(req.body.username); + const password = req.body.password; + + userSecurity.login(user, password) + .then(() => { + const token = new Token(user).toString(secret); + res.send({ success: true, token }); + }) + .catch((error) => { + res.status(401).send({ success: false, error: error.message }); + }); +} + +module.exports = loginController; diff --git a/seasoned_api/src/webserver/controllers/user/register.js b/seasoned_api/src/webserver/controllers/user/register.js new file mode 100644 index 0000000..b7e081a --- /dev/null +++ b/seasoned_api/src/webserver/controllers/user/register.js @@ -0,0 +1,24 @@ +const User = require('src/user/user'); +const UserSecurity = require('src/user/userSecurity'); +const userSecurity = new UserSecurity(); + +/** + * Controller: Register a new user + * @param {Request} req http request variable + * @param {Response} res + * @returns {Callback} + */ +function registerController(req, res) { + const user = new User(req.body.username, req.body.email); + const password = req.body.password; + + userSecurity.createNewUser(user, password) + .then(() => { + res.send({ success: true, message: 'Welcome to Seasoned!' }); + }) + .catch((error) => { + res.status(401).send({ success: false, error: error.message }); + }); +} + +module.exports = registerController; diff --git a/seasoned_api/src/webserver/middleware/mustBeAuthenticated.js b/seasoned_api/src/webserver/middleware/mustBeAuthenticated.js new file mode 100644 index 0000000..7613179 --- /dev/null +++ b/seasoned_api/src/webserver/middleware/mustBeAuthenticated.js @@ -0,0 +1,11 @@ +const mustBeAuthenticated = (req, res, next) => { + + if (req.loggedInUser === undefined) { + return res.status(401).send({ + success: false, + error: 'You must be logged in.', + }); } + return next(); +}; + +module.exports = mustBeAuthenticated; diff --git a/seasoned_api/src/webserver/middleware/tokenToUser.js b/seasoned_api/src/webserver/middleware/tokenToUser.js new file mode 100644 index 0000000..08e8f2a --- /dev/null +++ b/seasoned_api/src/webserver/middleware/tokenToUser.js @@ -0,0 +1,22 @@ +/* eslint-disable no-param-reassign */ +const configuration = require('src/config/configuration').getInstance(); +const secret = configuration.get('authentication', 'secret'); +const Token = require('src/user/token'); + +// Token example: +// curl -i -H "Authorization:[token]" localhost:31459/api/v1/user/history + +const tokenToUser = (req, res, next) => { + const rawToken = req.headers.authorization; + if (rawToken) { + try { + const token = Token.fromString(rawToken, secret); + req.loggedInUser = token.user; + } catch (error) { + req.loggedInUser = undefined; + } + } + next(); +}; + +module.exports = tokenToUser;