audiobookshelf/server/Auth.js
Igor Serebryany 63f1df4015
don't save proxy user's tokens in localstorage
we still want those tokens, we will use them in the mobile app. but we
don't want them client side, otherwise proxy users remain logged in. we
centralize the logic around storing the token, and turn `setUser` into
an action vs a mutation
2023-10-06 16:31:01 -07:00

297 lines
9.5 KiB
JavaScript

const uuidv4 = require("uuid").v4
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
const requestIp = require('./libs/requestIp')
const Logger = require('./Logger')
const Database = require('./Database')
const User = require('./objects/user/User')
class Auth {
constructor() { }
cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
res.sendStatus(200)
} else {
next()
}
}
async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else {
Logger.debug(`[Auth] Setting token secret - using random bytes`)
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
}
await Database.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
const users = await Database.userModel.getOldUsers()
if (users.length) {
for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
}
await Database.updateBulkUsers(users)
}
}
async authMiddleware(req, res, next) {
var token = null
// If using a get request, the token can be passed as a query string
if (req.method === 'GET' && req.query && req.query.token) {
token = req.query.token
} else {
const authHeader = req.headers['authorization']
token = authHeader && authHeader.split(' ')[1]
}
let proxyUsername = null
if (Database.serverSettings.proxyAuthEnabled) {
const uHeader = Database.serverSettings.proxyAuthUsernameHeader.toLowerCase()
proxyUsername = uHeader ? req.headers[uHeader] : null
}
if (token == null && proxyUsername == null) {
Logger.error('Api called without a token and no proxy auth provided', req.path)
return res.sendStatus(401)
}
let user
if (token) {
user = await this.verifyToken(token)
if (!user) {
Logger.error('Verify Token User Not Found', token)
return res.sendStatus(401)
}
user.isFromProxy = false
} else if (proxyUsername) {
const eHeader = Database.serverSettings.proxyAuthEmailHeader.toLowerCase()
const proxyEmail = eHeader ? req.headers[eHeader] : null
user = await this.getProxyUser(proxyUsername, proxyEmail)
if (!user) {
Logger.error('Cannot get user from proxy headers', proxyUsername)
return res.sendStatus(401)
}
user.isFromProxy = true
}
// if we got here, we should have a valid user in `user`
if (!user) {
Logger.error('Unknown error getting user')
return res.sendStatus(401)
}
// is the user active
if (!user.isActive) {
Logger.error('Verify Token User is disabled', token, user.username)
return res.sendStatus(403)
}
req.user = user
next()
}
hashPass(password) {
return new Promise((resolve) => {
bcrypt.hash(password, 8, (err, hash) => {
if (err) {
Logger.error('Hash failed', err)
resolve(null)
} else {
resolve(hash)
}
})
})
}
generateAccessToken(payload) {
return jwt.sign(payload, Database.serverSettings.tokenSecret)
}
authenticateUser(token) {
return this.verifyToken(token)
}
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => {
if (!payload || err) {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
if (user && user.username === payload.username) {
resolve(user)
} else {
resolve(null)
}
})
})
}
async getProxyUser(username, email) {
const user = await Database.userModel.getUserByUsername(username)
if (user) {
return user
}
if (!email) {
return null
}
const account = {
username,
email,
type: 'user',
isActive: true,
}
return await this.createUser(account)
}
// create and return a new user from an object with lots of user properties defined
async createUser(account) {
const username = account.username
const usernameExists = await Database.userModel.getUserByUsername(username)
if (usernameExists) {
return null
}
account.id = uuidv4()
const password = account.password || uuidv4()
delete account.password
account.pash = await this.hashPass(password)
account.token = await this.generateAccessToken({ userId: account.id, username })
account.createdAt = Date.now()
const newUser = new User(account)
const success = await Database.createUser(newUser)
return success ? await Database.userModel.getUserById(newUser.id) : null
}
/**
* Payload returned to a user after successful login
* @param {oldUser} user
* @returns {object}
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds()
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
Source: global.Source
}
}
async login(req, res) {
const ipAddress = requestIp.getClientIp(req)
const username = (req.body.username || '').toLowerCase()
const password = req.body.password || ''
const user = await Database.userModel.getUserByUsername(username)
if (!user?.isActive) {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
// Check passwordless root user
if (user.type === 'root' && (!user.pash || user.pash === '')) {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
return res.json(userLoginResponsePayload)
}
}
// Check password match
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
res.json(userLoginResponsePayload)
} else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
}
comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false
return bcrypt.compare(password, user.pash)
}
async userChangePassword(req, res) {
var { password, newPassword } = req.body
newPassword = newPassword || ''
const matchingUser = await Database.userModel.getUserById(req.user.id)
// Only root can have an empty password
if (matchingUser.type !== 'root' && !newPassword) {
return res.json({
error: 'Invalid new password - Only root can have an empty password'
})
}
// proxy users can change their password without knowing their old password
const compare = await this.comparePassword(password, matchingUser)
if (!compare && !req.user.isFromProxy) {
return res.json({
error: 'Invalid password'
})
}
let pw = ''
if (newPassword) {
pw = await this.hashPass(newPassword)
if (!pw) {
return res.json({
error: 'Hash failed'
})
}
}
matchingUser.pash = pw
const success = await Database.updateUser(matchingUser)
if (success) {
res.json({
success: true
})
} else {
res.json({
error: 'Unknown error'
})
}
}
}
module.exports = Auth