Browse Source

feat: login + TFA authentication

NGPixel 7 years ago
parent
commit
cb0d86906f

+ 141 - 19
client/js/components/login.vue

@@ -1,20 +1,29 @@
 <template lang="pug">
   .login(:class='{ "is-error": error }')
-    .login-container(:class='{ "is-expanded": strategies.length > 1 }')
+    .login-container(:class='{ "is-expanded": strategies.length > 1, "is-loading": isLoading }')
       .login-providers(v-show='strategies.length > 1')
         button(v-for='strategy in strategies', :class='{ "is-active": strategy.key === selectedStrategy }', @click='selectStrategy(strategy.key, strategy.useForm)', :title='strategy.title')
           em(v-html='strategy.icon')
           span {{ strategy.title }}
         .login-providers-fill
-      .login-frame
+      .login-frame(v-show='screen === "login"')
         h1 {{ siteTitle }}
-        h2 {{ $t('auth:loginrequired') }}
-        input(type='text', ref='iptEmail', :placeholder='$t("auth:fields.emailuser")')
-        input(type='password', ref='iptPassword', :placeholder='$t("auth:fields.password")')
+        h2 {{ $t('auth:loginRequired') }}
+        input(type='text', ref='iptEmail', v-model='username', :placeholder='$t("auth:fields.emailUser")')
+        input(type='password', ref='iptPassword', v-model='password', :placeholder='$t("auth:fields.password")', @keyup.enter='login')
         button.button.is-blue.is-fullwidth(@click='login')
           span {{ $t('auth:actions.login') }}
+      .login-frame(v-show='screen === "tfa"')
+        .login-frame-icon
+          svg.icons.is-48(role='img')
+            title {{ $t('auth:tfa.title') }}
+            use(xlink:href='#nc-key')
+        h2 {{ $t('auth:tfa.subtitle') }}
+        input(type='text', ref='iptTFA', v-model='securityCode', :placeholder='$t("auth:tfa.placeholder")', @keyup.enter='verifySecurityCode')
+        button.button.is-blue.is-fullwidth(@click='verifySecurityCode')
+          span {{ $t('auth:tfa.verifyToken') }}
     .login-copyright
-      span {{ $t('footer.poweredby') }}
+      span {{ $t('footer.poweredBy') }}
       a(href='https://wiki.js.org', rel='external', title='Wiki.js') Wiki.js
 </template>
 
@@ -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()

+ 18 - 0
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
+      }
+    }
   `
 }

+ 23 - 0
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;

+ 3 - 0
server/app/data.yml

@@ -53,6 +53,9 @@ configNamespaces:
   - site
   - theme
   - uploads
+localeNamespaces:
+  - auth
+  - common
 queues:
   - gitSync
   - uplClearTemp

+ 7 - 2
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)

+ 30 - 0
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
+}

+ 20 - 10
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')
+    })
   }
 }

+ 1 - 0
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')
 }

+ 11 - 1
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)

+ 105 - 5
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,

+ 1 - 1
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) {

+ 6 - 5
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)
     })

+ 16 - 0
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,

+ 20 - 1
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!