const bcrypt = require('bcrypt') const jetpack = require('fs-jetpack') const path = require('path') const randomstring = require('randomstring') const paths = require('./pathsController') const perms = require('./permissionController') const tokens = require('./tokenController') const utils = require('./utilsController') const ClientError = require('./utils/ClientError') const config = require('./utils/ConfigManager') // Don't forget to update min/max length of text inputs in auth.njk // when changing these values. const self = { user: { min: 4, max: 32 }, pass: { min: 6, // Should not be more than 72 characters // https://github.com/kelektiv/node.bcrypt.js/tree/v5.0.1#security-issues-and-concerns max: 64, // Length of randomized password // when resetting password through Dashboard's Manage Users. rand: 16 } } /** Preferences */ // https://github.com/kelektiv/node.bcrypt.js/tree/v5.0.1#a-note-on-rounds const saltRounds = 10 const usersPerPage = config.dashboard ? Math.max(Math.min(config.dashboard.usersPerPage || 0, 100), 1) : 25 // ip is an optional parameter, which if set will be rate limited // using tokens.authFailuresRateLimiter pool self.assertUser = async (token, fields, ip) => { if (ip) { const rateLimiterRes = await tokens.authFailuresRateLimiter.get(ip) if (rateLimiterRes && rateLimiterRes.remainingPoints <= 0) { throw new ClientError('Too many auth failures. Try again in a while.', { statusCode: 429 }) } } // Default fields/columns to fetch from database const _fields = ['id', 'username', 'enabled', 'timestamp', 'permission', 'registration'] // Allow fetching additional fields/columns if (typeof fields === 'string') { fields = [fields] } if (Array.isArray(fields)) { _fields.push(...fields) } const user = await utils.db.table('users') .where('token', token) .select(_fields) .first() if (user) { if (user.enabled === false || user.enabled === 0) { throw new ClientError('This account has been disabled.', { statusCode: 403 }) } return user } else { if (ip) { // Rate limit attempts with invalid token await tokens.authFailuresRateLimiter.consume(ip, 1) } throw new ClientError('Invalid token.', { statusCode: 403, code: 10001 }) } } self.requireUser = (req, res, next, options = {}) => { // Throws when token is missing, thus use only for users-only routes const token = options.token || req.headers.token if (!token) { return next(new ClientError('No token provided.', { statusCode: 403 })) } self.assertUser(token, options.fields, req.ip) .then(user => { // Add user data to Request.locals.user req.locals.user = user return next() }) .catch(next) } self.optionalUser = (req, res, next, options = {}) => { // Throws when token if missing only when private is set to true in config, // thus use for routes that can handle no auth requests const token = options.token || req.headers.token if (!token) { if (config.private === true) { return next(new ClientError('No token provided.', { statusCode: 403 })) } else { // Simply bypass this middleware otherwise return next() } } self.assertUser(token, options.fields, req.ip) .then(user => { // Add user data to Request.locals.user req.locals.user = user return next() }) .catch(next) } self.verify = async (req, res) => { const username = typeof req.body.username === 'string' ? req.body.username.trim() : '' if (!username) { throw new ClientError('No username provided.') } const password = typeof req.body.password === 'string' ? req.body.password.trim() : '' if (!password) { throw new ClientError('No password provided.') } // Use tokens.authFailuresRateLimiter pool for /api/login as well const rateLimiterRes = await tokens.authFailuresRateLimiter.get(req.ip) if (rateLimiterRes && rateLimiterRes.remainingPoints <= 0) { throw new ClientError('Too many auth failures. Try again in a while.', { statusCode: 429 }) } const user = await utils.db.table('users') .where('username', username) .first() if (!user) { await tokens.authFailuresRateLimiter.consume(req.ip, 1) throw new ClientError('Wrong credentials.', { statusCode: 403 }) } if (user.enabled === false || user.enabled === 0) { throw new ClientError('This account has been disabled.', { statusCode: 403 }) } const result = await bcrypt.compare(password, user.password) if (result === false) { await tokens.authFailuresRateLimiter.consume(req.ip, 1) throw new ClientError('Wrong credentials.', { statusCode: 403 }) } else { return res.json({ success: true, token: user.token }) } } self.register = async (req, res) => { if (config.enableUserAccounts === false) { throw new ClientError('Registration is currently disabled.', { statusCode: 403 }) } const username = typeof req.body.username === 'string' ? req.body.username.trim() : '' if (username.length < self.user.min || username.length > self.user.max) { throw new ClientError(`Username must have ${self.user.min}-${self.user.max} characters.`) } // "root" user is not required if you have other users with superadmin permission, // so removing them via direct database query is possible. // However, protect it from being re-created via the API endpoints anyway. if (username === 'root') { throw new ClientError('Username is reserved.') } const password = typeof req.body.password === 'string' ? req.body.password.trim() : '' if (password.length < self.pass.min || password.length > self.pass.max) { throw new ClientError(`Password must have ${self.pass.min}-${self.pass.max} characters.`) } // Use tokens.authFailuresRateLimiter pool for /api/register as well const rateLimiterRes = await tokens.authFailuresRateLimiter.get(req.ip) if (rateLimiterRes && rateLimiterRes.remainingPoints <= 0) { throw new ClientError('Too many auth failures. Try again in a while.', { statusCode: 429 }) } const user = await utils.db.table('users') .where('username', username) .first() if (user) { // Also consume rate limit to protect this route // from being brute-forced to find existing usernames await tokens.authFailuresRateLimiter.consume(req.ip, 1) throw new ClientError('Username already exists.') } const hash = await bcrypt.hash(password, saltRounds) const token = await tokens.getUniqueToken(res) await utils.db.table('users') .insert({ username, password: hash, token, enabled: 1, permission: perms.permissions.user, registration: Math.floor(Date.now() / 1000) }) utils.invalidateStatsCache('users') return res.json({ success: true, token }) } self.changePassword = async (req, res) => { const password = typeof req.body.password === 'string' ? req.body.password.trim() : '' if (password.length < self.pass.min || password.length > self.pass.max) { throw new ClientError(`Password must have ${self.pass.min}-${self.pass.max} characters.`) } const hash = await bcrypt.hash(password, saltRounds) await utils.db.table('users') .where('id', req.locals.user.id) .update('password', hash) return res.json({ success: true }) } self.assertPermission = (user, target) => { if (!perms.higher(user, target)) { throw new ClientError('The user is in the same or higher group as you.', { statusCode: 403 }) } } self.createUser = async (req, res) => { const isadmin = perms.is(req.locals.user, 'admin') if (!isadmin) { return res.status(403).end() } const username = typeof req.body.username === 'string' ? req.body.username.trim() : '' if (username.length < self.user.min || username.length > self.user.max) { throw new ClientError(`Username must have ${self.user.min}-${self.user.max} characters.`) } // Please consult notes in self.register() function. if (username === 'root') { throw new ClientError('Username is reserved.') } let password = typeof req.body.password === 'string' ? req.body.password.trim() : '' if (password.length) { if (password.length < self.pass.min || password.length > self.pass.max) { throw new ClientError(`Password must have ${self.pass.min}-${self.pass.max} characters.`) } } else { password = randomstring.generate(self.pass.rand) } let group = req.body.group let permission if (group !== undefined) { permission = perms.permissions[group] if (typeof permission !== 'number' || permission < 0) { group = 'user' permission = perms.permissions.user } } const exists = await utils.db.table('users') .where('username', username) .first() if (exists) { throw new ClientError('Username already exists.') } const hash = await bcrypt.hash(password, saltRounds) const token = await tokens.getUniqueToken(res) await utils.db.table('users') .insert({ username, password: hash, token, enabled: 1, permission, registration: Math.floor(Date.now() / 1000) }) utils.invalidateStatsCache('users') return res.json({ success: true, username, password, group }) } self.editUser = async (req, res) => { const isadmin = perms.is(req.locals.user, 'admin') if (!isadmin) { return res.status(403).end() } const id = parseInt(req.body.id) if (isNaN(id)) { throw new ClientError('No user specified.') } const target = await utils.db.table('users') .where('id', id) .first() if (!target) { throw new ClientError('Could not get user with the specified ID.') } // Ensure this user has permission to tamper with target user self.assertPermission(req.locals.user, target) const update = {} if (req.body.username !== undefined) { update.username = String(req.body.username).trim() if (update.username.length < self.user.min || update.username.length > self.user.max) { throw new ClientError(`Username must have ${self.user.min}-${self.user.max} characters.`) } } if (req.body.enabled !== undefined) { update.enabled = Boolean(req.body.enabled) } if (req.body.group !== undefined) { update.permission = perms.permissions[req.body.group] if (typeof update.permission !== 'number' || update.permission < 0) { update.permission = target.permission } } let password if (req.body.resetPassword) { password = randomstring.generate(self.pass.rand) update.password = await bcrypt.hash(password, saltRounds) } if (!Object.keys(update).length) { throw new ClientError('You are not editing any properties of this user.') } await utils.db.table('users') .where('id', id) .update(update) utils.invalidateStatsCache('users') const response = { success: true, update } if (password) { response.update.password = password } return res.json(response) } self.disableUser = async (req, res) => { // Re-map Request.body for .editUser() req.body = { id: req.body.id, enabled: false } return self.editUser(req, res) } self.deleteUser = async (req, res) => { const isadmin = perms.is(req.locals.user, 'admin') if (!isadmin) { return res.status(403).end() } const id = parseInt(req.body.id) const purge = req.body.purge if (isNaN(id)) { throw new ClientError('No user specified.') } const target = await utils.db.table('users') .where('id', id) .first() if (!target) { throw new ClientError('Could not get user with the specified ID.') } // Ensure this user has permission to tamper with target user self.assertPermission(req.locals.user, target) const files = await utils.db.table('files') .where('userid', id) .select('id') if (files.length) { const fileids = files.map(file => file.id) if (purge) { const failed = await utils.bulkDeleteFromDb('id', fileids, req.locals.user) utils.invalidateStatsCache('uploads') if (failed.length) { return res.json({ success: false, failed }) } } else { // Clear out userid attribute from the files await utils.db.table('files') .whereIn('id', fileids) .update('userid', null) } } const albums = await utils.db.table('albums') .where('userid', id) .where('enabled', 1) .select('id', 'identifier') if (albums.length) { const albumids = albums.map(album => album.id) await utils.db.table('albums') .whereIn('id', albumids) .del() utils.deleteStoredAlbumRenders(albumids) // Unlink their album ZIP archives await Promise.all(albums.map(async album => jetpack.removeAsync(path.join(paths.zips, `${album.identifier}.zip`)) )) } await utils.db.table('users') .where('id', id) .del() utils.invalidateStatsCache('users') return res.json({ success: true }) } self.bulkDeleteUsers = async (req, res) => { // TODO } self.listUsers = async (req, res) => { const isadmin = perms.is(req.locals.user, 'admin') if (!isadmin) { return res.status(403).end() } // Base result object const result = { success: true, users: [], usersPerPage, count: 0 } result.count = await utils.db.table('users') .count('id as count') .then(rows => rows[0].count) if (!result.count) { return res.json(result) } let offset = req.path_parameters && Number(req.path_parameters.page) if (isNaN(offset)) { offset = 0 } else if (offset < 0) { offset = Math.max(0, Math.ceil(result.count / usersPerPage) + offset) } result.users = await utils.db.table('users') .limit(usersPerPage) .offset(usersPerPage * offset) .select('id', 'username', 'enabled', 'timestamp', 'permission', 'registration') const pointers = {} for (const user of result.users) { user.groups = perms.mapPermissions(user) delete user.permission user.uploads = 0 user.usage = 0 pointers[user.id] = user } const uploads = await utils.db.table('files') .whereIn('userid', Object.keys(pointers)) .select('userid', 'size') for (const upload of uploads) { pointers[upload.userid].uploads++ pointers[upload.userid].usage += parseInt(upload.size) } return res.json(result) } module.exports = self