mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-15 05:41:34 +00:00
otherwise, they will stay logged in even after the proxy auth has disabled them (with the saved token in the local store)
304 lines
9.6 KiB
JavaScript
304 lines
9.6 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,
|
|
token: null,
|
|
isFromProxy: 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)
|
|
|
|
// proxy users don't get a token
|
|
if (!account.isFromProxy) {
|
|
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
|