diff --git a/.editorconfig b/.editorconfig index 49af1a19..8f066db4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,7 @@ insert_final_newline = true [*.{jade,pug,md}] trim_trailing_whitespace = false + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/Makefile b/Makefile index b18fecc0..2ebf8e2e 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,7 @@ docker-dev-down: ## Shutdown dockerized dev environment docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down docker-dev-rebuild: ## Rebuild dockerized dev image + rm -rf ./node_modules docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm docker-build: ## Run assets generation build in docker diff --git a/client/client-app.js b/client/client-app.js index 661c57e4..30f1f120 100644 --- a/client/client-app.js +++ b/client/client-app.js @@ -163,10 +163,12 @@ Vue.component('admin', () => import(/* webpackChunkName: "admin" */ './component Vue.component('editor', () => import(/* webpackPrefetch: -100, webpackChunkName: "editor" */ './components/editor.vue')) Vue.component('history', () => import(/* webpackChunkName: "history" */ './components/history.vue')) Vue.component('page-source', () => import(/* webpackChunkName: "source" */ './components/source.vue')) +Vue.component('loader', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/loader.vue')) Vue.component('login', () => import(/* webpackPrefetch: true, webpackChunkName: "login" */ './components/login.vue')) Vue.component('nav-header', () => import(/* webpackMode: "eager" */ './components/common/nav-header.vue')) Vue.component('page-selector', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/page-selector.vue')) Vue.component('profile', () => import(/* webpackChunkName: "profile" */ './components/profile.vue')) +Vue.component('register', () => import(/* webpackChunkName: "register" */ './components/register.vue')) Vue.component('v-card-chin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue')) Vue.component('nav-footer', () => import(/* webpackChunkName: "theme-page" */ './themes/' + process.env.CURRENT_THEME + '/components/nav-footer.vue')) diff --git a/client/components/admin/admin-auth.vue b/client/components/admin/admin-auth.vue index 65c2cd43..61aba5a9 100644 --- a/client/components/admin/admin-auth.vue +++ b/client/components/admin/admin-auth.vue @@ -151,6 +151,18 @@ multiple chips ) + template(v-if='strategy.key === `local`') + v-divider.mt-3 + v-subheader.pl-0 Security + .pr-3 + v-switch.ml-3( + :disabled='true' + v-model='strategy.recaptcha' + label='Use reCAPTCHA by Google' + color='primary' + hint='Protects against spam robots and malicious registrations.' + persistent-hint + ) + + diff --git a/client/components/common/password-strength.vue b/client/components/common/password-strength.vue new file mode 100644 index 00000000..0203ab18 --- /dev/null +++ b/client/components/common/password-strength.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/client/components/editor.vue b/client/components/editor.vue index 443d15a7..d7c4efb0 100644 --- a/client/components/editor.vue +++ b/client/components/editor.vue @@ -30,16 +30,6 @@ v-content component(:is='currentEditor') editor-modal-properties(v-model='dialogProps') - v-dialog(v-model='dialogProgress', persistent, max-width='350') - v-card(color='blue darken-3', dark) - v-card-text.text-xs-center.py-4 - atom-spinner.is-inline( - :animation-duration='1000' - :size='60' - color='#FFF' - ) - .subheading {{ $t('editor:save.processing') }} - .caption.blue--text.text--lighten-3 {{ $t('editor:save.pleaseWait') }} v-dialog(v-model='dialogEditorSelector', persistent, max-width='700') v-card.radius-7(color='blue darken-3', dark) v-card-text.text-xs-center.py-4 @@ -88,6 +78,7 @@ .caption.grey--text.text--darken-1 Drag-n-drop .caption.blue--text.text--lighten-2 This cannot be changed once the page is created. + loader(v-model='dialogProgress', :title='$t(`editor:save.processing`)', :subtitle='$t(`editor:save.pleaseWait`)') v-snackbar( :color='notification.style' bottom, diff --git a/client/components/login.vue b/client/components/login.vue index a65b63d7..24ffa9a3 100644 --- a/client/components/login.vue +++ b/client/components/login.vue @@ -15,7 +15,7 @@ v-toolbar(color='primary', flat, dense, dark) v-spacer .subheading(v-if='screen === "tfa"') {{ $t('auth:tfa.subtitle') }} - .subheading(v-else-if='selectedStrategy.key !== "local"') Login using {{ selectedStrategy.title }} + .subheading(v-else-if='selectedStrategy.key !== "local"') {{ $t('auth:loginUsingStrategy', { strategy: selectedStrategy.title }) }} .subheading(v-else) {{ $t('auth:loginRequired') }} v-spacer v-card-text.text-xs-center @@ -80,12 +80,12 @@ v-spacer v-card-actions.pb-3(v-if='selectedStrategy.key === "local"') v-spacer - a.caption(href='') Forgot your password? + a.caption(href='') {{ $t('auth:forgotPasswordLink') }} v-spacer template(v-if='isSocialShown') v-divider v-card-text.grey.lighten-4.text-xs-center - .pb-2.body-2.text-xs-center.grey--text.text--darken-2 or login using... + .pb-2.body-2.text-xs-center.grey--text.text--darken-2 {{ $t('auth:orLoginUsingStrategy') }} v-tooltip(top, v-for='strategy in strategies', :key='strategy.key') .social-login-btn.mr-2( slot='activator' @@ -99,8 +99,11 @@ v-divider v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"') v-spacer - .caption Don't have an account yet? #[a.caption(href='') Create an account] + i18next.caption(path='auth:switchToRegister.text', tag='div') + a.caption(href='/register', place='link') {{ $t('auth:switchToRegister.link') }} v-spacer + + loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)') nav-footer(color='grey darken-4') @@ -128,6 +131,8 @@ export default { securityCode: '', loginToken: '', isLoading: false, + loaderColor: 'grey darken-4', + loaderTitle: 'Working...', isShown: false } }, @@ -173,18 +178,20 @@ export default { if (this.username.length < 2) { this.$store.commit('showNotification', { style: 'red', - message: 'Enter a valid email / username.', + message: this.$t('auth:invalidEmailUsername'), icon: 'warning' }) this.$refs.iptEmail.focus() } else if (this.password.length < 2) { this.$store.commit('showNotification', { style: 'red', - message: 'Enter a valid password.', + message: this.$t('auth:invalidPassword'), icon: 'warning' }) this.$refs.iptPassword.focus() } else { + this.loaderColor = 'grey darken-4' + this.loaderTitle = this.$t('auth:signingIn') this.isLoading = true try { let resp = await this.$apollo.mutate({ @@ -205,23 +212,20 @@ export default { this.$nextTick(() => { this.$refs.iptTFA.focus() }) + this.isLoading = false } else { - this.$store.commit('showNotification', { - message: 'Login Successful! Redirecting...', - style: 'success', - icon: 'check' - }) + this.loaderColor = 'green darken-1' + this.loaderTitle = this.$t('auth:loginSuccess') Cookies.set('jwt', respObj.jwt, { expires: 365 }) _.delay(() => { window.location.replace('/') // TEMPORARY - USE RETURNURL }, 1000) } - this.isLoading = false } else { throw new Error(respObj.responseResult.message) } } else { - throw new Error('Authentication is unavailable.') + throw new Error(this.$t('auth:genericError')) } } catch (err) { console.error(err) @@ -270,7 +274,7 @@ export default { throw new Error(respObj.responseResult.message) } } else { - throw new Error('Authentication is unavailable.') + throw new Error(this.$t('auth:genericError')) } }).catch(err => { console.error(err) @@ -289,7 +293,6 @@ export default { query: strategiesQuery, update: (data) => data.authentication.strategies, watchLoading (isLoading) { - this.isLoading = isLoading this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'login-strategies-refresh') } } diff --git a/client/components/register.vue b/client/components/register.vue new file mode 100644 index 00000000..d253220c --- /dev/null +++ b/client/components/register.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/client/graph/register/register-mutation-create.gql b/client/graph/register/register-mutation-create.gql new file mode 100644 index 00000000..16cc9836 --- /dev/null +++ b/client/graph/register/register-mutation-create.gql @@ -0,0 +1,13 @@ +mutation($email: String!, $password: String!, $name: String!) { + authentication { + register(email: $email, password: $password, name: $name) { + responseResult { + succeeded + errorCode + slug + message + } + jwt + } + } +} diff --git a/client/static/img/icon-browse.png b/client/static/img/icon-browse.png deleted file mode 100644 index 60a11199..00000000 Binary files a/client/static/img/icon-browse.png and /dev/null differ diff --git a/client/static/img/icon-people.png b/client/static/img/icon-people.png deleted file mode 100644 index 4c35b7d0..00000000 Binary files a/client/static/img/icon-people.png and /dev/null differ diff --git a/client/static/img/icon-unlock.png b/client/static/img/icon-unlock.png deleted file mode 100644 index 7fbd96b1..00000000 Binary files a/client/static/img/icon-unlock.png and /dev/null differ diff --git a/client/static/svg/logo-icons8.svg b/client/static/svg/logo-icons8.svg new file mode 100644 index 00000000..e9a6035e --- /dev/null +++ b/client/static/svg/logo-icons8.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/dev/docker/docker-compose.yml b/dev/docker/docker-compose.yml index 86c5e167..72f541b8 100644 --- a/dev/docker/docker-compose.yml +++ b/dev/docker/docker-compose.yml @@ -30,8 +30,6 @@ services: adminer: image: adminer:latest - environment: - ADMINER_DESIGN: pappu687 logging: driver: "none" networks: diff --git a/package.json b/package.json index dbb526fe..13c5051e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "eslint --format codeframe --ext .js,.vue . && pug-lint server/views && jest", "docker:dev:up": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . up -d && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . exec wiki yarn dev", "docker:dev:down": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down", - "docker:dev:rebuild": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm", + "docker:dev:rebuild": "rmdir node_modules /s /q && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm", "docker:build": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . run wiki yarn build && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down" }, "bin": { @@ -59,6 +59,7 @@ "connect-redis": "3.4.0", "cookie-parser": "1.4.3", "cors": "2.8.5", + "custom-error-instance": "2.1.1", "dependency-graph": "0.7.2", "diff": "3.5.0", "diff2html": "2.5.0", @@ -154,6 +155,7 @@ "subscriptions-transport-ws": "0.9.15", "uslug": "1.0.4", "uuid": "3.3.2", + "validate.js": "0.12.0", "validator": "10.9.0", "validator-as-promised": "1.0.2", "winston": "3.1.0", @@ -284,7 +286,8 @@ "webpack-subresource-integrity": "1.3.0", "whatwg-fetch": "3.0.0", "write-file-webpack-plugin": "4.4.1", - "xterm": "3.8.0" + "xterm": "3.8.0", + "zxcvbn": "4.4.2" }, "browserslist": [ "> 1%", diff --git a/server/controllers/auth.js b/server/controllers/auth.js index d5b7e110..fe20b107 100644 --- a/server/controllers/auth.js +++ b/server/controllers/auth.js @@ -18,6 +18,13 @@ router.get('/logout', function (req, res) { res.redirect('/') }) +/** + * Register form + */ +router.get('/register', function (req, res, next) { + res.render('register') +}) + /** * JWT Public Endpoints */ diff --git a/server/graph/resolvers/authentication.js b/server/graph/resolvers/authentication.js index 8ab56c31..d0af41ef 100644 --- a/server/graph/resolvers/authentication.js +++ b/server/graph/resolvers/authentication.js @@ -38,7 +38,7 @@ module.exports = { AuthenticationMutation: { async login(obj, args, context) { try { - let authResult = await WIKI.models.users.login(args, context) + const authResult = await WIKI.models.users.login(args, context) return { ...authResult, responseResult: graphHelper.generateSuccess('Login success') @@ -49,7 +49,7 @@ module.exports = { }, async loginTFA(obj, args, context) { try { - let authResult = await WIKI.models.users.loginTFA(args, context) + const authResult = await WIKI.models.users.loginTFA(args, context) return { ...authResult, responseResult: graphHelper.generateSuccess('TFA success') @@ -58,6 +58,22 @@ module.exports = { return graphHelper.generateError(err) } }, + async register(obj, args, context) { + try { + await WIKI.models.users.register(args, context) + const authResult = await WIKI.models.users.login({ + username: args.email, + password: args.password, + strategy: 'local' + }, context) + return { + jwt: authResult.jwt, + responseResult: graphHelper.generateSuccess('Registration success') + } + } catch (err) { + return graphHelper.generateError(err) + } + }, async updateStrategies(obj, args, context) { try { for (let str of args.strategies) { diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index aabb4295..e361762a 100644 --- a/server/graph/schemas/authentication.graphql +++ b/server/graph/schemas/authentication.graphql @@ -36,6 +36,12 @@ type AuthenticationMutation { securityCode: String! ): DefaultResponse + register( + email: String! + password: String! + name: String! + ): AuthenticationRegisterResponse + updateStrategies( strategies: [AuthenticationStrategyInput] ): DefaultResponse @auth(requires: ["manage:system"]) @@ -69,6 +75,11 @@ type AuthenticationLoginResponse { tfaLoginToken: String } +type AuthenticationRegisterResponse { + responseResult: ResponseStatus + jwt: String +} + input AuthenticationStrategyInput { isEnabled: Boolean! key: String! diff --git a/server/helpers/error.js b/server/helpers/error.js index 67e13d4f..3643372b 100644 --- a/server/helpers/error.js +++ b/server/helpers/error.js @@ -1,30 +1,44 @@ -class BaseError extends Error { - constructor (message) { - super(message) - this.name = this.constructor.name - Error.captureStackTrace(this, this.constructor) - } -} - -class AuthGenericError extends BaseError { constructor (message = 'An unexpected error occured during login.') { super(message) } } -class AuthLoginFailed extends BaseError { constructor (message = 'Invalid email / username or password.') { super(message) } } -class AuthProviderInvalid extends BaseError { constructor (message = 'Invalid authentication provider.') { super(message) } } -class AuthTFAFailed extends BaseError { constructor (message = 'Incorrect TFA Security Code.') { super(message) } } -class AuthTFAInvalid extends BaseError { constructor (message = 'Invalid TFA Security Code or Login Token.') { super(message) } } -class BruteInstanceIsInvalid extends BaseError { constructor (message = 'Invalid Brute Force Instance.') { super(message) } } -class BruteTooManyAttempts extends BaseError { constructor (message = 'Too many attempts! Try again later.') { super(message) } } -class LocaleInvalidNamespace extends BaseError { constructor (message = 'Invalid locale or namespace.') { super(message) } } -class UserCreationFailed extends BaseError { constructor (message = 'An unexpected error occured during user creation.') { super(message) } } +const CustomError = require('custom-error-instance') module.exports = { - BaseError, - AuthGenericError, - AuthLoginFailed, - AuthProviderInvalid, - AuthTFAFailed, - AuthTFAInvalid, - BruteInstanceIsInvalid, - BruteTooManyAttempts, - LocaleInvalidNamespace, - UserCreationFailed + AuthGenericError: CustomError('AuthGenericError', { + message: 'An unexpected error occured during login.', + code: 1001 + }), + AuthLoginFailed: CustomError('AuthLoginFailed', { + message: 'Invalid email / username or password.', + code: 1002 + }), + AuthProviderInvalid: CustomError('AuthProviderInvalid', { + message: 'Invalid authentication provider.', + code: 1003 + }), + AuthAccountAlreadyExists: CustomError('AuthAccountAlreadyExists', { + message: 'An account already exists using this email address.', + code: 1004 + }), + AuthTFAFailed: CustomError('AuthTFAFailed', { + message: 'Incorrect TFA Security Code.', + code: 1005 + }), + AuthTFAInvalid: CustomError('AuthTFAInvalid', { + message: 'Invalid TFA Security Code or Login Token.', + code: 1006 + }), + BruteInstanceIsInvalid: CustomError('BruteInstanceIsInvalid', { + message: 'Invalid Brute Force Instance.', + code: 1007 + }), + BruteTooManyAttempts: CustomError('BruteTooManyAttempts', { + message: 'Too many attempts! Try again later.', + code: 1008 + }), + LocaleInvalidNamespace: CustomError('LocaleInvalidNamespace', { + message: 'Invalid locale or namespace.', + code: 1009 + }), + UserCreationFailed: CustomError('UserCreationFailed', { + message: 'An unexpected error occured during user creation.', + code: 1010 + }) } diff --git a/server/models/users.js b/server/models/users.js index e5c44023..28e24c1e 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -292,4 +292,23 @@ module.exports = class User extends Model { } throw new WIKI.Error.AuthTFAInvalid() } + + static async register ({ email, password, name }, context) { + const usr = await WIKI.models.users.query().findOne({ email, providerKey: 'local' }) + if (!usr) { + await WIKI.models.users.query().insert({ + provider: 'local', + email, + name, + password, + locale: 'en', + defaultEditor: 'markdown', + tfaIsActive: false, + isSystem: false + }) + return true + } else { + throw new WIKI.Error.AuthAccountAlreadyExists() + } + } } diff --git a/server/views/register.pug b/server/views/register.pug new file mode 100644 index 00000000..952885f8 --- /dev/null +++ b/server/views/register.pug @@ -0,0 +1,5 @@ +extends master.pug + +block body + #root.is-fullscreen + register