mirror of
https://github.com/BobbyWibowo/lolisafe.git
synced 2024-12-12 23:46:22 +00:00
729883a4f5
initial migration to usergroup-system for root user is governed by superadminAccount and superadminForcePromote fields in config file those will have default values expected for non-fork installs, but existing fork installs will not have them and thus will never trigger superadmin force-promotion from migration script
508 lines
14 KiB
JavaScript
508 lines
14 KiB
JavaScript
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
|