feat: guest + user permissions

This commit is contained in:
Nicolas Giard
2019-01-06 22:03:34 -05:00
parent aa57ea920e
commit 75eb277401
33 changed files with 463 additions and 191 deletions

View File

@@ -22,14 +22,12 @@ defaults:
db: 0
password: null
# DB defaults
defaultEditor: 'markdown'
graphEndpoint: 'https://graph.requarks.io'
lang:
code: en
autoUpdate: true
namespaces: []
namespacing: false
public: false
telemetry:
clientId: ''
isEnabled: false
@@ -47,13 +45,6 @@ defaults:
maxAge: 600
methods: 'GET,POST'
origin: true
configNamespaces:
- auth
- features
- logging
- site
- theme
- uploads
localeNamespaces:
- admin
- auth

View File

@@ -5,6 +5,18 @@ const _ = require('lodash')
/* global WIKI */
/**
* Robots.txt
*/
router.get('/robots.txt', (req, res, next) => {
res.type('text/plain')
if (_.includes(WIKI.config.seo.robots, 'noindex')) {
res.send("User-agent: *\nDisallow: /")
} else {
res.status(200).end()
}
})
/**
* Create/Edit document
*/
@@ -17,12 +29,20 @@ router.get(['/e', '/e/*'], async (req, res, next) => {
isPrivate: false
})
if (page) {
if (!WIKI.auth.checkAccess(req.user, ['manage:pages'], pageArgs)) {
return res.render('unauthorized', { action: 'edit'})
}
_.set(res.locals, 'pageMeta.title', `Edit ${page.title}`)
_.set(res.locals, 'pageMeta.description', page.description)
page.mode = 'update'
page.isPublished = (page.isPublished === true || page.isPublished === 1) ? 'true' : 'false'
page.content = Buffer.from(page.content).toString('base64')
} else {
if (!WIKI.auth.checkAccess(req.user, ['write:pages'], pageArgs)) {
return res.render('unauthorized', { action: 'create'})
}
_.set(res.locals, 'pageMeta.title', `New Page`)
page = {
path: pageArgs.path,
@@ -56,6 +76,11 @@ router.get(['/p', '/p/*'], (req, res, next) => {
*/
router.get(['/h', '/h/*'], async (req, res, next) => {
const pageArgs = pageHelper.parsePath(req.path)
if (!WIKI.auth.checkAccess(req.user, ['read:pages'], pageArgs)) {
return res.render('unauthorized', { action: 'history'})
}
const page = await WIKI.models.pages.getPageFromDb({
path: pageArgs.path,
locale: pageArgs.locale,
@@ -76,6 +101,11 @@ router.get(['/h', '/h/*'], async (req, res, next) => {
*/
router.get(['/s', '/s/*'], async (req, res, next) => {
const pageArgs = pageHelper.parsePath(req.path)
if (!WIKI.auth.checkAccess(req.user, ['read:pages'], pageArgs)) {
return res.render('unauthorized', { action: 'source'})
}
const page = await WIKI.models.pages.getPageFromDb({
path: pageArgs.path,
locale: pageArgs.locale,
@@ -96,6 +126,15 @@ router.get(['/s', '/s/*'], async (req, res, next) => {
*/
router.get('/*', async (req, res, next) => {
const pageArgs = pageHelper.parsePath(req.path)
if (!WIKI.auth.checkAccess(req.user, ['read:pages'], pageArgs)) {
if (pageArgs.path === 'home') {
return res.redirect('/login')
} else {
return res.render('unauthorized', { action: 'view'})
}
}
const page = await WIKI.models.pages.getPage({
path: pageArgs.path,
locale: pageArgs.locale,
@@ -108,8 +147,10 @@ router.get('/*', async (req, res, next) => {
const sidebar = await WIKI.models.navigation.getTree({ cache: true })
res.render('page', { page, sidebar })
} else if (pageArgs.path === 'home') {
_.set(res.locals, 'pageMeta.title', 'Welcome')
res.render('welcome')
} else {
_.set(res.locals, 'pageMeta.title', 'Page Not Found')
res.status(404).render('new', { pagePath: req.path })
}
})

View File

@@ -3,6 +3,8 @@ const passportJWT = require('passport-jwt')
const fs = require('fs-extra')
const _ = require('lodash')
const path = require('path')
const jwt = require('jsonwebtoken')
const moment = require('moment')
const securityHelper = require('../helpers/security')
@@ -10,11 +12,16 @@ const securityHelper = require('../helpers/security')
module.exports = {
strategies: {},
guest: {
cacheExpiration: moment.utc().subtract(1, 'd')
},
/**
* Initialize the authentication module
*/
init() {
this.passport = passport
// Serialization user methods
passport.serializeUser(function (user, done) {
done(null, user.id)
})
@@ -34,6 +41,10 @@ module.exports = {
return this
},
/**
* Load authentication strategies
*/
async activateStrategies() {
try {
// Unload any active strategies
@@ -46,7 +57,7 @@ module.exports = {
passport.use('jwt', new passportJWT.Strategy({
jwtFromRequest: securityHelper.extractJWT,
secretOrKey: WIKI.config.certs.public,
audience: 'urn:wiki.js', // TODO: use value from admin
audience: WIKI.config.auth.audience,
issuer: 'urn:wiki.js'
}, (jwtPayload, cb) => {
cb(null, jwtPayload)
@@ -60,7 +71,7 @@ module.exports = {
const strategy = require(`../modules/authentication/${stg.key}/authentication.js`)
stg.config.callbackURL = `${WIKI.config.host}/login/${stg.key}/callback` // TODO: config.host
stg.config.callbackURL = `${WIKI.config.host}/login/${stg.key}/callback`
strategy.init(passport, stg.config)
fs.readFile(path.join(WIKI.ROOTPATH, `assets/svg/auth-icon-${strategy.key}.svg`), 'utf8').then(iconData => {
@@ -79,5 +90,74 @@ module.exports = {
WIKI.logger.error(`Authentication Strategy: [ FAILED ]`)
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() }
// Expired but still valid within N days, just renew
if (info instanceof Error && info.name === 'TokenExpiredError' && moment().subtract(14, 'days').isBefore(info.expiredAt)) {
const jwtPayload = jwt.decode(securityHelper.extractJWT(req))
try {
const newToken = await WIKI.models.users.refreshToken(jwtPayload.id)
user = newToken.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: moment().add(365, 'days').toDate() })
}
} catch (err) {
return next()
}
}
// JWT is NOT valid, set as guest
if (!user) {
if (WIKI.auth.guest.cacheExpiration ) {
WIKI.auth.guest = await WIKI.models.users.getGuestUser()
WIKI.auth.guest.cacheExpiration = moment.utc().add(1, 'm')
}
req.user = WIKI.auth.guest
return next()
}
// JWT is valid
req.logIn(user, { session: false }, (err) => {
if (err) { return next(err) }
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 = [], path = false) {
// System Admin
if (_.includes(user.permissions, 'manage:system')) {
return true
}
// Check Global Permissions
if (_.intersection(user.permissions, permissions).length < 1) {
return false
}
// Check Page Rules
return false
}
}

View File

@@ -52,8 +52,6 @@ module.exports = {
appconfig.port = process.env.PORT || 80
}
appconfig.public = (appconfig.public === true || _.toLower(appconfig.public) === 'true')
WIKI.config = appconfig
WIKI.data = appdata
WIKI.version = require(path.join(WIKI.ROOTPATH, 'package.json')).version

View File

@@ -1,11 +1,14 @@
exports.up = knex => {
const dbCompat = {
charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`)
}
return knex.schema
// =====================================
// MODEL TABLES
// =====================================
// ASSETS ------------------------------
.createTable('assets', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('filename').notNullable()
table.string('basename').notNullable()
@@ -19,7 +22,7 @@ exports.up = knex => {
})
// ASSET FOLDERS -----------------------
.createTable('assetFolders', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('name').notNullable()
table.string('slug').notNullable()
@@ -27,7 +30,7 @@ exports.up = knex => {
})
// AUTHENTICATION ----------------------
.createTable('authentication', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.json('config').notNullable()
@@ -37,7 +40,7 @@ exports.up = knex => {
})
// COMMENTS ----------------------------
.createTable('comments', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.text('content').notNullable()
table.string('createdAt').notNullable()
@@ -45,14 +48,14 @@ exports.up = knex => {
})
// EDITORS -----------------------------
.createTable('editors', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.json('config').notNullable()
})
// GROUPS ------------------------------
.createTable('groups', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('name').notNullable()
table.json('permissions').notNullable()
@@ -63,7 +66,7 @@ exports.up = knex => {
})
// LOCALES -----------------------------
.createTable('locales', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('code', 2).notNullable().primary()
table.json('strings')
table.boolean('isRTL').notNullable().defaultTo(false)
@@ -74,7 +77,7 @@ exports.up = knex => {
})
// LOGGING ----------------------------
.createTable('loggers', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.string('level').notNullable().defaultTo('warn')
@@ -82,13 +85,13 @@ exports.up = knex => {
})
// NAVIGATION ----------------------------
.createTable('navigation', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.json('config')
})
// PAGE HISTORY ------------------------
.createTable('pageHistory', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('path').notNullable()
table.string('hash').notNullable()
@@ -104,7 +107,7 @@ exports.up = knex => {
})
// PAGES -------------------------------
.createTable('pages', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('path').notNullable()
table.string('hash').notNullable()
@@ -124,7 +127,7 @@ exports.up = knex => {
})
// PAGE TREE ---------------------------
.createTable('pageTree', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('path').notNullable()
table.integer('depth').unsigned().notNullable()
@@ -135,28 +138,28 @@ exports.up = knex => {
})
// RENDERERS ---------------------------
.createTable('renderers', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.json('config')
})
// SEARCH ------------------------------
.createTable('searchEngines', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.json('config')
})
// SETTINGS ----------------------------
.createTable('settings', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.json('value')
table.string('updatedAt').notNullable()
})
// STORAGE -----------------------------
.createTable('storage', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.string('key').notNullable().primary()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.string('mode', ['sync', 'push', 'pull']).notNullable().defaultTo('push')
@@ -164,7 +167,7 @@ exports.up = knex => {
})
// TAGS --------------------------------
.createTable('tags', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('tag').notNullable().unique()
table.string('title')
@@ -173,7 +176,7 @@ exports.up = knex => {
})
// USER KEYS ---------------------------
.createTable('userKeys', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('kind').notNullable()
table.string('token').notNullable()
@@ -182,7 +185,7 @@ exports.up = knex => {
})
// USERS -------------------------------
.createTable('users', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.string('email').notNullable()
table.string('name').notNullable()
@@ -205,21 +208,21 @@ exports.up = knex => {
// =====================================
// PAGE HISTORY TAGS ---------------------------
.createTable('pageHistoryTags', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.integer('pageId').unsigned().references('id').inTable('pageHistory').onDelete('CASCADE')
table.integer('tagId').unsigned().references('id').inTable('tags').onDelete('CASCADE')
})
// PAGE TAGS ---------------------------
.createTable('pageTags', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.integer('pageId').unsigned().references('id').inTable('pages').onDelete('CASCADE')
table.integer('tagId').unsigned().references('id').inTable('tags').onDelete('CASCADE')
})
// USER GROUPS -------------------------
.createTable('userGroups', table => {
table.charset('utf8mb4')
if (dbCompat.charset) { table.charset('utf8mb4') }
table.increments('id').primary()
table.integer('userId').unsigned().references('id').inTable('users').onDelete('CASCADE')
table.integer('groupId').unsigned().references('id').inTable('groups').onDelete('CASCADE')

View File

@@ -1,11 +0,0 @@
exports.seed = (knex, Promise) => {
return knex('settings')
.insert([
{ key: 'auth', value: {} },
{ key: 'features', value: {} },
{ key: 'logging', value: {} },
{ key: 'site', value: {} },
{ key: 'theme', value: {} },
{ key: 'uploads', value: {} }
])
}

View File

@@ -70,6 +70,13 @@ module.exports = {
},
async updateStrategies(obj, args, context) {
try {
WIKI.config.auth = {
audience: _.get(args, 'config.audience', WIKI.config.auth.audience),
tokenExpiration: _.get(args, 'config.tokenExpiration', WIKI.config.auth.tokenExpiration),
tokenRenewal: _.get(args, 'config.tokenRenewal', WIKI.config.auth.tokenRenewal)
}
await WIKI.configSvc.saveToDb(['auth'])
for (let str of args.strategies) {
await WIKI.models.authentication.query().patch({
isEnabled: str.isEnabled,

View File

@@ -43,7 +43,8 @@ type AuthenticationMutation {
): AuthenticationRegisterResponse
updateStrategies(
strategies: [AuthenticationStrategyInput]
strategies: [AuthenticationStrategyInput]!
config: AuthenticationConfigInput
): DefaultResponse @auth(requires: ["manage:system"])
}
@@ -88,3 +89,9 @@ input AuthenticationStrategyInput {
domainWhitelist: [String]!
autoEnrollGroups: [Int]!
}
input AuthenticationConfigInput {
audience: String!
tokenExpiration: String!
tokenRenewal: String!
}

View File

@@ -67,7 +67,7 @@ module.exports = async () => {
app.use(cookieParser())
app.use(WIKI.auth.passport.initialize())
app.use(mw.auth.jwt)
app.use(WIKI.auth.authenticate)
// ----------------------------------------
// SEO
@@ -138,8 +138,7 @@ module.exports = async () => {
// ----------------------------------------
app.use('/', ctrl.auth)
app.use('/', mw.auth.checkPath, ctrl.common)
app.use('/', ctrl.common)
// ----------------------------------------
// Error handling

View File

@@ -1,72 +0,0 @@
const jwt = require('jsonwebtoken')
const moment = require('moment')
const securityHelper = require('../helpers/security')
/* global WIKI */
/**
* Authentication middleware
*/
module.exports = {
jwt(req, res, next) {
WIKI.auth.passport.authenticate('jwt', {session: false}, async (err, user, info) => {
if (err) { return next() }
// Expired but still valid within 7 days, just renew
if (info instanceof Error && info.name === 'TokenExpiredError' && moment().subtract(14, 'days').isBefore(info.expiredAt)) {
const jwtPayload = jwt.decode(securityHelper.extractJWT(req))
try {
const newToken = await WIKI.models.users.refreshToken(jwtPayload.id)
user = newToken.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: moment().add(365, 'days').toDate() })
}
} catch (err) {
return next()
}
}
// JWT is NOT valid
if (!user) { return next() }
// JWT is valid
req.logIn(user, { session: false }, (err) => {
if (err) { return next(err) }
next()
})
})(req, res, next)
},
checkPath(req, res, next) {
// Is user authenticated ?
if (!req.isAuthenticated()) {
if (WIKI.config.public !== true) {
return res.redirect('/login')
} else {
// req.user = rights.guest
res.locals.isGuest = true
}
} else {
res.locals.isGuest = false
}
// Check permissions
// res.locals.rights = rights.check(req)
// if (!res.locals.rights.read) {
// return res.render('error-forbidden')
// }
// Expose user data
res.locals.user = req.user
return next()
}
}

View File

@@ -138,6 +138,11 @@ module.exports = class User extends Model {
return (result && _.has(result, 'delta') && result.delta === 0)
}
async getPermissions() {
const permissions = await this.$relatedQuery('groups').select('permissions').pluck('permissions')
this.permissions = _.uniq(_.flatten(permissions))
}
static async processProfile(profile) {
let primaryEmail = ''
if (_.isArray(profile.emails)) {
@@ -262,8 +267,8 @@ module.exports = class User extends Model {
passphrase: WIKI.config.sessionSecret
}, {
algorithm: 'RS256',
expiresIn: '30m',
audience: 'urn:wiki.js', // TODO: use value from admin
expiresIn: WIKI.config.auth.tokenExpiration,
audience: WIKI.config.auth.audience,
issuer: 'urn:wiki.js'
}),
user
@@ -391,4 +396,10 @@ module.exports = class User extends Model {
throw new WIKI.Error.AuthRegistrationDisabled()
}
}
static async getGuestUser () {
let user = await WIKI.models.users.query().findById(2)
user.getPermissions()
return user
}
}

View File

@@ -104,8 +104,12 @@ module.exports = () => {
await fs.ensureDir(path.join(dataPath, 'uploads'))
// Set config
_.set(WIKI.config, 'auth', {
audience: 'urn:wiki.js',
tokenExpiration: '30m',
tokenRenewal: '14d'
})
_.set(WIKI.config, 'company', '')
_.set(WIKI.config, 'defaultEditor', 'markdown')
_.set(WIKI.config, 'features', {
featurePageRatings: true,
featurePageComments: true,
@@ -136,7 +140,6 @@ module.exports = () => {
dkimKeySelector: '',
dkimPrivateKey: ''
})
_.set(WIKI.config, 'public', false)
_.set(WIKI.config, 'seo', {
description: '',
robots: ['index', 'follow'],
@@ -145,7 +148,7 @@ module.exports = () => {
})
_.set(WIKI.config, 'sessionSecret', (await crypto.randomBytesAsync(32)).toString('hex'))
_.set(WIKI.config, 'telemetry', {
isEnabled: req.body.telemetry === 'true',
isEnabled: req.body.telemetry === true,
clientId: WIKI.telemetry.cid
})
_.set(WIKI.config, 'theming', {
@@ -179,16 +182,15 @@ module.exports = () => {
// Save config to DB
WIKI.logger.info('Persisting config to DB...')
await WIKI.configSvc.saveToDb([
'auth',
'certs',
'company',
'defaultEditor',
'features',
'graphEndpoint',
'host',
'lang',
'logo',
'mail',
'public',
'seo',
'sessionSecret',
'telemetry',
@@ -389,8 +391,10 @@ module.exports = () => {
WIKI.server.on('listening', () => {
WIKI.logger.info('HTTP Server: [ RUNNING ]')
WIKI.logger.info('========================================')
WIKI.logger.info(`Browse to http://localhost:${WIKI.config.port}/`)
WIKI.logger.info('========================================')
WIKI.logger.info('🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻')
WIKI.logger.info('')
WIKI.logger.info(`Browse to http://localhost:${WIKI.config.port}/ to complete setup!`)
WIKI.logger.info('')
WIKI.logger.info('🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺')
})
}

View File

@@ -0,0 +1,13 @@
extends master.pug
block body
#root.is-fullscreen
v-app
.unauthorized
.unauthorized-content
img.animated.fadeIn(src='/svg/icon-delete-shield.svg', alt='Unauthorized')
.headline= t('unauthorized.title')
.subheading.mt-3= t('unauthorized.action.' + action)
v-btn.mt-5(color='red lighten-4', href='javascript:window.history.go(-1);', large, outline)
v-icon(left) arrow_back
span= t('unauthorized.goback')