From cb0d86906fb03c9b7122a385b130828fbfd46e73 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Tue, 9 Jan 2018 20:41:53 -0500 Subject: [PATCH] feat: login + TFA authentication --- client/js/components/login.vue | 160 +++++++++++++++++++--- client/js/constants/graphql.js | 18 +++ client/scss/components/login.scss | 23 ++++ server/app/data.yml | 3 + server/extensions/authentication/local.js | 9 +- server/helpers/error.js | 30 ++++ server/helpers/security.js | 30 ++-- server/index.js | 1 + server/master.js | 12 +- server/models/user.js | 110 ++++++++++++++- server/modules/auth.js | 2 +- server/modules/localization.js | 11 +- server/schemas/resolvers-user.js | 16 +++ server/schemas/types.graphql | 21 ++- 14 files changed, 402 insertions(+), 44 deletions(-) create mode 100644 server/helpers/error.js diff --git a/client/js/components/login.vue b/client/js/components/login.vue index 2a9164ee..be26dbf0 100644 --- a/client/js/components/login.vue +++ b/client/js/components/login.vue @@ -1,20 +1,29 @@ @@ -23,26 +32,36 @@ export default { name: 'login', - data() { + data () { return { error: false, strategies: [], - selectedStrategy: 'local' + selectedStrategy: 'local', + screen: 'login', + username: '', + password: '', + securityCode: '', + loginToken: '', + isLoading: false } }, computed: { - siteTitle() { + siteTitle () { return siteConfig.title } }, methods: { - selectStrategy(key, useForm) { + selectStrategy (key, useForm) { this.selectedStrategy = key + this.screen = 'login' if (!useForm) { - window.location.assign(siteConfig.path + '/login/' + key) + window.location.assign(siteConfig.path + 'login/' + key) + } else { + this.$refs.iptEmail.focus() } }, - refreshStrategies() { + refreshStrategies () { + this.isLoading = true graphQL.query({ query: CONSTANTS.GRAPHQL.GQL_QUERY_AUTHENTICATION, variables: { @@ -54,19 +73,122 @@ export default { } else { throw new Error('No authentication providers available!') } + this.isLoading = false }).catch(err => { console.error(err) + this.$store.dispatch('alert', { + style: 'error', + icon: 'gg-warning', + msg: err.message + }) + this.isLoading = false }) }, - login() { - this.$store.dispatch('alert', { - style: 'error', - icon: 'gg-warning', - msg: 'Email or password is invalid' - }) + login () { + if (this.username.length < 2) { + this.$store.dispatch('alert', { + style: 'error', + icon: 'gg-warning', + msg: 'Enter a valid email / username.' + }) + this.$refs.iptEmail.focus() + } else if (this.password.length < 2) { + this.$store.dispatch('alert', { + style: 'error', + icon: 'gg-warning', + msg: 'Enter a valid password.' + }) + this.$refs.iptPassword.focus() + } else { + this.isLoading = true + graphQL.mutate({ + mutation: CONSTANTS.GRAPHQL.GQL_MUTATION_LOGIN, + variables: { + username: this.username, + password: this.password, + provider: this.selectedStrategy + } + }).then(resp => { + if (resp.data.login) { + let respObj = resp.data.login + if (respObj.succeeded === true) { + if (respObj.tfaRequired === true) { + this.screen = 'tfa' + this.securityCode = '' + this.loginToken = respObj.tfaLoginToken + this.$nextTick(() => { + this.$refs.iptTFA.focus() + }) + } else { + this.$store.dispatch('alert', { + style: 'success', + icon: 'gg-check', + msg: 'Login successful!' + }) + } + this.isLoading = false + } else { + throw new Error(respObj.message) + } + } else { + throw new Error('Authentication is unavailable.') + } + }).catch(err => { + console.error(err) + this.$store.dispatch('alert', { + style: 'error', + icon: 'gg-warning', + msg: err.message + }) + this.isLoading = false + }) + } + }, + verifySecurityCode () { + if (this.securityCode.length !== 6) { + this.$store.dispatch('alert', { + style: 'error', + icon: 'gg-warning', + msg: 'Enter a valid security code.' + }) + this.$refs.iptTFA.focus() + } else { + this.isLoading = true + graphQL.mutate({ + mutation: CONSTANTS.GRAPHQL.GQL_MUTATION_LOGINTFA, + variables: { + loginToken: this.loginToken, + securityCode: this.securityCode + } + }).then(resp => { + if (resp.data.loginTFA) { + let respObj = resp.data.loginTFA + if (respObj.succeeded === true) { + this.$store.dispatch('alert', { + style: 'success', + icon: 'gg-check', + msg: 'Login successful!' + }) + this.isLoading = false + } else { + throw new Error(respObj.message) + } + } else { + throw new Error('Authentication is unavailable.') + } + }).catch(err => { + console.error(err) + this.$store.dispatch('alert', { + style: 'error', + icon: 'gg-warning', + msg: err.message + }) + this.isLoading = false + }) + } } }, - mounted() { + mounted () { this.$store.commit('navigator/subtitleStatic', 'Login') this.refreshStrategies() this.$refs.iptEmail.focus() diff --git a/client/js/constants/graphql.js b/client/js/constants/graphql.js index e189cce7..ea6ddc3e 100644 --- a/client/js/constants/graphql.js +++ b/client/js/constants/graphql.js @@ -18,5 +18,23 @@ export default { value } } + `, + GQL_MUTATION_LOGIN: gql` + mutation($username: String!, $password: String!, $provider: String!) { + login(username: $username, password: $password, provider: $provider) { + succeeded + message + tfaRequired + tfaLoginToken + } + } + `, + GQL_MUTATION_LOGINTFA: gql` + mutation($loginToken: String!, $securityCode: String!) { + loginTFA(loginToken: $loginToken, securityCode: $securityCode) { + succeeded + message + } + } ` } diff --git a/client/scss/components/login.scss b/client/scss/components/login.scss index 104c9623..803d1e55 100644 --- a/client/scss/components/login.scss +++ b/client/scss/components/login.scss @@ -54,6 +54,15 @@ border-radius: 6px; animation: zoomIn .5s ease; + &::after { + position: absolute; + top: 1rem; + right: 1rem; + content: " "; + @include spinner(mc('blue', '500'),0.5s,16px); + display: none; + } + &.is-expanded { width: 650px; @@ -67,6 +76,10 @@ } } + &.is-loading::after { + display: block; + } + @include until($tablet) { width: 100%; border-radius: 0; @@ -264,6 +277,16 @@ } + &-tfa { + position: relative; + display: flex; + width: 400px; + align-items: stretch; + box-shadow: 0 14px 28px rgba(0,0,0,0.2); + border-radius: 6px; + animation: zoomIn .5s ease; + } + &-copyright { display: flex; align-items: center; diff --git a/server/app/data.yml b/server/app/data.yml index 4f6ef3f1..01044a63 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -53,6 +53,9 @@ configNamespaces: - site - theme - uploads +localeNamespaces: + - auth + - common queues: - gitSync - uplClearTemp diff --git a/server/extensions/authentication/local.js b/server/extensions/authentication/local.js index 8e3382dd..f712d3ae 100644 --- a/server/extensions/authentication/local.js +++ b/server/extensions/authentication/local.js @@ -17,7 +17,12 @@ module.exports = { usernameField: 'email', passwordField: 'password' }, (uEmail, uPassword, done) => { - wiki.db.User.findOne({ email: uEmail, provider: 'local' }).then((user) => { + wiki.db.User.findOne({ + where: { + email: uEmail, + provider: 'local' + } + }).then((user) => { if (user) { return user.validatePassword(uPassword).then(() => { return done(null, user) || true @@ -25,7 +30,7 @@ module.exports = { return done(err, null) }) } else { - return done(new Error('INVALID_LOGIN'), null) + return done(new wiki.Error.AuthLoginFailed(), null) } }).catch((err) => { done(err, null) diff --git a/server/helpers/error.js b/server/helpers/error.js new file mode 100644 index 00000000..67e13d4f --- /dev/null +++ b/server/helpers/error.js @@ -0,0 +1,30 @@ +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) } } + +module.exports = { + BaseError, + AuthGenericError, + AuthLoginFailed, + AuthProviderInvalid, + AuthTFAFailed, + AuthTFAInvalid, + BruteInstanceIsInvalid, + BruteTooManyAttempts, + LocaleInvalidNamespace, + UserCreationFailed +} diff --git a/server/helpers/security.js b/server/helpers/security.js index ba78a552..ab74f937 100644 --- a/server/helpers/security.js +++ b/server/helpers/security.js @@ -1,15 +1,25 @@ -'use strict' - -/* global appdata, appconfig */ - -const _ = require('lodash') +const Promise = require('bluebird') +const crypto = require('crypto') module.exports = { sanitizeCommitUser (user) { - let wlist = new RegExp('[^a-zA-Z0-9-_.\',& ' + appdata.regex.cjk + appdata.regex.arabic + ']', 'g') - return { - name: _.chain(user.name).replace(wlist, '').trim().value(), - email: appconfig.git.showUserEmail ? user.email : appconfig.git.serverEmail - } + // let wlist = new RegExp('[^a-zA-Z0-9-_.\',& ' + appdata.regex.cjk + appdata.regex.arabic + ']', 'g') + // return { + // name: _.chain(user.name).replace(wlist, '').trim().value(), + // email: appconfig.git.showUserEmail ? user.email : appconfig.git.serverEmail + // } + }, + /** + * Generate a random token + * + * @param {any} length + * @returns + */ + async generateToken (length) { + return Promise.fromCallback(clb => { + crypto.randomBytes(length, clb) + }).then(buf => { + return buf.toString('hex') + }) } } diff --git a/server/index.js b/server/index.js index 897010a8..9a5e9d30 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,7 @@ let wiki = { IS_MASTER: cluster.isMaster, ROOTPATH: process.cwd(), SERVERPATH: path.join(process.cwd(), 'server'), + Error: require('./helpers/error'), configSvc: require('./modules/config'), kernel: require('./modules/kernel') } diff --git a/server/master.js b/server/master.js index b2d188e3..c2ac6308 100644 --- a/server/master.js +++ b/server/master.js @@ -114,7 +114,17 @@ module.exports = async () => { app.use('/', ctrl.auth) - app.use('/graphql', graphqlApollo.graphqlExpress({ schema: graphqlSchema })) + app.use('/graphql', (req, res, next) => { + graphqlApollo.graphqlExpress({ + schema: graphqlSchema, + context: { req, res }, + formatError: (err) => { + return { + message: err.message + } + } + })(req, res, next) + }) app.use('/graphiql', graphqlApollo.graphiqlExpress({ endpointURL: '/graphql' })) // app.use('/uploads', mw.auth, ctrl.uploads) app.use('/admin', mw.auth, ctrl.admin) diff --git a/server/models/user.js b/server/models/user.js index 51916d07..dd7d11bb 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -1,8 +1,10 @@ -/* global wiki, appconfig */ +/* global wiki */ const Promise = require('bluebird') const bcrypt = require('bcryptjs-then') const _ = require('lodash') +const tfa = require('node-2fa') +const securityHelper = require('../helpers/security') /** * Users schema @@ -56,10 +58,108 @@ module.exports = (sequelize, DataTypes) => { ] }) - userSchema.prototype.validatePassword = function (rawPwd) { - return bcrypt.compare(rawPwd, this.password).then((isValid) => { - return (isValid) ? true : Promise.reject(new Error(wiki.lang.t('auth:errors:invalidlogin'))) + userSchema.prototype.validatePassword = async function (rawPwd) { + if (await bcrypt.compare(rawPwd, this.password) === true) { + return true + } else { + throw new wiki.Error.AuthLoginFailed() + } + } + + userSchema.prototype.enableTFA = async function () { + let tfaInfo = tfa.generateSecret({ + name: wiki.config.site.title }) + this.tfaIsActive = true + this.tfaSecret = tfaInfo.secret + return this.save() + } + + userSchema.prototype.disableTFA = async function () { + this.tfaIsActive = false + this.tfaSecret = '' + return this.save() + } + + userSchema.prototype.verifyTFA = function (code) { + let result = tfa.verifyToken(this.tfaSecret, code) + console.info(result) + return (result && _.has(result, 'delta') && result.delta === 0) + } + + userSchema.login = async (opts, context) => { + if (_.has(wiki.config.auth.strategies, opts.provider)) { + _.set(context.req, 'body.email', opts.username) + _.set(context.req, 'body.password', opts.password) + + // Authenticate + return new Promise((resolve, reject) => { + wiki.auth.passport.authenticate(opts.provider, async (err, user, info) => { + if (err) { return reject(err) } + if (!user) { return reject(new wiki.Error.AuthLoginFailed()) } + + // Is 2FA required? + if (user.tfaIsActive) { + try { + let loginToken = await securityHelper.generateToken(32) + await wiki.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600) + return resolve({ + succeeded: true, + message: 'Login Successful. Awaiting 2FA security code.', + tfaRequired: true, + tfaLoginToken: loginToken + }) + } catch (err) { + wiki.logger.warn(err) + return reject(new wiki.Error.AuthGenericError()) + } + } else { + // No 2FA, log in user + return context.req.logIn(user, err => { + if (err) { return reject(err) } + resolve({ + succeeded: true, + message: 'Login Successful', + tfaRequired: false + }) + }) + } + })(context.req, context.res, () => {}) + }) + } else { + throw new wiki.Error.AuthProviderInvalid() + } + } + + userSchema.loginTFA = async (opts, context) => { + if (opts.securityCode.length === 6 && opts.loginToken.length === 64) { + console.info(opts.loginToken) + let result = await wiki.redis.get(`tfa:${opts.loginToken}`) + console.info(result) + if (result) { + console.info('DUDE2') + let userId = _.toSafeInteger(result) + if (userId && userId > 0) { + console.info('DUDE3') + let user = await wiki.db.User.findById(userId) + if (user && user.verifyTFA(opts.securityCode)) { + console.info('DUDE4') + return Promise.fromCallback(clb => { + context.req.logIn(user, clb) + }).return({ + succeeded: true, + message: 'Login Successful' + }).catch(err => { + wiki.logger.warn(err) + throw new wiki.Error.AuthGenericError() + }) + } else { + throw new wiki.Error.AuthTFAFailed() + } + } + } + } + throw new wiki.Error.AuthTFAInvalid() } userSchema.processProfile = (profile) => { @@ -92,7 +192,7 @@ module.exports = (sequelize, DataTypes) => { new: true }).then((user) => { // Handle unregistered accounts - if (!user && profile.provider !== 'local' && (appconfig.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) { + if (!user && profile.provider !== 'local' && (wiki.config.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) { let nUsr = { email: primaryEmail, provider: profile.provider, diff --git a/server/modules/auth.js b/server/modules/auth.js index d44525cb..55f3c0df 100644 --- a/server/modules/auth.js +++ b/server/modules/auth.js @@ -13,7 +13,7 @@ module.exports = { // Serialization user methods passport.serializeUser(function (user, done) { - done(null, user._id) + done(null, user.id) }) passport.deserializeUser(function (id, done) { diff --git a/server/modules/localization.js b/server/modules/localization.js index d832935b..0d133bb2 100644 --- a/server/modules/localization.js +++ b/server/modules/localization.js @@ -9,8 +9,9 @@ const Promise = require('bluebird') module.exports = { engine: null, - namespaces: ['common', 'admin', 'auth', 'errors', 'git'], + namespaces: [], init() { + this.namespaces = wiki.data.localeNamespaces this.engine = i18next this.engine.use(i18nBackend).init({ load: 'languageOnly', @@ -21,12 +22,12 @@ module.exports = { lng: wiki.config.site.lang, fallbackLng: 'en', backend: { - loadPath: path.join(wiki.SERVERPATH, 'locales/{{lng}}/{{ns}}.json') + loadPath: path.join(wiki.SERVERPATH, 'locales/{{lng}}/{{ns}}.yml') } }) return this }, - getByNamespace(locale, namespace) { + async getByNamespace(locale, namespace) { if (this.engine.hasResourceBundle(locale, namespace)) { let data = this.engine.getResourceBundle(locale, namespace) return _.map(dotize.convert(data), (value, key) => { @@ -39,12 +40,12 @@ module.exports = { throw new Error('Invalid locale or namespace') } }, - loadLocale(locale) { + async loadLocale(locale) { return Promise.fromCallback(cb => { return this.engine.loadLanguages(locale, cb) }) }, - setCurrentLocale(locale) { + async setCurrentLocale(locale) { return Promise.fromCallback(cb => { return this.engine.changeLanguage(locale, cb) }) diff --git a/server/schemas/resolvers-user.js b/server/schemas/resolvers-user.js index 90133707..f02ddc34 100644 --- a/server/schemas/resolvers-user.js +++ b/server/schemas/resolvers-user.js @@ -19,6 +19,22 @@ module.exports = { limit: 1 }) }, + login(obj, args, context) { + return wiki.db.User.login(args, context).catch(err => { + return { + succeeded: false, + message: err.message + } + }) + }, + loginTFA(obj, args, context) { + return wiki.db.User.loginTFA(args, context).catch(err => { + return { + succeeded: false, + message: err.message + } + }) + }, modifyUser(obj, args) { return wiki.db.User.update({ email: args.email, diff --git a/server/schemas/types.graphql b/server/schemas/types.graphql index a47305b4..ce39caa4 100644 --- a/server/schemas/types.graphql +++ b/server/schemas/types.graphql @@ -148,8 +148,16 @@ type User implements Base { } type OperationResult { - succeded: Boolean! + succeeded: Boolean! message: String + data: String +} + +type LoginResult { + succeeded: Boolean! + message: String + tfaRequired: Boolean + tfaLoginToken: String } # Query (Read) @@ -249,6 +257,17 @@ type Mutation { id: Int! ): OperationResult + login( + username: String! + password: String! + provider: String! + ): LoginResult + + loginTFA( + loginToken: String! + securityCode: String! + ): OperationResult + modifyComment( id: Int! content: String!