diff --git a/lib/api/users.js b/lib/api/users.js index 79802f4..c246a6d 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -12,7 +12,7 @@ const router = express.Router(); router.post('/login', async (req, res) => { - passport.authenticate('local', (err, user) => { + passport.authenticate('local', (err, {user, token}) => { if (user) { @@ -24,7 +24,7 @@ router.post('/login', async (req, res) => { debug(user.name); res.send({ - token: req.session.id, + token: token, user: safeUserInfo(req.user), }); }); diff --git a/lib/auth.js b/lib/auth.js index 19a6b8c..3c98bae 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -5,6 +5,8 @@ const passport = require('passport'); const LocalStrategy = require('passport-local').Strategy; +const UniqueTokenStrategy = require('passport-unique-token').UniqueTokenStrategy; +const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const logger = require('./logger'); const debug = require('debug')('httpserver:auth'); @@ -49,7 +51,7 @@ passport.deserializeUser(async function(id, done) { done(null, user); }); -// Configures passport's authenticate (log-in) function. +// Log-in function via user and password. Creates a new session token & returns the token. passport.use(new LocalStrategy( { usernameField: 'user', @@ -60,22 +62,65 @@ passport.use(new LocalStrategy( debug(`Login attempt for user ${username}`); try { - var user = config.getUserByName(username); + let user = config.registry.getUserByName(username); if (!user) return done('User could not be found'); //Check password - var comparison = await bcrypt.compare(password, user.password); + let comparison = await bcrypt.compare(password, user.password); //debug(comparison); if (comparison !== true) return done('Incorrect password.'); if (comparison === true) { - //var sessionToken = crypto.randomBytes(12).toString('base64'); + let token = crypto.randomBytes(24).toString('base64'); + let token_expire = Date.now() + config.getBasicConfig().sessionLength; + + // Save token to db + config.registry.insertSession(token, token_expire, user._id); + + logger.info(`User ${username} has logged in with new token: ${token}`); + + // Append user's role to the object for easy authentication + user.role = config.registry.getRole(user.role_key); - logger.info(`User ${username} has logged in`); - //Return user object - done(null, user); + //Return user & auth token + done(null, {user, token}); } } + catch (err) { + debug(err); + return done(err, false); + } + } +)); + +passport.use(new UniqueTokenStrategy( + { + tokenField: 'token', // body request + tokenQuery: 'token', // query string request + tokenHeader: 'Authorization', // header request + failOnMissing: false, // allow fallback to user prompt + }, + (token, done) => { + try { + // for token format Authorization: Bearer IyFDQt9xGxj3zPPE6ZKjz6ntg5WVdto0 + if (token.split(' ')[0] === 'Bearer') { + token = token.split(' ')[1]; + } + + // Find user based on session token + const user = config.registry.getUserFromSessionToken(token); + debug(`user=${user}, type=${typeof user}, token=${token}`); + + if (!user) { + debug('User not found'); + return done(null, false, {message: 'test'}); + } + + // Append user's role to the object for easy authentication + user.role = config.registry.getRole(user.role_key); + + return done(null, user); + } catch (err) { debug(err); return done(err); @@ -135,7 +180,6 @@ class Auth { const cfg = config.getBasicConfig(); if (cfg.extAccess || Auth.isLocalIP(req.ip)) { - debug('isRequestAllowed: true'); return true; } else { diff --git a/lib/configuration.js b/lib/configuration.js index e39c32f..641ec1f 100644 --- a/lib/configuration.js +++ b/lib/configuration.js @@ -18,6 +18,7 @@ var config = { httpPort: 10222, httpsPort: 10223, extHttpsPort: 10223, + sessionLength: 1000 * 3600 * 24 * 365 * 3, // Max session length in ms. Default: very long. extAccess: false, performNAT: false, keyPemFile: '', @@ -114,8 +115,8 @@ class Configuration { /** * @returns {import('./db/sqlRegistry')} registry */ - getRegistry() { - // @ts-ignore + get registry() { + // 2022-01-15 JL: Made a bound getter for registry for easier coding assert(_registryInitialized, 'Registry not initialized! , setRegistry() not called?'); return _registry; } @@ -133,7 +134,7 @@ class Configuration { _saveToRegistry(config, callback) { if (_registryInitialized) - this.getRegistry().putConfig(config, callback); + this.registry.putConfig(config, callback); else callback(); } @@ -232,21 +233,6 @@ class Configuration { return Path.join(os.tmpdir(), 'mms'); } - /** - * Gets user info from given username - * @param {String} username Username - * @returns {import('./DataStructures').User} User object - */ - getUserByName(username){ - - if (typeof username == 'string') { - return this.getRegistry().getUserByName(username); - } - else { - return null; - } - } - /** * Gets user info from given ID * @param {String} userId ID of user @@ -258,7 +244,7 @@ class Configuration { if (userCache[userId]) return userCache[userId]; else { - var user = this.getRegistry().getUserById(userId); + var user = this.registry.getUserById(userId); //Add user's role to user object, for ease in other functions if (user) user.role = this.getRole(user.role_key); //Add user to memory cache @@ -282,7 +268,7 @@ class Configuration { if (typeof userId == 'string' && typeof update == 'object') { - var info = this.getRegistry().updateUser(userId, update); + var info = this.registry.updateUser(userId, update); //Wipe userCache userCache = {}; @@ -300,7 +286,7 @@ class Configuration { getRole(key) { if (typeof key == 'string') { - return this.getRegistry().getRole(key); + return this.registry.getRole(key); } else { return null; @@ -313,7 +299,7 @@ class Configuration { */ getRoles() { - return this.getRegistry().getRoles(); + return this.registry.getRoles(); } } diff --git a/lib/contentDirectoryService.js b/lib/contentDirectoryService.js index fc67af9..8b86d00 100755 --- a/lib/contentDirectoryService.js +++ b/lib/contentDirectoryService.js @@ -476,7 +476,7 @@ class ContentDirectoryService extends Service { * */ initializeRegistry(callback) { - this._nodeRegistry = Configuration.getRegistry(); + this._nodeRegistry = Configuration.registry; this._nodeRegistry._service = this; callback(null); } diff --git a/lib/db/sqlRegistry.js b/lib/db/sqlRegistry.js index 45637b2..c2c6d5d 100644 --- a/lib/db/sqlRegistry.js +++ b/lib/db/sqlRegistry.js @@ -54,6 +54,9 @@ class SQLRegistry extends MemoryRegistry { getUserByName: 'SELECT * FROM users WHERE name = ?', getUserById: 'SELECT * FROM users WHERE _id = ?', updateUser: 'UPDATE users SET name = ?, display_name = ?, role_key = ?, password = ? WHERE _id = ?', + getUserFromSessionToken: 'SELECT * FROM users WHERE _id IN ( SELECT user_id FROM user_sessions WHERE token = ? AND token_expire > ? )', + insertSession: 'INSERT INTO user_sessions (token, token_expire, token_created, user_id, useragent, ip) VALUES (?,?,?,?,?,?)', + pruneOldSessions: 'DELETE FROM user_sessions WHERE token_expire < ?', getRole: 'SELECT * FROM user_roles WHERE key=?', getRoles: 'SELECT * FROM user_roles ORDER BY access_level', @@ -371,6 +374,21 @@ class SQLRegistry extends MemoryRegistry { }); queries.push('UPDATE db_info SET version = 7'); } + if (version < 8) { + queries.push( + 'CREATE TABLE IF NOT EXISTS user_sessions (' + + 'token TEXT NOT NULL UNIQUE,' + + 'token_expire INTEGER NOT NULL,' + // Note: JS Date() format + 'token_created INTEGER NOT NULL,' + + 'user_id TEXT NOT NULL,' + + 'useragent TEXT,' + // TODO: Configurable option to enable/disable useragent+IP session storage + 'ip TEXT,' + // Purpose: Someone may wish to share their media server to the public & may wish to audit usage + 'FOREIGN KEY(user_id) REFERENCES users(_id),' + + 'PRIMARY KEY(token)' + + ')' + ); + queries.push('UPDATE db_info SET version = 8'); + } //execute queries in order for (var query of queries) { var stmt; @@ -801,18 +819,24 @@ class SQLRegistry extends MemoryRegistry { /** * Get user by name - * @param {String} username Username + * @param {string} username Username * @returns {import('../DataStructures').User} user data */ getUserByName(username) { debug('getUserByName: ENTER'); - return this.sql.getStatement('getUserByName').get(username); + if (typeof username == 'string') { + return this.sql.getStatement('getUserByName').get(username); + } + else { + debug('getUserByName: Username not provided'); + return null; + } } /** * Get user by ID - * @param {String} _id User ID + * @param {string} _id User ID * @returns {import('../DataStructures').User} user data */ getUserById(_id) { @@ -853,7 +877,7 @@ class SQLRegistry extends MemoryRegistry { /** * Update user with given data - * @param {String} userId userID + * @param {string} userId userID * @param {Object} userData User data to update * @returns {import('better-sqlite3').RunResult} Run Result */ @@ -872,9 +896,41 @@ class SQLRegistry extends MemoryRegistry { return this.sql.getStatement('updateUser').run(name, display_name, role_key, password, userId); } + /** + * Get user from session token + * @param {string} token Auth token + * @returns {import('../DataStructures').User} user data + */ + getUserFromSessionToken(token) { + debug('getUserFromSessionToken: ENTER'); + + return this.sql.getStatement('getUserFromSessionToken').get(token, Date.now()); + } + + /** + * + * @param {string} token New token + * @param {Date|Number} token_expire Expiration time + * @param {string} userId User ID + * @param {string} [useragent] Useragent, for logging + * @param {string} [ip] IP address of user, for logging + */ + insertSession(token, token_expire, userId, useragent, ip) { + debug('insertSession: ENTER'); + + token_expire = new Date(token_expire).valueOf(); + assert(!isNaN(token_expire.valueOf()), 'token_expire is an invalid date.'); + assert(typeof userId === 'string', 'userId must be string.'); + assert(typeof token === 'string', 'token must be a string,'); + if (!useragent) useragent = null; + if (!ip) ip = null; + + return this.sql.getStatement('insertSession').run(token, token_expire, Date.now(), userId, useragent, ip); + } + /** * Get role by key - * @param {String} key Role key + * @param {string} key Role key * @returns {import('../DataStructures').Role} Role info */ getRole(key) { @@ -956,8 +1012,8 @@ class SQLRegistry extends MemoryRegistry { /** * Validate FTS (search term?) - * @param {String} value Value to validate - * @returns {String} validated/filtered search query thing + * @param {string} value Value to validate + * @returns {string} validated/filtered search query thing */ validateFTS(value) { debug('validateFTS: ENTER'); diff --git a/lib/httpServer.js b/lib/httpServer.js index 613db1a..f5401db 100644 --- a/lib/httpServer.js +++ b/lib/httpServer.js @@ -57,7 +57,7 @@ class HTTPServer { this.httpServer = httpServer; this.httpsServer = httpsServer; - var sqlDbDir = path.dirname(config.getRegistry().sql.path) + '\\'; + var sqlDbDir = path.dirname(config.registry.sql.path) + '\\'; debug(`sqlDbDir=${sqlDbDir}`); app.use(session({ @@ -74,7 +74,7 @@ class HTTPServer { next(); }); app.use(passport.initialize()); - app.use(passport.session()); + app.use(passport.authenticate('token')); app.use('/api', restRouter); app.use((req, res) => { diff --git a/package-lock.json b/package-lock.json index 3c9c160..27ca4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "mediamonkeyserver", - "version": "0.3.2", + "version": "0.4.1", "lockfileVersion": 2, "requires": true, "packages": { @@ -35,6 +35,7 @@ "opn": "^6.0.0", "passport": "^0.4.1", "passport-local": "^1.0.0", + "passport-unique-token": "^3.0.0", "pubsub-js": "^1.9.0", "pwd": "^1.1.0", "range-parser": "^1.2.1", @@ -6635,6 +6636,17 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-unique-token": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-unique-token/-/passport-unique-token-3.0.0.tgz", + "integrity": "sha512-BkSWODzwS1i8Z5ImmPQOWZ05dw9oS09VMBIZOogubKACrm3UO3wlJnwT/fCMQh5iTtFLYA+X4yWmtsqufftgXw==", + "dependencies": { + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/path-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", @@ -15170,6 +15182,14 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, + "passport-unique-token": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-unique-token/-/passport-unique-token-3.0.0.tgz", + "integrity": "sha512-BkSWODzwS1i8Z5ImmPQOWZ05dw9oS09VMBIZOogubKACrm3UO3wlJnwT/fCMQh5iTtFLYA+X4yWmtsqufftgXw==", + "requires": { + "passport-strategy": "^1.0.0" + } + }, "path-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", diff --git a/package.json b/package.json index f37fc24..84fe50b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "opn": "^6.0.0", "passport": "^0.4.1", "passport-local": "^1.0.0", + "passport-unique-token": "^3.0.0", "pubsub-js": "^1.9.0", "pwd": "^1.1.0", "range-parser": "^1.2.1", diff --git a/webui/src/server.js b/webui/src/server.js index e0f4571..78d066c 100644 --- a/webui/src/server.js +++ b/webui/src/server.js @@ -36,7 +36,16 @@ socket.on('reconnect', attemptNum => { //socket.on('disconnect', () => console.log('Disconnected')); //socket.on('reconnect_attempt', () => console.log('Attempting to reconnect')); -function handleConnectError(err) { +function handleConnectError(err) { + // When we're not logged in, the error will say: Error: 401: Unauthorized + if (typeof err === 'string' && err.includes('401')) { + + Server.setAuth(null); + cookie.remove('token'); + + notifyLoginStateChange({user: null}); + console.log('Got an unauthorized error; Setting state to logged-out'); + } showSnackbarMessage('Could not connect to server.'); notifyOffline(); } @@ -123,7 +132,6 @@ class Server { if (res.loggedIn === false) { //Only show login prompt if we aren't already aware that user is not logged in if (user) { - console.log('Showing login prompt'); Server.setAuth(null); cookie.remove('token');