diff --git a/client/components/admin/admin-api.vue b/client/components/admin/admin-api.vue index 7202522f..edad343a 100644 --- a/client/components/admin/admin-api.vue +++ b/client/components/admin/admin-api.vue @@ -6,14 +6,14 @@ img(src='/svg/icon-rest-api.svg', alt='API', style='width: 80px;') .admin-header-title .headline.blue--text.text--darken-2 API - .subheading.grey--text Manage keys to access the API + .subheading.grey--text Manage keys to access the API #[v-chip(label, color='primary', small).white--text coming soon] v-spacer - v-btn(outline, color='grey', large, @click='refresh') + v-btn(outline, color='grey', large, @click='refresh', disabled) v-icon refresh - v-btn(color='green', dark, depressed, large, @click='globalSwitch') + v-btn(color='green', disabled, depressed, large, @click='globalSwitch') v-icon(left) power_settings_new | Enable API - v-btn(color='primary', depressed, large, @click='newKey') + v-btn(color='primary', depressed, large, @click='newKey', disabled) v-icon(left) add | New API Key v-card.mt-3 @@ -58,7 +58,7 @@ td {{ props.item.updatedOn }} td: v-btn(icon): v-icon.grey--text.text--darken-1 more_horiz template(slot='no-data') - v-alert.mt-3(icon='warning', :value='true', outline) No API have been generated yet. + v-alert.mt-3(icon='info', :value='true', outline, color='info') No API have been generated yet. .text-xs-center.py-2 v-pagination(v-model='pagination.page', :length='pages') diff --git a/client/components/admin/admin-auth.vue b/client/components/admin/admin-auth.vue index 4250fb4f..23b8982f 100644 --- a/client/components/admin/admin-auth.vue +++ b/client/components/admin/admin-auth.vue @@ -207,6 +207,11 @@ export default { await this.$apollo.mutate({ mutation: strategiesSaveMutation, variables: { + config: { + audience: this.jwtAudience, + tokenExpiration: this.jwtExpiration, + tokenRenewal: this.jwtRenewablePeriod + }, strategies: this.strategies.map(str => _.pick(str, [ 'isEnabled', 'key', diff --git a/client/components/admin/admin-dev.vue b/client/components/admin/admin-dev.vue index 750113b6..692662cd 100644 --- a/client/components/admin/admin-dev.vue +++ b/client/components/admin/admin-dev.vue @@ -7,8 +7,18 @@ .admin-header-title .headline.primary--text Developer Tools .subheading.grey--text Β―\_(ツ)_/Β― + v-spacer + v-card.radius-7 + v-card-text + .caption Enables extra dev options and removes many safeguards. + .caption.red--text Do not enable unless you know what you're doing! + v-switch.mt-1( + color='primary' + hide-details + label='Dev Mode' + ) - v-card.mt-3 + v-card.mt-3.white.grey--text.text--darken-3 v-tabs( v-model='selectedTab' color='grey darken-2' @@ -92,9 +102,8 @@ export default { }, 500) return resp }, - query: '', response: null, - variables: null, + variables: '{}', operationName: null, websocketConnectionParams: null }), @@ -103,6 +112,7 @@ export default { graphiQLInstance.queryEditorComponent.editor.refresh() graphiQLInstance.variableEditorComponent.editor.refresh() graphiQLInstance.state.variableEditorOpen = true + graphiQLInstance.state.docExplorerOpen = true }, renderVoyager() { ReactDOM.render( @@ -120,7 +130,7 @@ export default { diff --git a/client/components/admin/admin-general.vue b/client/components/admin/admin-general.vue index 79928ab0..6efe8787 100644 --- a/client/components/admin/admin-general.vue +++ b/client/components/admin/admin-general.vue @@ -38,6 +38,8 @@ :counter='50' v-model='config.title' prepend-icon='public' + hint='Displayed in the top bar and appended to all pages meta title.' + persistent-hint ) v-divider v-subheader SEO @@ -48,6 +50,8 @@ :counter='255' v-model='config.description' prepend-icon='explore' + hint='Default description when none is provided for a page.' + persistent-hint ) v-select.mt-2( outline @@ -57,7 +61,7 @@ v-model='config.robots' prepend-icon='explore' :return-object='false' - hint='Default: Index, Follow' + hint='Default: Index, Follow. Can also be set on a per-page basis.' persistent-hint ) v-divider @@ -69,6 +73,8 @@ :items='analyticsServices' v-model='config.analyticsService' prepend-icon='timeline' + persistent-hint + hint='Automatically add tracking code for services like Google Analytics.' ) v-text-field.mt-2( v-if='config.analyticsService !== ``' diff --git a/client/components/admin/admin-groups-edit-permissions.vue b/client/components/admin/admin-groups-edit-permissions.vue index dee228da..9933da93 100644 --- a/client/components/admin/admin-groups-edit-permissions.vue +++ b/client/components/admin/admin-groups-edit-permissions.vue @@ -60,14 +60,14 @@ export default { }, { permission: 'write:pages', - hint: 'Can view and create new pages, as specified in the Page Rules', + hint: 'Can create new pages, as specified in the Page Rules', warning: false, restrictedForSystem: false, disabled: false }, { permission: 'manage:pages', - hint: 'Can view, create, edit and move existing pages as specified in the Page Rules', + hint: 'Can edit and move existing pages as specified in the Page Rules', warning: false, restrictedForSystem: false, disabled: false @@ -95,7 +95,7 @@ export default { }, { permission: 'manage:assets', - hint: 'Can edit and delete assets (such as images and files), as specified in the Page Rules', + hint: 'Can edit and delete existing assets (such as images and files), as specified in the Page Rules', warning: false, restrictedForSystem: false, disabled: false @@ -116,7 +116,7 @@ export default { }, { permission: 'manage:comments', - hint: 'Can edit and delete comments, as specified in the Page Rules', + hint: 'Can edit and delete existing comments, as specified in the Page Rules', warning: false, restrictedForSystem: false, disabled: false diff --git a/client/components/admin/admin-locale.vue b/client/components/admin/admin-locale.vue index 1c48d772..170d7ec9 100644 --- a/client/components/admin/admin-locale.vue +++ b/client/components/admin/admin-locale.vue @@ -52,6 +52,8 @@ v-toolbar(color='primary', dark, dense, flat) v-toolbar-title .subheading {{ $t('admin:locale.namespacing') }} + v-spacer + v-chip(label, color='white', small).primary--text coming soon v-card-text v-switch( v-model='namespacing' diff --git a/client/components/admin/admin-logging.vue b/client/components/admin/admin-logging.vue index 9bd8be2f..23172dc6 100644 --- a/client/components/admin/admin-logging.vue +++ b/client/components/admin/admin-logging.vue @@ -6,11 +6,11 @@ img(src='/svg/icon-registry-editor.svg', alt='Logging', style='width: 80px;') .admin-header-title .headline.primary--text Logging - .subheading.grey--text Configure the system logger(s) + .subheading.grey--text Configure the system logger(s) #[v-chip(label, color='primary', small).white--text coming soon] v-spacer v-btn(outline, color='grey', @click='refresh', large) v-icon refresh - v-btn(color='black', dark, depressed, @click='toggleConsole', large) + v-btn(color='black', disabled, depressed, @click='toggleConsole', large) ConsoleLineIcon.mr-3 span Live Trail v-btn(color='success', @click='save', depressed, large) @@ -34,6 +34,7 @@ :label='logger.title' color='primary' hide-details + disabled ) v-tab-item(v-for='(logger, n) in activeLoggers', :key='logger.key', :transition='false', :reverse-transition='false') diff --git a/client/components/admin/admin-search.vue b/client/components/admin/admin-search.vue index 06c2fce3..d0abe78c 100644 --- a/client/components/admin/admin-search.vue +++ b/client/components/admin/admin-search.vue @@ -6,7 +6,7 @@ img(src='/svg/icon-search.svg', alt='Search Engine', style='width: 80px;') .admin-header-title .headline.primary--text Search Engine - .subheading.grey--text Configure the search capabilities of your wiki + .subheading.grey--text Configure the search capabilities of your wiki #[v-chip(label, color='primary', small).white--text coming soon] v-spacer v-btn(outline, color='grey', @click='refresh', large) v-icon refresh diff --git a/client/components/admin/admin-storage.vue b/client/components/admin/admin-storage.vue index 66e008c8..e562ad34 100644 --- a/client/components/admin/admin-storage.vue +++ b/client/components/admin/admin-storage.vue @@ -6,7 +6,7 @@ img(src='/svg/icon-cloud-storage.svg', alt='Storage', style='width: 80px;') .admin-header-title .headline.primary--text Storage - .subheading.grey--text Set backup and sync targets for your content + .subheading.grey--text Set backup and sync targets for your content #[v-chip(label, color='primary', small).white--text coming soon] v-spacer v-btn(outline, color='grey', @click='refresh', large) v-icon refresh diff --git a/client/components/editor.vue b/client/components/editor.vue index 6171f2d9..a380a20e 100644 --- a/client/components/editor.vue +++ b/client/components/editor.vue @@ -14,12 +14,12 @@ outline color='blue' @click.native.stop='openPropsModal' - :class='{ "is-icon": $vuetify.breakpoint.mdAndDown, "mx-0": mode === `create`, "ml-0": mode !== `create` }' + :class='{ "is-icon": $vuetify.breakpoint.mdAndDown, "mx-0": !welcomeMode, "ml-0": !welcomeMode }' ) v-icon(color='blue', :left='$vuetify.breakpoint.lgAndUp') sort_by_alpha span.white--text(v-if='$vuetify.breakpoint.lgAndUp') {{ $t('editor:page') }} v-btn( - v-if='path !== `home`' + v-if='!welcomeMode' outline color='red' :class='{ "is-icon": $vuetify.breakpoint.mdAndDown }' @@ -62,6 +62,7 @@ import editorStore from '@/store/editor' WIKI.$store.registerModule('editor', editorStore) export default { + i18nOptions: { namespaces: 'editor' }, components: { AtomSpinner, editorCode: () => import(/* webpackChunkName: "editor-code", webpackMode: "lazy" */ './editor/editor-code.vue'), @@ -127,7 +128,8 @@ export default { darkMode: get('site/dark'), mode: get('editor/mode'), notification: get('notification'), - notificationState: sync('notification@isActive') + notificationState: sync('notification@isActive'), + welcomeMode() { return this.mode === `create` && this.path === `home` } }, watch: { currentEditor(newValue, oldValue) { @@ -242,6 +244,8 @@ export default { throw new Error(_.get(resp, 'responseResult.message')) } } + + this.initContentParsed = this.$store.get('editor/content') } catch (err) { this.$store.commit('showNotification', { message: err.message, diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index 8ffeac3d..938849ab 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -234,7 +234,7 @@ export default { if (!token.type) { return } - console.info(token) + // console.info(token) }, /** * Update scroll sync diff --git a/client/graph/admin/auth/auth-mutation-save-strategies.gql b/client/graph/admin/auth/auth-mutation-save-strategies.gql index b488a535..46f34624 100644 --- a/client/graph/admin/auth/auth-mutation-save-strategies.gql +++ b/client/graph/admin/auth/auth-mutation-save-strategies.gql @@ -1,6 +1,6 @@ -mutation($strategies: [AuthenticationStrategyInput]) { +mutation($strategies: [AuthenticationStrategyInput]!, $config: AuthenticationConfigInput) { authentication { - updateStrategies(strategies: $strategies) { + updateStrategies(strategies: $strategies, config: $config) { responseResult { succeeded errorCode diff --git a/client/scss/app.scss b/client/scss/app.scss index 96ede88f..1f4bb33d 100644 --- a/client/scss/app.scss +++ b/client/scss/app.scss @@ -23,6 +23,7 @@ // @import 'node_modules/diff2html/dist/diff2html.min'; @import 'pages/new'; +@import 'pages/unauthorized'; @import 'pages/welcome'; @import 'pages/error'; diff --git a/client/scss/pages/_unauthorized.scss b/client/scss/pages/_unauthorized.scss new file mode 100644 index 00000000..915d8c70 --- /dev/null +++ b/client/scss/pages/_unauthorized.scss @@ -0,0 +1,81 @@ +.unauthorized { + background: linear-gradient(to bottom, darken(mc('blue', '900'), 10%) 0%, mc('red', '500') 100%); + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: mc('grey', '50'); + + &::before { + content: ''; + display:block; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background-image: url('../static/svg/motif-diagonals.svg'); + background-position: center center; + background-repeat: repeat; + background-size: 50px; + z-index: 0; + opacity: .75; + animation: onboardingBgReveal 50s linear infinite; + + @include keyframes(onboardingBgReveal) { + 0% { + background-position-y: 0; + } + 100% { + background-position-y: -2000px; + } + } + } + + &::after { + content: ''; + position: absolute; + background-color: transparent; + background-image: url('../static/svg/motif-overlay.svg'); + background-attachment: fixed; + background-size: cover; + opacity: .5; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + &-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 2; + } + + img { + height: 250px; + margin-bottom: 3rem; + z-index: 2; + animation-duration: 2s; + + @include until($tablet) { + height: 200px; + } + } + + h1 { + font-size: 1.5rem; + margin-bottom: 1rem; + z-index: 2; + } + h2 { + margin-bottom: 3rem; + z-index: 2; + } + .v-btn { + z-index: 2; + } +} diff --git a/client/static/svg/icon-delete-shield.svg b/client/static/svg/icon-delete-shield.svg new file mode 100644 index 00000000..59159340 --- /dev/null +++ b/client/static/svg/icon-delete-shield.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/client/static/svg/icon-safety-float.svg b/client/static/svg/icon-safety-float.svg new file mode 100644 index 00000000..2dd4dbf3 --- /dev/null +++ b/client/static/svg/icon-safety-float.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/static/svg/motif-diagonals.svg b/client/static/svg/motif-diagonals.svg new file mode 100644 index 00000000..56ea8544 --- /dev/null +++ b/client/static/svg/motif-diagonals.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue index 9f6c40db..d5b273f7 100644 --- a/client/themes/default/components/page.vue +++ b/client/themes/default/components/page.vue @@ -189,10 +189,8 @@ export default { }, breadcrumbs: [ { path: '/', name: 'Home' }, - { path: '/universe', name: 'Universe' }, - { path: '/universe/galaxy', name: 'Galaxy' }, - { path: '/universe/galaxy/solar-system', name: 'Solar System' }, - { path: '/universe/galaxy/solar-system/planet-earth', name: 'Planet Earth' } + { path: '/' + this.path, name: 'Breadcrumb' }, + { path: '/' + this.path, name: 'Coming soon' } ], scrollStyle: { vuescroll: {}, diff --git a/dev/examples/Dockerfile b/dev/examples/Dockerfile deleted file mode 100644 index 34b22f9c..00000000 --- a/dev/examples/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM requarks/wiki:latest - -# Replace with your email address: -ENV WIKI_ADMIN_EMAIL admin@example.com - -WORKDIR /var/wiki - -# Replace your-config.yml with the path to your config file: -ADD your-config.yml config.yml - -EXPOSE 3000 -ENTRYPOINT [ "node", "server" ] diff --git a/dev/examples/docker-compose.yml b/dev/examples/docker-compose.yml index 7407a580..d2721197 100644 --- a/dev/examples/docker-compose.yml +++ b/dev/examples/docker-compose.yml @@ -1,19 +1,53 @@ -version: '3' +# -- DEV DOCKER-COMPOSE -- +# -- DO NOT USE IN PRODUCTION! -- + +version: "3" services: - wikidb: - image: mongo - expose: - - '27017' - command: '--smallfiles --logpath=/dev/null' - volumes: - - ./data/mongo:/data/db - wikijs: - image: 'requarks/wiki:latest' - links: - - wikidb - ports: - - '80:3000' + + redis: + image: redis:4-alpine + logging: + driver: "none" + networks: + - wikinet + + db: + image: postgres:9-alpine environment: - WIKI_ADMIN_EMAIL: admin@example.com + POSTGRES_DB: wiki + POSTGRES_PASSWORD: wikijsrocks + POSTGRES_USER: wikijs + logging: + driver: "none" volumes: - - ./config.yml:/var/wiki/config.yml + - db-data:/var/lib/postgresql/data + networks: + - wikinet + + wiki: + image: requarks/wiki:beta + depends_on: + - db + - redis + environment: + PORT: 3000 # DO NOT CHANGE! Use ports below to specify listening port. + DB_TYPE: postgres + DB_HOST: db + DB_PORT: 5432 + DB_USER: wikijs + DB_PASS: wikijsrocks + DB_NAME: wiki + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_DB: 0 + REDIS_PASS: '' + networks: + - wikinet + ports: + - "3000:3000" # <-- replace with "80:3000" to listen on port 80 instead + +networks: + wikinet: + +volumes: + db-data: diff --git a/server/app/data.yml b/server/app/data.yml index 2f2d9483..d3fdf518 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -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 diff --git a/server/controllers/common.js b/server/controllers/common.js index c89c3030..2211dccf 100644 --- a/server/controllers/common.js +++ b/server/controllers/common.js @@ -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 }) } }) diff --git a/server/core/auth.js b/server/core/auth.js index a2736378..bda065c5 100644 --- a/server/core/auth.js +++ b/server/core/auth.js @@ -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} 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 } } diff --git a/server/core/config.js b/server/core/config.js index e45b7e8b..dfe26ea2 100644 --- a/server/core/config.js +++ b/server/core/config.js @@ -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 diff --git a/server/db/migrations/2.0.0.js b/server/db/migrations/2.0.0-beta.1.js similarity index 89% rename from server/db/migrations/2.0.0.js rename to server/db/migrations/2.0.0-beta.1.js index 49b92a14..4ec6fd1e 100644 --- a/server/db/migrations/2.0.0.js +++ b/server/db/migrations/2.0.0-beta.1.js @@ -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') diff --git a/server/db/seeds/settings.js b/server/db/seeds/settings.js deleted file mode 100644 index d8c30727..00000000 --- a/server/db/seeds/settings.js +++ /dev/null @@ -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: {} } - ]) -} diff --git a/server/graph/resolvers/authentication.js b/server/graph/resolvers/authentication.js index b52eb7d6..fa3b13d2 100644 --- a/server/graph/resolvers/authentication.js +++ b/server/graph/resolvers/authentication.js @@ -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, diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index e361762a..ee69dc23 100644 --- a/server/graph/schemas/authentication.graphql +++ b/server/graph/schemas/authentication.graphql @@ -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! +} diff --git a/server/master.js b/server/master.js index 818bd3f8..8f2e2990 100644 --- a/server/master.js +++ b/server/master.js @@ -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 diff --git a/server/middlewares/auth.js b/server/middlewares/auth.js deleted file mode 100644 index 8ee13bc4..00000000 --- a/server/middlewares/auth.js +++ /dev/null @@ -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() - } -} diff --git a/server/models/users.js b/server/models/users.js index f91a74ce..dc0aae07 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -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 + } } diff --git a/server/setup.js b/server/setup.js index b35cde35..81dc5fd3 100644 --- a/server/setup.js +++ b/server/setup.js @@ -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('πŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”Ί') }) } diff --git a/server/views/unauthorized.pug b/server/views/unauthorized.pug new file mode 100644 index 00000000..61d22f00 --- /dev/null +++ b/server/views/unauthorized.pug @@ -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')