mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-03 09:44:41 +02:00
Inital passportjs integration
This commit is contained in:
parent
911c854365
commit
e1ddb95250
6 changed files with 695 additions and 175 deletions
346
server/Auth.js
346
server/Auth.js
|
@ -1,43 +1,144 @@
|
|||
const passport = require('passport')
|
||||
const bcrypt = require('./libs/bcryptjs')
|
||||
const jwt = require('./libs/jsonwebtoken')
|
||||
const requestIp = require('./libs/requestIp')
|
||||
const Logger = require('./Logger')
|
||||
const LocalStrategy = require('passport-local')
|
||||
const JwtStrategy = require('passport-jwt').Strategy;
|
||||
const ExtractJwt = require('passport-jwt').ExtractJwt;
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const User = require('./objects/user/User.js')
|
||||
|
||||
/**
|
||||
* @class Class for handling all the authentication related functionality.
|
||||
*/
|
||||
class Auth {
|
||||
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
|
||||
this.user = null
|
||||
}
|
||||
|
||||
get username() {
|
||||
return this.user ? this.user.username : 'nobody'
|
||||
/**
|
||||
* Inializes all passportjs stragegies and other passportjs ralated initialization.
|
||||
*/
|
||||
initPassportJs() {
|
||||
// Check if we should load the local strategy
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
|
||||
passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this)))
|
||||
}
|
||||
// Check if we should load the google-oauth20 strategy
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes("google-oauth20")) {
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: global.ServerSettings.authGoogleOauth20ClientID,
|
||||
clientSecret: global.ServerSettings.authGoogleOauth20ClientSecret,
|
||||
callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL
|
||||
}, function (accessToken, refreshToken, profile, done) {
|
||||
// TODO: what to use as username
|
||||
// TODO: do we want to create the users which does not exist?
|
||||
return done(null, { username: profile.emails[0].value })
|
||||
}))
|
||||
}
|
||||
|
||||
// Load the JwtStrategy (always) -> for bearer token auth
|
||||
passport.use(new JwtStrategy({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: global.ServerSettings.tokenSecret
|
||||
}, this.jwtAuthCheck.bind(this)))
|
||||
|
||||
// define how to seralize a user (to be put into the session)
|
||||
passport.serializeUser(function (user, cb) {
|
||||
process.nextTick(function () {
|
||||
// only store username and id to session
|
||||
// TODO: do we want to store more info in the session?
|
||||
return cb(null, {
|
||||
"username": user.username,
|
||||
"id": user.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// define how to deseralize a user (use the username to get it from the database)
|
||||
passport.deserializeUser(function (user, cb) {
|
||||
process.nextTick(function () {
|
||||
parsedUserInfo = JSON.parse(user)
|
||||
// TODO: do the matching on username or better on id?
|
||||
var dbUser = this.db.users.find(u => u.username.toLowerCase() === parsedUserInfo.username.toLowerCase())
|
||||
return cb(null, new User(dbUser));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get users() {
|
||||
return this.db.users
|
||||
/**
|
||||
* Creates all (express) routes required for authentication.
|
||||
* @param {express.Router} router
|
||||
*/
|
||||
initAuthRoutes(router) {
|
||||
// just a route saying "you need to login" where we redirect e.g. after logout
|
||||
// TODO: replace with a 401?
|
||||
router.get('/login', function (req, res) {
|
||||
res.send('please login')
|
||||
})
|
||||
|
||||
// Local strategy login route (takes username and password)
|
||||
router.post('/login', passport.authenticate('local', {
|
||||
failureRedirect: '/login'
|
||||
}),
|
||||
(function (req, res) {
|
||||
// return the user login response json if the login was successfull
|
||||
res.json(this.getUserLoginResponsePayload(req.user.username))
|
||||
}).bind(this)
|
||||
)
|
||||
|
||||
// google-oauth20 strategy login route (this redirects to the google login)
|
||||
router.get('/auth/google', passport.authenticate('google', { scope: ['email'] }))
|
||||
|
||||
// google-oauth20 strategy callback route (this receives the token from google)
|
||||
router.get('/auth/google/callback',
|
||||
passport.authenticate('google', { failureRedirect: '/login' }),
|
||||
(function (req, res) {
|
||||
// return the user login response json if the login was successfull
|
||||
res.json(this.getUserLoginResponsePayload(req.user.username))
|
||||
}).bind(this)
|
||||
)
|
||||
|
||||
// Logout route
|
||||
router.get('/logout', function (req, res) {
|
||||
// TODO: invalidate possible JWTs
|
||||
req.logout()
|
||||
res.redirect('/login')
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
/**
|
||||
* middleware to use in express to only allow authenticated users.
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.NextFunction} next
|
||||
*/
|
||||
isAuthenticated(req, res, next) {
|
||||
// check if session cookie says that we are authenticated
|
||||
if (req.isAuthenticated()) {
|
||||
next()
|
||||
} else {
|
||||
// try JWT to authenticate
|
||||
passport.authenticate("jwt")(req, res, next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user.
|
||||
* @param {Object} user
|
||||
* @returns the token.
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token for each user.
|
||||
*/
|
||||
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`)
|
||||
this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||
} else {
|
||||
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
||||
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
}
|
||||
await this.db.updateServerSettings()
|
||||
|
@ -46,46 +147,70 @@ class Auth {
|
|||
if (this.db.users.length) {
|
||||
for (const user of this.db.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 this.db.updateEntities('user', this.db.users)
|
||||
}
|
||||
}
|
||||
|
||||
async authMiddleware(req, res, next) {
|
||||
var token = null
|
||||
/**
|
||||
* Checks if the user in the validated jwt_payload really exists and is active.
|
||||
* @param {Object} jwt_payload
|
||||
* @param {function} done
|
||||
*/
|
||||
jwtAuthCheck(jwt_payload, done) {
|
||||
var user = this.db.users.find(u => u.username.toLowerCase() === jwt_payload.username.toLowerCase())
|
||||
|
||||
// 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]
|
||||
if (!user || !user.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
var user = await this.verifyToken(token)
|
||||
if (!user) {
|
||||
Logger.error('Verify Token User Not Found', token)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Verify Token User is disabled', token, user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
req.user = user
|
||||
next()
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a username and passpword touple is valid and the user active.
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {function} done
|
||||
*/
|
||||
localAuthCheckUserPw(username, password, done) {
|
||||
var user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password match
|
||||
var compare = bcrypt.compareSync(password, user.pash)
|
||||
if (compare) {
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a password with bcrypt.
|
||||
* @param {string} password
|
||||
* @returns {string} hash
|
||||
*/
|
||||
hashPass(password) {
|
||||
return new Promise((resolve) => {
|
||||
bcrypt.hash(password, 8, (err, hash) => {
|
||||
if (err) {
|
||||
Logger.error('Hash failed', err)
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(hash)
|
||||
|
@ -94,28 +219,14 @@ class Auth {
|
|||
})
|
||||
}
|
||||
|
||||
generateAccessToken(payload) {
|
||||
return jwt.sign(payload, global.ServerSettings.tokenSecret);
|
||||
}
|
||||
|
||||
authenticateUser(token) {
|
||||
return this.verifyToken(token)
|
||||
}
|
||||
|
||||
verifyToken(token) {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => {
|
||||
if (!payload || err) {
|
||||
Logger.error('JWT Verify Token Failed', err)
|
||||
return resolve(null)
|
||||
}
|
||||
const user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
|
||||
resolve(user || null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getUserLoginResponsePayload(user) {
|
||||
/**
|
||||
* Return the login info payload for a user.
|
||||
* @param {string} username
|
||||
* @returns {string} jsonPayload
|
||||
*/
|
||||
getUserLoginResponsePayload(username) {
|
||||
var user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
||||
user = new User(user)
|
||||
return {
|
||||
user: user.toJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||
|
@ -123,101 +234,6 @@ class Auth {
|
|||
Source: global.Source
|
||||
}
|
||||
}
|
||||
|
||||
async login(req, res) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
var username = (req.body.username || '').toLowerCase()
|
||||
var password = req.body.password || ''
|
||||
|
||||
var user = this.users.find(u => u.username.toLowerCase() === username)
|
||||
|
||||
if (!user || !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.id === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
return res.json(this.getUserLoginResponsePayload(user))
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
var compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
res.json(this.getUserLoginResponsePayload(user))
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
|
||||
// Not in use now
|
||||
lockUser(user) {
|
||||
user.isLocked = true
|
||||
return this.db.updateEntity('user', user).catch((error) => {
|
||||
Logger.error('[Auth] Failed to lock user', user.username, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
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 || ''
|
||||
var matchingUser = this.users.find(u => u.id === 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'
|
||||
})
|
||||
}
|
||||
|
||||
var compare = await this.comparePassword(password, matchingUser)
|
||||
if (!compare) {
|
||||
return res.json({
|
||||
error: 'Invalid password'
|
||||
})
|
||||
}
|
||||
|
||||
var pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPass(newPassword)
|
||||
if (!pw) {
|
||||
return res.json({
|
||||
error: 'Hash failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
matchingUser.pash = pw
|
||||
var success = await this.db.updateEntity('user', matchingUser)
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Auth
|
Loading…
Add table
Add a link
Reference in a new issue