From 2b98a5f27afcb51bc9f731287c2be774db6c4e3a Mon Sep 17 00:00:00 2001 From: Nicolas Giard Date: Sat, 22 Dec 2018 16:18:16 -0500 Subject: [PATCH] feat: account verification + mail config in admin area --- client/components/admin.vue | 4 + client/components/admin/admin-mail.vue | 206 ++++++++++++ .../admin/mail/mail-mutation-save-config.gql | 36 +++ client/graph/admin/mail/mail-query-config.gql | 17 + client/static/svg/icon-new-post.svg | 39 +++ package.json | 1 + server/core/mail.js | 74 +++++ server/db/migrations/2.0.0.js | 15 + server/graph/resolvers/mail.js | 46 +++ server/graph/schemas/mail.graphql | 57 ++++ server/helpers/error.js | 16 + server/master.js | 1 + server/models/users.js | 21 +- .../authentication/local/authentication.js | 8 +- server/setup.js | 8 +- server/templates/account-verify.html | 304 ++++++++++++++++++ 16 files changed, 849 insertions(+), 4 deletions(-) create mode 100644 client/components/admin/admin-mail.vue create mode 100644 client/graph/admin/mail/mail-mutation-save-config.gql create mode 100644 client/graph/admin/mail/mail-query-config.gql create mode 100644 client/static/svg/icon-new-post.svg create mode 100644 server/core/mail.js create mode 100644 server/graph/resolvers/mail.js create mode 100644 server/graph/schemas/mail.graphql create mode 100644 server/templates/account-verify.html diff --git a/client/components/admin.vue b/client/components/admin.vue index aed83164..c2dc8bee 100644 --- a/client/components/admin.vue +++ b/client/components/admin.vue @@ -66,6 +66,9 @@ v-list-tile(to='/api') v-list-tile-avatar: v-icon call_split v-list-tile-title {{ $t('admin:api.title') }} + v-list-tile(to='/mail') + v-list-tile-avatar: v-icon email + v-list-tile-title {{ $t('admin:mail.title') }} v-list-tile(to='/system') v-list-tile-avatar: v-icon tune v-list-tile-title {{ $t('admin:system.title') }} @@ -121,6 +124,7 @@ const router = new VueRouter({ { path: '/search', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-search.vue') }, { path: '/storage', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-storage.vue') }, { path: '/api', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-api.vue') }, + { path: '/mail', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-mail.vue') }, { path: '/system', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-system.vue') }, { path: '/utilities', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-utilities.vue') }, { path: '/dev', component: () => import(/* webpackChunkName: "admin-dev" */ './admin/admin-dev.vue') }, diff --git a/client/components/admin/admin-mail.vue b/client/components/admin/admin-mail.vue new file mode 100644 index 00000000..ba0eab4b --- /dev/null +++ b/client/components/admin/admin-mail.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/client/graph/admin/mail/mail-mutation-save-config.gql b/client/graph/admin/mail/mail-mutation-save-config.gql new file mode 100644 index 00000000..ed280713 --- /dev/null +++ b/client/graph/admin/mail/mail-mutation-save-config.gql @@ -0,0 +1,36 @@ +mutation ( + $senderName: String!, + $senderEmail: String!, + $host: String!, + $port: Int!, + $secure: Boolean!, + $user: String!, + $pass: String!, + $useDKIM: Boolean!, + $dkimDomainName: String!, + $dkimKeySelector: String!, + $dkimPrivateKey: String! +) { + mail { + updateConfig( + senderName: $senderName, + senderEmail: $senderEmail, + host: $host, + port: $port, + secure: $secure, + user: $user, + pass: $pass, + useDKIM: $useDKIM, + dkimDomainName: $dkimDomainName, + dkimKeySelector: $dkimKeySelector, + dkimPrivateKey: $dkimPrivateKey + ) { + responseResult { + succeeded + errorCode + slug + message + } + } + } +} diff --git a/client/graph/admin/mail/mail-query-config.gql b/client/graph/admin/mail/mail-query-config.gql new file mode 100644 index 00000000..0cdc8c19 --- /dev/null +++ b/client/graph/admin/mail/mail-query-config.gql @@ -0,0 +1,17 @@ +{ + mail { + config { + senderName + senderEmail + host + port + secure + user + pass + useDKIM + dkimDomainName + dkimKeySelector + dkimPrivateKey + } + } +} diff --git a/client/static/svg/icon-new-post.svg b/client/static/svg/icon-new-post.svg new file mode 100644 index 00000000..1fdeb21c --- /dev/null +++ b/client/static/svg/icon-new-post.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index 13c5051e..c3074004 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "mysql2": "1.6.4", "node-2fa": "1.1.2", "node-cache": "4.2.0", + "nodemailer": "4.7.0", "oauth2orize": "1.11.0", "objection": "1.4.0", "ora": "3.0.0", diff --git a/server/core/mail.js b/server/core/mail.js new file mode 100644 index 00000000..fc71b679 --- /dev/null +++ b/server/core/mail.js @@ -0,0 +1,74 @@ +const nodemailer = require('nodemailer') +const _ = require('lodash') +const fs = require('fs-extra') +const path = require('path') + +/* global WIKI */ + +module.exports = { + transport: null, + templates: {}, + init() { + if (_.get(WIKI.config, 'mail.host', '').length > 2) { + let conf = { + host: WIKI.config.mail.host, + port: WIKI.config.mail.port, + secure: WIKI.config.mail.secure + } + if (_.get(WIKI.config, 'mail.user', '').length > 1) { + conf = { + ...conf, + auth: { + user: WIKI.config.mail.user, + pass: WIKI.config.mail.pass + } + } + } + if (_.get(WIKI.config, 'mail.useDKIM', false)) { + conf = { + ...conf, + dkim: { + domainName: WIKI.config.mail.dkimDomainName, + keySelector: WIKI.config.mail.dkimKeySelector, + privateKey: WIKI.config.mail.dkimPrivateKey + } + } + } + this.transport = nodemailer.createTransport(conf) + } else { + WIKI.logger.warn('Mail is not setup! Please set the configuration in the administration area!') + this.transport = null + } + return this + }, + async send(opts) { + if (!this.transport) { + WIKI.logger.warn('Cannot send email because mail is not setup in the administration area!') + throw new WIKI.Error.MailNotSetup() + } + await this.loadTemplate(opts.template) + return this.transport.sendMail({ + from: 'noreply@requarks.io', + to: opts.to, + subject: `${opts.subject} - ${WIKI.config.title}`, + text: opts.text, + html: _.get(this.templates, opts.template)({ + logo: '', + siteTitle: WIKI.config.title, + copyright: 'Powered by Wiki.js', + ...opts.data + }) + }) + }, + async loadTemplate(key) { + if (_.has(this.templates, key)) { return } + const keyKebab = _.kebabCase(key) + try { + const rawTmpl = await fs.readFile(path.join(WIKI.SERVERPATH, `templates/${keyKebab}.html`), 'utf8') + _.set(this.templates, key, _.template(rawTmpl)) + } catch (err) { + WIKI.logger.warn(err) + throw new WIKI.Error.MailTemplateFailed() + } + } +} diff --git a/server/db/migrations/2.0.0.js b/server/db/migrations/2.0.0.js index d2f68e03..b515032a 100644 --- a/server/db/migrations/2.0.0.js +++ b/server/db/migrations/2.0.0.js @@ -170,6 +170,15 @@ exports.up = knex => { table.string('createdAt').notNullable() table.string('updatedAt').notNullable() }) + // USER KEYS --------------------------- + .createTable('userKeys', table => { + table.charset('utf8mb4') + table.increments('id').primary() + table.string('kind').notNullable() + table.string('key').notNullable() + table.string('createdAt').notNullable() + table.string('validUntil').notNullable() + }) // USERS ------------------------------- .createTable('users', table => { table.charset('utf8mb4') @@ -185,6 +194,8 @@ exports.up = knex => { table.string('pictureUrl') table.string('timezone').notNullable().defaultTo('America/New_York') table.boolean('isSystem').notNullable().defaultTo(false) + table.boolean('isActive').notNullable().defaultTo(false) + table.boolean('isVerified').notNullable().defaultTo(false) table.string('createdAt').notNullable() table.string('updatedAt').notNullable() }) @@ -240,6 +251,9 @@ exports.up = knex => { table.integer('pageId').unsigned().references('id').inTable('pages') table.string('localeCode', 2).references('code').inTable('locales') }) + .table('userKeys', table => { + table.integer('userId').unsigned().references('id').inTable('users') + }) .table('users', table => { table.string('providerKey').references('key').inTable('authentication').notNullable().defaultTo('local') table.string('localeCode', 2).references('code').inTable('locales').notNullable().defaultTo('en') @@ -267,5 +281,6 @@ exports.down = knex => { .dropTableIfExists('settings') .dropTableIfExists('storage') .dropTableIfExists('tags') + .dropTableIfExists('userKeys') .dropTableIfExists('users') } diff --git a/server/graph/resolvers/mail.js b/server/graph/resolvers/mail.js new file mode 100644 index 00000000..9bb0845d --- /dev/null +++ b/server/graph/resolvers/mail.js @@ -0,0 +1,46 @@ +const _ = require('lodash') +const graphHelper = require('../../helpers/graph') + +/* global WIKI */ + +module.exports = { + Query: { + async mail() { return {} } + }, + Mutation: { + async mail() { return {} } + }, + MailQuery: { + async config(obj, args, context, info) { + return WIKI.config.mail + } + }, + MailMutation: { + async updateConfig(obj, args, context) { + try { + WIKI.config.mail = { + senderName: args.senderName, + senderEmail: args.senderEmail, + host: args.host, + port: args.port, + secure: args.secure, + user: args.user, + pass: args.pass, + useDKIM: args.useDKIM, + dkimDomainName: args.dkimDomainName, + dkimKeySelector: args.dkimKeySelector, + dkimPrivateKey: args.dkimPrivateKey, + } + await WIKI.configSvc.saveToDb(['mail']) + + WIKI.mail.init() + + return { + responseResult: graphHelper.generateSuccess('Mail configuration updated successfully') + } + } catch (err) { + return graphHelper.generateError(err) + } + } + } +} diff --git a/server/graph/schemas/mail.graphql b/server/graph/schemas/mail.graphql new file mode 100644 index 00000000..1ac3ffe8 --- /dev/null +++ b/server/graph/schemas/mail.graphql @@ -0,0 +1,57 @@ +# =============================================== +# MAIL +# =============================================== + +extend type Query { + mail: MailQuery +} + +extend type Mutation { + mail: MailMutation +} + +# ----------------------------------------------- +# QUERIES +# ----------------------------------------------- + +type MailQuery { + config: MailConfig @auth(requires: ["manage:system"]) +} + +# ----------------------------------------------- +# MUTATIONS +# ----------------------------------------------- + +type MailMutation { + updateConfig( + senderName: String! + senderEmail: String! + host: String! + port: Int! + secure: Boolean! + user: String! + pass: String! + useDKIM: Boolean! + dkimDomainName: String! + dkimKeySelector: String! + dkimPrivateKey: String! + ): DefaultResponse @auth(requires: ["manage:system"]) +} + +# ----------------------------------------------- +# TYPES +# ----------------------------------------------- + +type MailConfig { + senderName: String! + senderEmail: String! + host: String! + port: Int! + secure: Boolean! + user: String! + pass: String! + useDKIM: Boolean! + dkimDomainName: String! + dkimKeySelector: String! + dkimPrivateKey: String! +} diff --git a/server/helpers/error.js b/server/helpers/error.js index 345aed58..05e57626 100644 --- a/server/helpers/error.js +++ b/server/helpers/error.js @@ -1,6 +1,14 @@ const CustomError = require('custom-error-instance') module.exports = { + AuthAccountBanned: CustomError('AuthAccountBanned', { + message: 'Your account has been disabled.', + code: 1016 + }), + AuthAccountNotVerified: CustomError('AuthAccountNotVerified', { + message: 'You must verify your account before your can login.', + code: 1017 + }), AuthGenericError: CustomError('AuthGenericError', { message: 'An unexpected error occured during login.', code: 1001 @@ -45,6 +53,14 @@ module.exports = { message: 'Input data is invalid.', code: 1013 }), + MailNotSetup: CustomError('MailNotSetup', { + message: 'Mail is not setup yet.', + code: 1014 + }), + MailTemplateFailed: CustomError('MailTemplateFailed', { + message: 'Mail template failed to load.', + code: 1015 + }), LocaleInvalidNamespace: CustomError('LocaleInvalidNamespace', { message: 'Invalid locale or namespace.', code: 1009 diff --git a/server/master.js b/server/master.js index be8fdab9..7a3e55e1 100644 --- a/server/master.js +++ b/server/master.js @@ -19,6 +19,7 @@ module.exports = async () => { WIKI.auth = require('./core/auth').init() WIKI.lang = require('./core/localization').init() + WIKI.mail = require('./core/mail').init() // ---------------------------------------- // Load middlewares diff --git a/server/models/users.js b/server/models/users.js index 2174d75e..ca096e52 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -34,6 +34,8 @@ module.exports = class User extends Model { location: {type: 'string'}, pictureUrl: {type: 'string'}, isSystem: {type: 'boolean'}, + isActive: {type: 'boolean'}, + isVerified: {type: 'boolean'}, createdAt: {type: 'string'}, updatedAt: {type: 'string'} } @@ -351,7 +353,24 @@ module.exports = class User extends Model { locale: 'en', defaultEditor: 'markdown', tfaIsActive: false, - isSystem: false + isSystem: false, + isActive: true, + isVerified: false + }) + + // Send verification email + await WIKI.mail.send({ + template: 'accountVerify', + to: email, + subject: 'Verify your account', + data: { + preheadertext: 'Verify your account in order to gain access to the wiki.', + title: 'Verify your account', + content: 'Click the button below in order to verify your account and gain access to the wiki.', + buttonLink: 'http://www.google.com', + buttonText: 'Verify' + }, + text: `You must open the following link in your browser to verify your account and gain access to the wiki: http://www.google.com` }) return true } else { diff --git a/server/modules/authentication/local/authentication.js b/server/modules/authentication/local/authentication.js index b212c03c..5f4120e9 100644 --- a/server/modules/authentication/local/authentication.js +++ b/server/modules/authentication/local/authentication.js @@ -19,7 +19,13 @@ module.exports = { }).then((user) => { if (user) { return user.verifyPassword(uPassword).then(() => { - done(null, user) + if (!user.isActive) { + done(new WIKI.Error.AuthAccountBanned(), null) + } else if (!user.isVerified) { + done(new WIKI.Error.AuthAccountNotVerified(), null) + } else { + done(null, user) + } }).catch((err) => { done(err, null) }) diff --git a/server/setup.js b/server/setup.js index f9623ec6..ea5c061f 100644 --- a/server/setup.js +++ b/server/setup.js @@ -204,7 +204,9 @@ module.exports = () => { name: 'Administrator', locale: 'en', defaultEditor: 'markdown', - tfaIsActive: false + tfaIsActive: false, + isActive: true, + isVerified: true }) await adminUser.$relatedQuery('groups').relate(adminGroup.id) @@ -222,7 +224,9 @@ module.exports = () => { locale: 'en', defaultEditor: 'markdown', tfaIsActive: false, - isSystem: true + isSystem: true, + isActive: true, + isVerified: true }) await guestUser.$relatedQuery('groups').relate(guestGroup.id) diff --git a/server/templates/account-verify.html b/server/templates/account-verify.html new file mode 100644 index 00000000..3d55d420 --- /dev/null +++ b/server/templates/account-verify.html @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ <%= preheadertext %> +
+ + + + +
+ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌  +
+ + + + + + +
+ +