2023-11-05 21:11:37 +01:00
|
|
|
const axios = require('axios')
|
2023-03-24 18:21:25 +01:00
|
|
|
const passport = require('passport')
|
2022-07-07 02:01:27 +02:00
|
|
|
const bcrypt = require('./libs/bcryptjs')
|
2022-07-07 01:45:43 +02:00
|
|
|
const jwt = require('./libs/jsonwebtoken')
|
2023-04-16 17:08:13 +02:00
|
|
|
const LocalStrategy = require('./libs/passportLocal')
|
|
|
|
const JwtStrategy = require('passport-jwt').Strategy
|
|
|
|
const ExtractJwt = require('passport-jwt').ExtractJwt
|
2023-11-04 21:36:43 +01:00
|
|
|
const OpenIDClient = require('openid-client')
|
2023-09-13 18:35:39 +02:00
|
|
|
const Database = require('./Database')
|
2023-11-04 21:36:43 +01:00
|
|
|
const Logger = require('./Logger')
|
2023-12-04 22:36:34 +01:00
|
|
|
const e = require('express')
|
2023-03-24 18:21:25 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @class Class for handling all the authentication related functionality.
|
|
|
|
*/
|
2021-08-18 00:01:11 +02:00
|
|
|
class Auth {
|
2023-03-24 18:21:25 +01:00
|
|
|
|
2023-09-13 18:35:39 +02:00
|
|
|
constructor() {
|
2023-12-04 22:36:34 +01:00
|
|
|
// Map of openId sessions indexed by oauth2 state-variable
|
|
|
|
this.openIdAuthSession = new Map()
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
2023-09-20 19:37:55 +02:00
|
|
|
* Inializes all passportjs strategies and other passportjs ralated initialization.
|
2023-03-24 18:21:25 +01:00
|
|
|
*/
|
2023-09-16 20:42:48 +02:00
|
|
|
async initPassportJs() {
|
2023-09-20 19:37:55 +02:00
|
|
|
// Check if we should load the local strategy (username + password login)
|
2023-03-24 18:21:25 +01:00
|
|
|
if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
|
2023-11-10 23:11:51 +01:00
|
|
|
this.initAuthStrategyPassword()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check if we should load the openid strategy
|
|
|
|
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
|
|
|
|
this.initAuthStrategyOpenID()
|
2023-03-24 18:21:25 +01:00
|
|
|
}
|
2023-04-14 20:26:29 +02:00
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
// Load the JwtStrategy (always) -> for bearer token auth
|
|
|
|
passport.use(new JwtStrategy({
|
2023-09-26 00:05:58 +02:00
|
|
|
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
|
2023-09-23 20:30:28 +02:00
|
|
|
secretOrKey: Database.serverSettings.tokenSecret
|
2023-03-24 18:21:25 +01:00
|
|
|
}, this.jwtAuthCheck.bind(this)))
|
|
|
|
|
|
|
|
// define how to seralize a user (to be put into the session)
|
|
|
|
passport.serializeUser(function (user, cb) {
|
|
|
|
process.nextTick(function () {
|
2023-09-20 19:37:55 +02:00
|
|
|
// only store id to session
|
2023-03-24 18:31:58 +01:00
|
|
|
return cb(null, JSON.stringify({
|
2023-11-04 21:36:43 +01:00
|
|
|
id: user.id,
|
2023-04-16 17:08:13 +02:00
|
|
|
}))
|
|
|
|
})
|
|
|
|
})
|
2023-03-24 18:21:25 +01:00
|
|
|
|
2023-09-20 19:37:55 +02:00
|
|
|
// define how to deseralize a user (use the ID to get it from the database)
|
2023-03-24 18:31:58 +01:00
|
|
|
passport.deserializeUser((function (user, cb) {
|
2023-09-13 18:35:39 +02:00
|
|
|
process.nextTick((async function () {
|
2023-03-24 18:31:58 +01:00
|
|
|
const parsedUserInfo = JSON.parse(user)
|
2023-09-20 19:37:55 +02:00
|
|
|
// load the user by ID that is stored in the session
|
|
|
|
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
|
2023-04-16 17:08:13 +02:00
|
|
|
return cb(null, dbUser)
|
|
|
|
}).bind(this))
|
|
|
|
}).bind(this))
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
|
|
|
|
2023-11-10 23:11:51 +01:00
|
|
|
/**
|
|
|
|
* Passport use LocalStrategy
|
|
|
|
*/
|
|
|
|
initAuthStrategyPassword() {
|
|
|
|
passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this)))
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Passport use OpenIDClient.Strategy
|
|
|
|
*/
|
|
|
|
initAuthStrategyOpenID() {
|
2023-11-19 19:57:17 +01:00
|
|
|
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
|
|
|
Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-11-10 23:11:51 +01:00
|
|
|
const openIdIssuerClient = new OpenIDClient.Issuer({
|
|
|
|
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
|
|
|
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
|
|
|
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
|
|
|
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
|
|
|
jwks_uri: global.ServerSettings.authOpenIDJwksURL
|
|
|
|
}).Client
|
|
|
|
const openIdClient = new openIdIssuerClient({
|
|
|
|
client_id: global.ServerSettings.authOpenIDClientID,
|
|
|
|
client_secret: global.ServerSettings.authOpenIDClientSecret
|
|
|
|
})
|
|
|
|
passport.use('openid-client', new OpenIDClient.Strategy({
|
|
|
|
client: openIdClient,
|
|
|
|
params: {
|
|
|
|
redirect_uri: '/auth/openid/callback',
|
|
|
|
scope: 'openid profile email'
|
|
|
|
}
|
|
|
|
}, async (tokenset, userinfo, done) => {
|
|
|
|
Logger.debug(`[Auth] openid callback userinfo=`, userinfo)
|
|
|
|
|
2023-11-11 20:10:24 +01:00
|
|
|
let failureMessage = 'Unauthorized'
|
2023-11-10 23:11:51 +01:00
|
|
|
if (!userinfo.sub) {
|
|
|
|
Logger.error(`[Auth] openid callback invalid userinfo, no sub`)
|
2023-11-11 20:10:24 +01:00
|
|
|
return done(null, null, failureMessage)
|
2023-11-10 23:11:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// First check for matching user by sub
|
|
|
|
let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)
|
|
|
|
if (!user) {
|
|
|
|
// Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy"
|
|
|
|
if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) {
|
|
|
|
Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`)
|
|
|
|
user = await Database.userModel.getUserByEmail(userinfo.email)
|
|
|
|
// Check that user is not already matched
|
|
|
|
if (user?.authOpenIDSub) {
|
|
|
|
Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
|
2023-11-11 20:10:24 +01:00
|
|
|
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
|
|
|
|
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
|
2023-11-10 23:11:51 +01:00
|
|
|
user = null
|
|
|
|
}
|
|
|
|
} else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) {
|
|
|
|
Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`)
|
|
|
|
user = await Database.userModel.getUserByUsername(userinfo.preferred_username)
|
|
|
|
// Check that user is not already matched
|
|
|
|
if (user?.authOpenIDSub) {
|
|
|
|
Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`)
|
2023-11-11 20:10:24 +01:00
|
|
|
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
|
|
|
|
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
|
2023-11-10 23:11:51 +01:00
|
|
|
user = null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If existing user was matched and isActive then save sub to user
|
|
|
|
if (user?.isActive) {
|
|
|
|
Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`)
|
|
|
|
user.authOpenIDSub = userinfo.sub
|
|
|
|
await Database.userModel.updateFromOld(user)
|
|
|
|
} else if (user && !user.isActive) {
|
|
|
|
Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Optionally auto register the user
|
|
|
|
if (!user && Database.serverSettings.authOpenIDAutoRegister) {
|
|
|
|
Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
|
|
|
|
user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!user?.isActive) {
|
2023-11-11 20:10:24 +01:00
|
|
|
if (user && !user.isActive) {
|
|
|
|
failureMessage = 'Unauthorized'
|
|
|
|
}
|
2023-11-10 23:11:51 +01:00
|
|
|
// deny login
|
2023-11-11 20:10:24 +01:00
|
|
|
done(null, null, failureMessage)
|
2023-11-10 23:11:51 +01:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// permit login
|
|
|
|
return done(null, user)
|
|
|
|
}))
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Unuse strategy
|
|
|
|
*
|
|
|
|
* @param {string} name
|
|
|
|
*/
|
|
|
|
unuseAuthStrategy(name) {
|
|
|
|
passport.unuse(name)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Use strategy
|
|
|
|
*
|
|
|
|
* @param {string} name
|
|
|
|
*/
|
|
|
|
useAuthStrategy(name) {
|
|
|
|
if (name === 'openid') {
|
|
|
|
this.initAuthStrategyOpenID()
|
|
|
|
} else if (name === 'local') {
|
|
|
|
this.initAuthStrategyPassword()
|
|
|
|
} else {
|
|
|
|
Logger.error('[Auth] Invalid auth strategy ' + name)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-17 19:42:42 +02:00
|
|
|
/**
|
2023-09-24 19:36:36 +02:00
|
|
|
* Stores the client's choice how the login callback should happen in temp cookies
|
|
|
|
*
|
|
|
|
* @param {import('express').Request} req
|
|
|
|
* @param {import('express').Response} res
|
2023-09-17 19:42:42 +02:00
|
|
|
*/
|
|
|
|
paramsToCookies(req, res) {
|
2023-12-05 09:43:06 +01:00
|
|
|
// Set if isRest flag is set or if mobile oauth flow is used
|
|
|
|
if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// store the isRest flag to the is_rest cookie
|
2023-12-05 09:43:06 +01:00
|
|
|
res.cookie('is_rest', 'true', {
|
2023-09-20 19:48:57 +02:00
|
|
|
maxAge: 120000, // 2 min
|
2023-09-17 19:42:42 +02:00
|
|
|
httpOnly: true
|
|
|
|
})
|
2023-09-24 22:36:35 +02:00
|
|
|
} else {
|
2023-09-20 19:37:55 +02:00
|
|
|
// no isRest-flag set -> set is_rest cookie to false
|
2023-11-20 22:41:38 +01:00
|
|
|
res.cookie('is_rest', 'false', {
|
2023-09-20 19:48:57 +02:00
|
|
|
maxAge: 120000, // 2 min
|
2023-09-17 19:42:42 +02:00
|
|
|
httpOnly: true
|
|
|
|
})
|
2023-09-20 19:37:55 +02:00
|
|
|
|
2023-09-26 00:05:58 +02:00
|
|
|
// persist state if passed in
|
|
|
|
if (req.query.state) {
|
|
|
|
res.cookie('auth_state', req.query.state, {
|
|
|
|
maxAge: 120000, // 2 min
|
|
|
|
httpOnly: true
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
const callback = req.query.redirect_uri || req.query.callback
|
|
|
|
|
2023-09-20 19:37:55 +02:00
|
|
|
// check if we are missing a callback parameter - we need one if isRest=false
|
2023-09-26 00:05:58 +02:00
|
|
|
if (!callback) {
|
2023-09-17 19:42:42 +02:00
|
|
|
res.status(400).send({
|
|
|
|
message: 'No callback parameter'
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-09-20 19:37:55 +02:00
|
|
|
// store the callback url to the auth_cb cookie
|
2023-09-26 00:05:58 +02:00
|
|
|
res.cookie('auth_cb', callback, {
|
2023-09-20 19:48:57 +02:00
|
|
|
maxAge: 120000, // 2 min
|
2023-09-17 19:42:42 +02:00
|
|
|
httpOnly: true
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Informs the client in the right mode about a successfull login and the token
|
|
|
|
* (clients choise is restored from cookies).
|
2023-09-24 19:36:36 +02:00
|
|
|
*
|
|
|
|
* @param {import('express').Request} req
|
|
|
|
* @param {import('express').Response} res
|
2023-09-17 19:42:42 +02:00
|
|
|
*/
|
|
|
|
async handleLoginSuccessBasedOnCookie(req, res) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// get userLogin json (information about the user, server and the session)
|
2023-09-17 19:42:42 +02:00
|
|
|
const data_json = await this.getUserLoginResponsePayload(req.user)
|
|
|
|
|
2023-09-24 22:36:35 +02:00
|
|
|
if (req.cookies.is_rest === 'true') {
|
2023-09-17 19:42:42 +02:00
|
|
|
// REST request - send data
|
|
|
|
res.json(data_json)
|
2023-09-24 22:36:35 +02:00
|
|
|
} else {
|
2023-09-17 19:42:42 +02:00
|
|
|
// UI request -> check if we have a callback url
|
|
|
|
// TODO: do we want to somehow limit the values for auth_cb?
|
2023-09-26 00:05:58 +02:00
|
|
|
if (req.cookies.auth_cb) {
|
|
|
|
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
|
2023-09-20 19:37:55 +02:00
|
|
|
// UI request -> redirect to auth_cb url and send the jwt token as parameter
|
2023-09-26 00:05:58 +02:00
|
|
|
res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`)
|
2023-09-24 22:36:35 +02:00
|
|
|
} else {
|
|
|
|
res.status(400).send('No callback or already expired')
|
2023-09-17 19:42:42 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
|
|
|
* Creates all (express) routes required for authentication.
|
2023-09-24 19:36:36 +02:00
|
|
|
*
|
|
|
|
* @param {import('express').Router} router
|
2023-03-24 18:21:25 +01:00
|
|
|
*/
|
2023-09-20 19:37:55 +02:00
|
|
|
async initAuthRoutes(router) {
|
2023-03-24 18:21:25 +01:00
|
|
|
// Local strategy login route (takes username and password)
|
2023-09-24 19:36:36 +02:00
|
|
|
router.post('/login', passport.authenticate('local'), async (req, res) => {
|
|
|
|
// return the user login response json if the login was successfull
|
|
|
|
res.json(await this.getUserLoginResponsePayload(req.user))
|
|
|
|
})
|
2023-03-24 18:21:25 +01:00
|
|
|
|
2023-04-14 20:26:29 +02:00
|
|
|
// openid strategy login route (this redirects to the configured openid login provider)
|
2023-09-14 19:49:19 +02:00
|
|
|
router.get('/auth/openid', (req, res, next) => {
|
2023-11-11 17:52:05 +01:00
|
|
|
try {
|
|
|
|
// helper function from openid-client
|
|
|
|
function pick(object, ...paths) {
|
|
|
|
const obj = {}
|
|
|
|
for (const path of paths) {
|
|
|
|
if (object[path] !== undefined) {
|
|
|
|
obj[path] = object[path]
|
|
|
|
}
|
2023-11-05 19:37:05 +01:00
|
|
|
}
|
2023-11-11 17:52:05 +01:00
|
|
|
return obj
|
2023-11-05 19:37:05 +01:00
|
|
|
}
|
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// Get the OIDC client from the strategy
|
|
|
|
// We need to call the client manually, because the strategy does not support forwarding the code challenge
|
|
|
|
// for API or mobile clients
|
|
|
|
const oidcStrategy = passport._strategy('openid-client')
|
2023-11-19 21:00:39 +01:00
|
|
|
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
2023-12-04 22:36:34 +01:00
|
|
|
|
2023-12-05 09:43:06 +01:00
|
|
|
let mobile_redirect_uri = null
|
2023-12-04 22:36:34 +01:00
|
|
|
|
|
|
|
// The client wishes a different redirect_uri
|
|
|
|
// We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect
|
|
|
|
// where we will handle the redirect to it
|
|
|
|
if (req.query.redirect_uri) {
|
|
|
|
// Check if the redirect_uri is in the whitelist
|
|
|
|
if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) ||
|
2023-12-17 17:41:39 +01:00
|
|
|
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) {
|
2023-12-04 22:36:34 +01:00
|
|
|
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString()
|
2023-12-05 09:43:06 +01:00
|
|
|
mobile_redirect_uri = req.query.redirect_uri
|
2023-12-04 22:36:34 +01:00
|
|
|
} else {
|
|
|
|
Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`)
|
|
|
|
return res.status(400).send('Invalid redirect_uri')
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
|
|
|
}
|
|
|
|
|
2023-12-05 09:43:06 +01:00
|
|
|
Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
2023-11-11 17:52:05 +01:00
|
|
|
const client = oidcStrategy._client
|
|
|
|
const sessionKey = oidcStrategy._key
|
|
|
|
|
|
|
|
let code_challenge
|
|
|
|
let code_challenge_method
|
|
|
|
|
|
|
|
// If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app)
|
|
|
|
// The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow
|
|
|
|
// and as such will not send a code challenge, we will generate then one
|
|
|
|
if (req.query.code_challenge) {
|
|
|
|
code_challenge = req.query.code_challenge
|
|
|
|
code_challenge_method = req.query.code_challenge_method || 'S256'
|
|
|
|
|
|
|
|
if (!['S256', 'plain'].includes(code_challenge_method)) {
|
|
|
|
return res.status(400).send('Invalid code_challenge_method')
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// If no code_challenge is provided, assume a web application flow and generate one
|
|
|
|
const code_verifier = OpenIDClient.generators.codeVerifier()
|
|
|
|
code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
|
|
|
code_challenge_method = 'S256'
|
|
|
|
|
|
|
|
// Store the code_verifier in the session for later use in the token exchange
|
|
|
|
req.session[sessionKey] = { ...req.session[sessionKey], code_verifier }
|
|
|
|
}
|
2023-11-04 21:36:43 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
const params = {
|
|
|
|
state: OpenIDClient.generators.random(),
|
|
|
|
// Other params by the passport strategy
|
|
|
|
...oidcStrategy._params
|
|
|
|
}
|
2023-11-05 19:37:05 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
if (!params.nonce && params.response_type.includes('id_token')) {
|
|
|
|
params.nonce = OpenIDClient.generators.random()
|
2023-11-05 19:37:05 +01:00
|
|
|
}
|
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
req.session[sessionKey] = {
|
|
|
|
...req.session[sessionKey],
|
2023-11-20 22:41:38 +01:00
|
|
|
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
|
2023-12-05 09:43:06 +01:00
|
|
|
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
|
|
|
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
2023-11-11 17:52:05 +01:00
|
|
|
}
|
2023-11-05 19:37:05 +01:00
|
|
|
|
2023-12-04 22:36:34 +01:00
|
|
|
// We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API
|
|
|
|
// for the request to mobile-redirect and as such the session is not shared
|
2023-12-05 09:43:06 +01:00
|
|
|
this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri })
|
2023-12-04 22:36:34 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// Now get the URL to direct to
|
|
|
|
const authorizationUrl = client.authorizationUrl({
|
|
|
|
...params,
|
|
|
|
scope: 'openid profile email',
|
|
|
|
response_type: 'code',
|
|
|
|
code_challenge,
|
2023-12-05 00:18:58 +01:00
|
|
|
code_challenge_method
|
2023-11-11 17:52:05 +01:00
|
|
|
})
|
2023-11-05 19:37:05 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// params (isRest, callback) to a cookie that will be send to the client
|
|
|
|
this.paramsToCookies(req, res)
|
2023-11-05 19:37:05 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// Redirect the user agent (browser) to the authorization URL
|
|
|
|
res.redirect(authorizationUrl)
|
|
|
|
} catch (error) {
|
|
|
|
Logger.error(`[Auth] Error in /auth/openid route: ${error}`)
|
|
|
|
res.status(500).send('Internal Server Error')
|
2023-11-05 19:37:05 +01:00
|
|
|
}
|
2023-11-11 17:52:05 +01:00
|
|
|
})
|
2023-11-05 19:37:05 +01:00
|
|
|
|
2023-12-04 22:36:34 +01:00
|
|
|
// This will be the oauth2 callback route for mobile clients
|
|
|
|
// It will redirect to an app-link like audiobookshelf://oauth
|
|
|
|
router.get('/auth/openid/mobile-redirect', (req, res) => {
|
|
|
|
try {
|
|
|
|
// Extract the state parameter from the request
|
|
|
|
const { state, code } = req.query
|
2023-12-17 17:41:39 +01:00
|
|
|
|
2023-12-04 22:36:34 +01:00
|
|
|
// Check if the state provided is in our list
|
|
|
|
if (!state || !this.openIdAuthSession.has(state)) {
|
|
|
|
Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
|
|
|
return res.status(400).send('State parameter mismatch')
|
|
|
|
}
|
|
|
|
|
2023-12-05 09:43:06 +01:00
|
|
|
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
2023-12-04 22:36:34 +01:00
|
|
|
|
2023-12-05 09:43:06 +01:00
|
|
|
if (!mobile_redirect_uri) {
|
2023-12-04 22:36:34 +01:00
|
|
|
Logger.error('[Auth] No redirect URI')
|
|
|
|
return res.status(400).send('No redirect URI')
|
|
|
|
}
|
|
|
|
|
|
|
|
this.openIdAuthSession.delete(state)
|
|
|
|
|
2023-12-05 09:43:06 +01:00
|
|
|
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
2023-12-04 22:36:34 +01:00
|
|
|
// Redirect to the overwrite URI saved in the map
|
|
|
|
res.redirect(redirectUri)
|
|
|
|
} catch (error) {
|
|
|
|
Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`)
|
|
|
|
res.status(500).send('Internal Server Error')
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// openid strategy callback route (this receives the token from the configured openid login provider)
|
|
|
|
router.get('/auth/openid/callback', (req, res, next) => {
|
|
|
|
const oidcStrategy = passport._strategy('openid-client')
|
|
|
|
const sessionKey = oidcStrategy._key
|
2023-11-04 21:36:43 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
if (!req.session[sessionKey]) {
|
|
|
|
return res.status(400).send('No session')
|
|
|
|
}
|
2023-11-05 19:37:05 +01:00
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
|
|
|
|
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
|
|
|
|
// Crucial for API/Mobile clients
|
|
|
|
if (req.query.code_verifier) {
|
|
|
|
req.session[sessionKey].code_verifier = req.query.code_verifier
|
|
|
|
}
|
2023-04-14 20:26:29 +02:00
|
|
|
|
2023-11-28 20:07:49 +01:00
|
|
|
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
|
2023-11-28 17:29:22 +01:00
|
|
|
Logger.error(logMessage)
|
2023-11-28 20:07:49 +01:00
|
|
|
if (response) {
|
|
|
|
// Depending on the error, it can also have a body
|
|
|
|
// We also log the request header the passport plugin sents for the URL
|
|
|
|
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
|
|
|
|
Logger.debug(header + '\n' + response.body?.toString())
|
2023-11-28 17:29:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
return res.status(errorCode).send(errorMessage)
|
|
|
|
} else {
|
|
|
|
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-11-28 23:37:19 +01:00
|
|
|
function passportCallback(req, res, next) {
|
2023-11-28 17:29:22 +01:00
|
|
|
return (err, user, info) => {
|
|
|
|
const isMobile = req.session[sessionKey]?.mobile === true
|
|
|
|
if (err) {
|
2023-11-28 20:07:49 +01:00
|
|
|
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
|
2023-11-28 17:29:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
// Info usually contains the error message from the SSO provider
|
2023-11-28 21:16:39 +01:00
|
|
|
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
|
2023-11-28 17:29:22 +01:00
|
|
|
}
|
2023-11-28 23:37:19 +01:00
|
|
|
|
2023-11-28 17:29:22 +01:00
|
|
|
req.logIn(user, (loginError) => {
|
|
|
|
if (loginError) {
|
|
|
|
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
|
|
|
|
}
|
|
|
|
next()
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-11-11 17:52:05 +01:00
|
|
|
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
|
|
|
|
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
|
2023-12-05 09:43:06 +01:00
|
|
|
// We set it here again because the passport param can change between requests
|
|
|
|
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
|
2023-11-11 17:52:05 +01:00
|
|
|
},
|
2023-09-20 19:37:55 +02:00
|
|
|
// on a successfull login: read the cookies and react like the client requested (callback or json)
|
2023-09-26 00:05:58 +02:00
|
|
|
this.handleLoginSuccessBasedOnCookie.bind(this))
|
2023-04-14 20:26:29 +02:00
|
|
|
|
2023-11-05 21:11:37 +01:00
|
|
|
/**
|
2023-12-17 17:41:39 +01:00
|
|
|
* Helper route used to auto-populate the openid URLs in config/authentication
|
|
|
|
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
|
|
|
|
*
|
|
|
|
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
|
2023-11-05 21:11:37 +01:00
|
|
|
*/
|
2023-12-17 17:41:39 +01:00
|
|
|
router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
|
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
2023-11-05 21:11:37 +01:00
|
|
|
if (!req.query.issuer) {
|
|
|
|
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
|
|
|
|
}
|
2023-12-17 17:41:39 +01:00
|
|
|
|
|
|
|
// Strip trailing slash
|
2023-11-05 21:11:37 +01:00
|
|
|
let issuerUrl = req.query.issuer
|
|
|
|
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
|
|
|
|
2023-12-17 17:41:39 +01:00
|
|
|
// Append config pathname and validate URL
|
|
|
|
let configUrl = null
|
|
|
|
try {
|
|
|
|
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
|
|
|
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
|
|
|
throw new Error('Invalid pathname')
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
|
|
|
return res.status(400).send('Invalid request. Query param \'issuer\' is invalid')
|
|
|
|
}
|
|
|
|
|
|
|
|
axios.get(configUrl.toString()).then(({ data }) => {
|
2023-11-05 21:11:37 +01:00
|
|
|
res.json({
|
|
|
|
issuer: data.issuer,
|
|
|
|
authorization_endpoint: data.authorization_endpoint,
|
|
|
|
token_endpoint: data.token_endpoint,
|
|
|
|
userinfo_endpoint: data.userinfo_endpoint,
|
|
|
|
end_session_endpoint: data.end_session_endpoint,
|
|
|
|
jwks_uri: data.jwks_uri
|
|
|
|
})
|
|
|
|
}).catch((error) => {
|
|
|
|
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
|
|
|
|
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
// Logout route
|
2023-04-16 17:08:13 +02:00
|
|
|
router.post('/logout', (req, res) => {
|
2023-03-24 18:21:25 +01:00
|
|
|
// TODO: invalidate possible JWTs
|
2023-04-16 17:08:13 +02:00
|
|
|
req.logout((err) => {
|
|
|
|
if (err) {
|
|
|
|
res.sendStatus(500)
|
|
|
|
} else {
|
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
})
|
2023-03-24 18:21:25 +01:00
|
|
|
})
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
|
|
|
* middleware to use in express to only allow authenticated users.
|
2023-09-24 19:36:36 +02:00
|
|
|
* @param {import('express').Request} req
|
|
|
|
* @param {import('express').Response} res
|
|
|
|
* @param {import('express').NextFunction} next
|
2023-03-24 18:21:25 +01:00
|
|
|
*/
|
|
|
|
isAuthenticated(req, res, next) {
|
|
|
|
// check if session cookie says that we are authenticated
|
|
|
|
if (req.isAuthenticated()) {
|
2021-08-18 00:01:11 +02:00
|
|
|
next()
|
2023-03-24 18:21:25 +01:00
|
|
|
} else {
|
|
|
|
// try JWT to authenticate
|
|
|
|
passport.authenticate("jwt")(req, res, next)
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
2023-09-24 19:36:36 +02:00
|
|
|
* Function to generate a jwt token for a given user
|
|
|
|
*
|
2023-11-08 23:14:57 +01:00
|
|
|
* @param {{ id:string, username:string }} user
|
2023-09-24 19:36:36 +02:00
|
|
|
* @returns {string} token
|
2023-03-24 18:21:25 +01:00
|
|
|
*/
|
|
|
|
generateAccessToken(user) {
|
2023-04-16 17:08:13 +02:00
|
|
|
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
|
2023-03-24 18:21:25 +01:00
|
|
|
}
|
|
|
|
|
2023-09-13 18:35:39 +02:00
|
|
|
/**
|
2023-09-24 19:36:36 +02:00
|
|
|
* Function to validate a jwt token for a given user
|
|
|
|
*
|
2023-09-13 18:35:39 +02:00
|
|
|
* @param {string} token
|
2023-09-24 19:36:36 +02:00
|
|
|
* @returns {Object} tokens data
|
2023-09-13 18:35:39 +02:00
|
|
|
*/
|
|
|
|
static validateAccessToken(token) {
|
|
|
|
try {
|
|
|
|
return jwt.verify(token, global.ServerSettings.tokenSecret)
|
|
|
|
}
|
|
|
|
catch (err) {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
2023-09-20 19:37:55 +02:00
|
|
|
* Generate a token which is used to encrpt/protect the jwts.
|
2023-03-24 18:21:25 +01:00
|
|
|
*/
|
2022-07-19 00:19:16 +02:00
|
|
|
async initTokenSecret() {
|
|
|
|
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
|
2023-09-23 20:42:28 +02:00
|
|
|
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
2022-07-19 00:19:16 +02:00
|
|
|
} else {
|
2023-09-23 20:42:28 +02:00
|
|
|
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
2022-07-19 00:19:16 +02:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
await Database.updateServerSettings()
|
2022-07-19 00:19:16 +02:00
|
|
|
|
|
|
|
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
2023-08-20 20:34:03 +02:00
|
|
|
const users = await Database.userModel.getOldUsers()
|
2023-07-22 22:32:20 +02:00
|
|
|
if (users.length) {
|
|
|
|
for (const user of users) {
|
2023-11-08 23:14:57 +01:00
|
|
|
user.token = await this.generateAccessToken(user)
|
2022-07-19 00:19:16 +02:00
|
|
|
}
|
2023-07-22 22:32:20 +02:00
|
|
|
await Database.updateBulkUsers(users)
|
2022-07-19 00:19:16 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
|
|
|
* Checks if the user in the validated jwt_payload really exists and is active.
|
|
|
|
* @param {Object} jwt_payload
|
|
|
|
* @param {function} done
|
|
|
|
*/
|
2023-09-20 20:06:16 +02:00
|
|
|
async jwtAuthCheck(jwt_payload, done) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// load user by id from the jwt token
|
2023-09-26 00:05:58 +02:00
|
|
|
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
2021-09-22 03:57:33 +02:00
|
|
|
|
2023-09-26 00:05:58 +02:00
|
|
|
if (!user?.isActive) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// deny login
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, null)
|
|
|
|
return
|
2021-09-22 03:57:33 +02:00
|
|
|
}
|
2023-09-20 19:37:55 +02:00
|
|
|
// approve login
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, user)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-04-16 17:08:13 +02:00
|
|
|
* Checks if a username and password tuple is valid and the user active.
|
2023-03-24 18:21:25 +01:00
|
|
|
* @param {string} username
|
|
|
|
* @param {string} password
|
|
|
|
* @param {function} done
|
|
|
|
*/
|
2023-04-16 17:08:13 +02:00
|
|
|
async localAuthCheckUserPw(username, password, done) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// Load the user given it's username
|
2023-09-16 21:45:04 +02:00
|
|
|
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
2021-09-22 03:57:33 +02:00
|
|
|
|
2023-12-02 23:17:52 +01:00
|
|
|
if (!user?.isActive) {
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, null)
|
|
|
|
return
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
// Check passwordless root user
|
2023-12-02 23:17:52 +01:00
|
|
|
if (user.type === 'root' && !user.pash) {
|
2023-03-24 18:21:25 +01:00
|
|
|
if (password) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// deny login
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, null)
|
|
|
|
return
|
|
|
|
}
|
2023-09-20 19:37:55 +02:00
|
|
|
// approve login
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, user)
|
|
|
|
return
|
2023-12-02 23:17:52 +01:00
|
|
|
} else if (!user.pash) {
|
|
|
|
Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
|
|
|
|
done(null, null)
|
|
|
|
return
|
2021-09-11 02:55:02 +02:00
|
|
|
}
|
2023-03-24 18:21:25 +01:00
|
|
|
|
|
|
|
// Check password match
|
2023-04-16 17:08:13 +02:00
|
|
|
const compare = await bcrypt.compare(password, user.pash)
|
2023-03-24 18:21:25 +01:00
|
|
|
if (compare) {
|
2023-09-20 19:37:55 +02:00
|
|
|
// approve login
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, user)
|
|
|
|
return
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
2023-09-20 19:37:55 +02:00
|
|
|
// deny login
|
2023-03-24 18:21:25 +01:00
|
|
|
done(null, null)
|
|
|
|
return
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
|
|
|
* Hashes a password with bcrypt.
|
|
|
|
* @param {string} password
|
|
|
|
* @returns {string} hash
|
|
|
|
*/
|
2021-08-18 00:01:11 +02:00
|
|
|
hashPass(password) {
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
bcrypt.hash(password, 8, (err, hash) => {
|
|
|
|
if (err) {
|
|
|
|
resolve(null)
|
|
|
|
} else {
|
|
|
|
resolve(hash)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-03-24 18:21:25 +01:00
|
|
|
/**
|
2023-09-24 19:36:36 +02:00
|
|
|
* Return the login info payload for a user
|
|
|
|
*
|
|
|
|
* @param {Object} user
|
|
|
|
* @returns {Promise<Object>} jsonPayload
|
2023-03-24 18:21:25 +01:00
|
|
|
*/
|
2023-09-13 18:35:39 +02:00
|
|
|
async getUserLoginResponsePayload(user) {
|
|
|
|
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
2022-04-30 00:43:46 +02:00
|
|
|
return {
|
|
|
|
user: user.toJSONForBrowser(),
|
2023-07-22 21:25:20 +02:00
|
|
|
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
2023-07-05 01:14:44 +02:00
|
|
|
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
|
|
|
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
2022-05-21 18:21:03 +02:00
|
|
|
Source: global.Source
|
2022-04-30 00:43:46 +02:00
|
|
|
}
|
|
|
|
}
|
2023-11-23 22:14:49 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {string} password
|
|
|
|
* @param {*} user
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
comparePassword(password, user) {
|
|
|
|
if (user.type === 'root' && !password && !user.pash) return true
|
|
|
|
if (!password || !user.pash) return false
|
|
|
|
return bcrypt.compare(password, user.pash)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* User changes their password from request
|
|
|
|
*
|
|
|
|
* @param {import('express').Request} req
|
|
|
|
* @param {import('express').Response} res
|
|
|
|
*/
|
|
|
|
async userChangePassword(req, res) {
|
|
|
|
let { password, newPassword } = req.body
|
|
|
|
newPassword = newPassword || ''
|
|
|
|
const matchingUser = req.user
|
|
|
|
|
|
|
|
// 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'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check password match
|
|
|
|
const compare = await this.comparePassword(password, matchingUser)
|
|
|
|
if (!compare) {
|
|
|
|
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) {
|
|
|
|
Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
|
|
|
|
res.json({
|
|
|
|
success: true
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
res.json({
|
|
|
|
error: 'Unknown error'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
2021-08-18 00:01:11 +02:00
|
|
|
}
|
2023-03-24 18:21:25 +01:00
|
|
|
|
2021-08-18 00:01:11 +02:00
|
|
|
module.exports = Auth
|