mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
484 lines
15 KiB
484 lines
15 KiB
import passport from 'passport'
|
|
import passportJWT from 'passport-jwt'
|
|
import _ from 'lodash'
|
|
import jwt from 'jsonwebtoken'
|
|
import ms from 'ms'
|
|
import { DateTime } from 'luxon'
|
|
import util from 'node:util'
|
|
import crypto from 'node:crypto'
|
|
import { pem2jwk } from 'pem-jwk'
|
|
import NodeCache from 'node-cache'
|
|
import { extractJWT } from '../helpers/security.mjs'
|
|
|
|
const randomBytes = util.promisify(crypto.randomBytes)
|
|
|
|
export default {
|
|
strategies: {},
|
|
guest: {
|
|
cacheExpiration: DateTime.utc().minus({ days: 1 })
|
|
},
|
|
groups: {},
|
|
validApiKeys: [],
|
|
revocationList: new NodeCache(),
|
|
|
|
/**
|
|
* Initialize the authentication module
|
|
*/
|
|
init() {
|
|
this.passport = passport
|
|
|
|
passport.serializeUser((user, done) => {
|
|
done(null, user.id)
|
|
})
|
|
|
|
passport.deserializeUser(async (id, done) => {
|
|
try {
|
|
const user = await WIKI.db.users.query().findById(id).withGraphFetched('groups').modifyGraph('groups', builder => {
|
|
builder.select('groups.id', 'permissions')
|
|
})
|
|
if (user) {
|
|
done(null, user)
|
|
} else {
|
|
done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
|
|
}
|
|
} catch (err) {
|
|
done(err, null)
|
|
}
|
|
})
|
|
|
|
this.reloadGroups()
|
|
this.reloadApiKeys()
|
|
|
|
return this
|
|
},
|
|
|
|
/**
|
|
* Load authentication strategies
|
|
*/
|
|
async activateStrategies () {
|
|
try {
|
|
// Unload any active strategies
|
|
WIKI.auth.strategies = {}
|
|
const currentStrategies = _.keys(passport._strategies)
|
|
_.pull(currentStrategies, 'session')
|
|
_.forEach(currentStrategies, stg => { passport.unuse(stg) })
|
|
|
|
// Load JWT
|
|
passport.use('jwt', new passportJWT.Strategy({
|
|
jwtFromRequest: extractJWT,
|
|
secretOrKey: WIKI.config.auth.certs.public,
|
|
audience: WIKI.config.auth.audience,
|
|
issuer: 'urn:wiki.js',
|
|
algorithms: ['RS256']
|
|
}, (jwtPayload, cb) => {
|
|
cb(null, jwtPayload)
|
|
}))
|
|
|
|
// Load enabled strategies
|
|
const enabledStrategies = await WIKI.db.authentication.getStrategies({ enabledOnly: true })
|
|
for (const stg of enabledStrategies) {
|
|
try {
|
|
const strategy = (await import(`../modules/authentication/${stg.module}/authentication.mjs`)).default
|
|
strategy.init(passport, stg.id, stg.config)
|
|
|
|
WIKI.auth.strategies[stg.id] = {
|
|
...strategy,
|
|
...stg
|
|
}
|
|
WIKI.logger.info(`Authentication Strategy ${stg.displayName}: [ OK ]`)
|
|
} catch (err) {
|
|
WIKI.logger.error(`Authentication Strategy ${stg.displayName} (${stg.id}): [ FAILED ]`)
|
|
WIKI.logger.error(err)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
WIKI.logger.error(`Failed to initialize Authentication Strategies: [ ERROR ]`)
|
|
WIKI.logger.error(err)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Authenticate current request
|
|
*
|
|
* @param {Express Request} req
|
|
* @param {Express Response} res
|
|
* @param {Express Next Callback} next
|
|
*/
|
|
authenticate (req, res, next) {
|
|
WIKI.auth.passport.authenticate('jwt', { session: false }, async (err, user, info) => {
|
|
if (err) { return next() }
|
|
let mustRevalidate = false
|
|
const strategyId = user.pvd
|
|
|
|
// Expired but still valid within N days, just renew
|
|
if (info instanceof Error && info.name === 'TokenExpiredError') {
|
|
const expiredDate = (info.expiredAt instanceof Date) ? info.expiredAt.toISOString() : info.expiredAt
|
|
if (DateTime.utc().minus(ms(WIKI.config.auth.tokenRenewal)) < DateTime.fromISO(expiredDate)) {
|
|
mustRevalidate = true
|
|
}
|
|
}
|
|
|
|
// Check if user / group is in revocation list
|
|
if (user && !user.api && !mustRevalidate) {
|
|
const uRevalidate = WIKI.auth.revocationList.get(`u${_.toString(user.id)}`)
|
|
if (uRevalidate && user.iat < uRevalidate) {
|
|
mustRevalidate = true
|
|
} else if (DateTime.fromSeconds(user.iat) <= WIKI.startedAt) { // Prevent new / restarted instance from allowing revoked tokens
|
|
mustRevalidate = true
|
|
} else {
|
|
for (const gid of user.groups) {
|
|
const gRevalidate = WIKI.auth.revocationList.get(`g${_.toString(gid)}`)
|
|
if (gRevalidate && user.iat < gRevalidate) {
|
|
mustRevalidate = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Revalidate and renew token
|
|
if (mustRevalidate && !req.path.startsWith('/_graphql')) {
|
|
const jwtPayload = jwt.decode(extractJWT(req))
|
|
try {
|
|
const newToken = await WIKI.db.users.refreshToken(jwtPayload.id, jwtPayload.pvd)
|
|
user = newToken.user
|
|
user.permissions = user.getPermissions()
|
|
user.groups = user.getGroups()
|
|
user.strategyId = strategyId
|
|
req.user = user
|
|
|
|
// Try headers, otherwise cookies for response
|
|
if (req.get('content-type') === 'application/json') {
|
|
res.set('new-jwt', newToken.token)
|
|
} else {
|
|
res.cookie('jwt', newToken.token, { expires: DateTime.utc().plus({ days: 365 }).toJSDate() })
|
|
}
|
|
} catch (errc) {
|
|
WIKI.logger.warn(errc)
|
|
return next()
|
|
}
|
|
} else if (user) {
|
|
user = await WIKI.db.users.getById(user.id)
|
|
user.permissions = user.getPermissions()
|
|
user.groups = user.getGroups()
|
|
user.strategyId = strategyId
|
|
req.user = user
|
|
} else {
|
|
// JWT is NOT valid, set as guest
|
|
if (WIKI.auth.guest.cacheExpiration <= DateTime.utc()) {
|
|
WIKI.auth.guest = await WIKI.db.users.getGuestUser()
|
|
WIKI.auth.guest.cacheExpiration = DateTime.utc().plus({ minutes: 1 })
|
|
}
|
|
req.user = WIKI.auth.guest
|
|
return next()
|
|
}
|
|
|
|
// Process API tokens
|
|
if (_.has(user, 'api')) {
|
|
if (!WIKI.config.api.isEnabled) {
|
|
return next(new Error('API is disabled. You must enable it from the Administration Area first.'))
|
|
} else if (_.includes(WIKI.auth.validApiKeys, user.api)) {
|
|
req.user = {
|
|
id: 1,
|
|
email: 'api@localhost',
|
|
name: 'API',
|
|
pictureUrl: null,
|
|
timezone: 'America/New_York',
|
|
locale: 'en',
|
|
permissions: _.get(WIKI.auth.groups, `${user.grp}.permissions`, []),
|
|
groups: [user.grp],
|
|
getPermissions () {
|
|
return req.user.permissions
|
|
},
|
|
getGroups () {
|
|
return req.user.groups
|
|
}
|
|
}
|
|
return next()
|
|
} else {
|
|
return next(new Error('API Key is invalid or was revoked.'))
|
|
}
|
|
}
|
|
|
|
// JWT is valid
|
|
req.logIn(user, { session: false }, (errc) => {
|
|
if (errc) { return next(errc) }
|
|
next()
|
|
})
|
|
})(req, res, next)
|
|
},
|
|
|
|
/**
|
|
* Check if user has access to resource
|
|
*
|
|
* @param {User} user
|
|
* @param {Array<String>} permissions
|
|
* @param {String|Boolean} path
|
|
*/
|
|
checkAccess(user, permissions = [], page = false) {
|
|
const userPermissions = user.permissions ? user.permissions : user.getPermissions()
|
|
|
|
// System Admin
|
|
if (_.includes(userPermissions, 'manage:system')) {
|
|
return true
|
|
}
|
|
|
|
// Check Global Permissions
|
|
if (_.intersection(userPermissions, permissions).length < 1) {
|
|
return false
|
|
}
|
|
|
|
// Skip if no page rule to check
|
|
if (!page) {
|
|
return true
|
|
}
|
|
|
|
// Check Page Rules
|
|
if (user.groups) {
|
|
let checkState = {
|
|
deny: false,
|
|
match: false,
|
|
specificity: ''
|
|
}
|
|
user.groups.forEach(grp => {
|
|
const grpId = _.isObject(grp) ? _.get(grp, 'id', 0) : grp
|
|
_.get(WIKI.auth.groups, `${grpId}.pageRules`, []).forEach(rule => {
|
|
if (rule.locales && rule.locales.length > 0) {
|
|
if (!rule.locales.includes(page.locale)) { return }
|
|
}
|
|
if (_.intersection(rule.roles, permissions).length > 0) {
|
|
switch (rule.match) {
|
|
case 'START':
|
|
if (_.startsWith(`/${page.path}`, `/${rule.path}`)) {
|
|
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['END', 'REGEX', 'EXACT', 'TAG'] })
|
|
}
|
|
break
|
|
case 'END':
|
|
if (_.endsWith(page.path, rule.path)) {
|
|
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['REGEX', 'EXACT', 'TAG'] })
|
|
}
|
|
break
|
|
case 'REGEX':
|
|
const reg = new RegExp(rule.path)
|
|
if (reg.test(page.path)) {
|
|
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['EXACT', 'TAG'] })
|
|
}
|
|
break
|
|
case 'TAG':
|
|
_.get(page, 'tags', []).forEach(tag => {
|
|
if (tag.tag === rule.path) {
|
|
checkState = this._applyPageRuleSpecificity({
|
|
rule,
|
|
checkState,
|
|
higherPriority: ['EXACT']
|
|
})
|
|
}
|
|
})
|
|
break
|
|
case 'EXACT':
|
|
if (`/${page.path}` === `/${rule.path}`) {
|
|
checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: [] })
|
|
}
|
|
break
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
return (checkState.match && !checkState.deny)
|
|
}
|
|
|
|
return false
|
|
},
|
|
|
|
/**
|
|
* Check for exclusive permissions (contain any X permission(s) but not any Y permission(s))
|
|
*
|
|
* @param {User} user
|
|
* @param {Array<String>} includePermissions
|
|
* @param {Array<String>} excludePermissions
|
|
*/
|
|
checkExclusiveAccess(user, includePermissions = [], excludePermissions = []) {
|
|
const userPermissions = user.permissions ? user.permissions : user.getPermissions()
|
|
|
|
// Check Inclusion Permissions
|
|
if (_.intersection(userPermissions, includePermissions).length < 1) {
|
|
return false
|
|
}
|
|
|
|
// Check Exclusion Permissions
|
|
if (_.intersection(userPermissions, excludePermissions).length > 0) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
},
|
|
|
|
/**
|
|
* Check and apply Page Rule specificity
|
|
*
|
|
* @access private
|
|
*/
|
|
_applyPageRuleSpecificity ({ rule, checkState, higherPriority = [] }) {
|
|
if (rule.path.length === checkState.specificity.length) {
|
|
// Do not override higher priority rules
|
|
if (_.includes(higherPriority, checkState.match)) {
|
|
return checkState
|
|
}
|
|
// Do not override a previous DENY rule with same match
|
|
if (rule.match === checkState.match && checkState.deny && !rule.deny) {
|
|
return checkState
|
|
}
|
|
} else if (rule.path.length < checkState.specificity.length) {
|
|
// Do not override higher specificity rules
|
|
return checkState
|
|
}
|
|
|
|
return {
|
|
deny: rule.deny,
|
|
match: rule.match,
|
|
specificity: rule.path
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reload Groups from DB
|
|
*/
|
|
async reloadGroups () {
|
|
const groupsArray = await WIKI.db.groups.query()
|
|
this.groups = _.keyBy(groupsArray, 'id')
|
|
WIKI.auth.guest.cacheExpiration = DateTime.utc().minus({ days: 1 })
|
|
},
|
|
|
|
/**
|
|
* Reload valid API Keys from DB
|
|
*/
|
|
async reloadApiKeys () {
|
|
const keys = await WIKI.db.apiKeys.query().select('id').where('isRevoked', false).andWhere('expiration', '>', DateTime.utc().toISO())
|
|
this.validApiKeys = _.map(keys, 'id')
|
|
},
|
|
|
|
/**
|
|
* Generate New Authentication Public / Private Key Certificates
|
|
*/
|
|
async regenerateCertificates () {
|
|
WIKI.logger.info('Regenerating certificates...')
|
|
|
|
_.set(WIKI.config, 'sessionSecret', (await randomBytes(32)).toString('hex'))
|
|
const certs = crypto.generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: {
|
|
type: 'pkcs1',
|
|
format: 'pem'
|
|
},
|
|
privateKeyEncoding: {
|
|
type: 'pkcs1',
|
|
format: 'pem',
|
|
cipher: 'aes-256-cbc',
|
|
passphrase: WIKI.config.sessionSecret
|
|
}
|
|
})
|
|
|
|
_.set(WIKI.config, 'certs', {
|
|
jwk: pem2jwk(certs.publicKey),
|
|
public: certs.publicKey,
|
|
private: certs.privateKey
|
|
})
|
|
|
|
await WIKI.configSvc.saveToDb([
|
|
'certs',
|
|
'sessionSecret'
|
|
])
|
|
|
|
await WIKI.auth.activateStrategies()
|
|
WIKI.events.outbound.emit('reloadAuthStrategies')
|
|
|
|
WIKI.logger.info('Regenerated certificates: [ COMPLETED ]')
|
|
},
|
|
|
|
/**
|
|
* Reset Guest User
|
|
*/
|
|
async resetGuestUser() {
|
|
WIKI.logger.info('Resetting guest account...')
|
|
const guestGroup = await WIKI.db.groups.query().where('id', 2).first()
|
|
|
|
await WIKI.db.users.query().delete().where({
|
|
providerKey: 'local',
|
|
email: 'guest@example.com'
|
|
}).orWhere('id', 2)
|
|
|
|
const guestUser = await WIKI.db.users.query().insert({
|
|
id: 2,
|
|
provider: 'local',
|
|
email: 'guest@example.com',
|
|
name: 'Guest',
|
|
password: '',
|
|
locale: 'en',
|
|
defaultEditor: 'markdown',
|
|
tfaIsActive: false,
|
|
isSystem: true,
|
|
isActive: true,
|
|
isVerified: true
|
|
})
|
|
await guestUser.$relatedQuery('groups').relate(guestGroup.id)
|
|
|
|
WIKI.logger.info('Guest user has been reset: [ COMPLETED ]')
|
|
},
|
|
|
|
/**
|
|
* Subscribe to HA propagation events
|
|
*/
|
|
subscribeToEvents() {
|
|
WIKI.events.inbound.on('reloadGroups', () => {
|
|
WIKI.auth.reloadGroups()
|
|
})
|
|
WIKI.events.inbound.on('reloadApiKeys', () => {
|
|
WIKI.auth.reloadApiKeys()
|
|
})
|
|
WIKI.events.inbound.on('reloadAuthStrategies', () => {
|
|
WIKI.auth.activateStrategies()
|
|
})
|
|
WIKI.events.inbound.on('addAuthRevoke', (args) => {
|
|
WIKI.auth.revokeUserTokens(args)
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Get all user permissions for a specific page
|
|
*/
|
|
getEffectivePermissions (req, page) {
|
|
return {
|
|
comments: {
|
|
read: WIKI.auth.checkAccess(req.user, ['read:comments'], page),
|
|
write: WIKI.auth.checkAccess(req.user, ['write:comments'], page),
|
|
manage: WIKI.auth.checkAccess(req.user, ['manage:comments'], page)
|
|
},
|
|
history: {
|
|
read: WIKI.auth.checkAccess(req.user, ['read:history'], page)
|
|
},
|
|
source: {
|
|
read: WIKI.auth.checkAccess(req.user, ['read:source'], page)
|
|
},
|
|
pages: {
|
|
read: WIKI.auth.checkAccess(req.user, ['read:pages'], page),
|
|
write: WIKI.auth.checkAccess(req.user, ['write:pages'], page),
|
|
manage: WIKI.auth.checkAccess(req.user, ['manage:pages'], page),
|
|
delete: WIKI.auth.checkAccess(req.user, ['delete:pages'], page),
|
|
script: WIKI.auth.checkAccess(req.user, ['write:scripts'], page),
|
|
style: WIKI.auth.checkAccess(req.user, ['write:styles'], page)
|
|
},
|
|
system: {
|
|
manage: WIKI.auth.checkAccess(req.user, ['manage:system'], page)
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Add user / group ID to JWT revocation list, forcing all requests to be validated against the latest permissions
|
|
*/
|
|
revokeUserTokens ({ id, kind = 'u' }) {
|
|
WIKI.auth.revocationList.set(`${kind}${_.toString(id)}`, Math.round(DateTime.utc().minus({ seconds: 5 }).toSeconds()), Math.ceil(ms(WIKI.config.auth.tokenExpiration) / 1000))
|
|
}
|
|
}
|