users.js 8.37 KB
Newer Older
1 2 3 4 5
/* global WIKI */

const bcrypt = require('bcryptjs-then')
const _ = require('lodash')
const tfa = require('node-2fa')
6
const securityHelper = require('../helpers/security')
7
const jwt = require('jsonwebtoken')
8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
const Model = require('objection').Model

const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/

/**
 * Users model
 */
module.exports = class User extends Model {
  static get tableName() { return 'users' }

  static get jsonSchema () {
    return {
      type: 'object',
      required: ['email', 'name', 'provider'],

      properties: {
        id: {type: 'integer'},
        email: {type: 'string', format: 'email'},
        name: {type: 'string', minLength: 1, maxLength: 255},
        providerId: {type: 'number'},
        password: {type: 'string'},
        role: {type: 'string', enum: ['admin', 'guest', 'user']},
        tfaIsActive: {type: 'boolean', default: false},
        tfaSecret: {type: 'string'},
32 33 34
        jobTitle: {type: 'string'},
        location: {type: 'string'},
        pictureUrl: {type: 'string'},
35 36 37 38 39 40 41 42 43 44
        createdAt: {type: 'string'},
        updatedAt: {type: 'string'}
      }
    }
  }

  static get relationMappings() {
    return {
      groups: {
        relation: Model.ManyToManyRelation,
45
        modelClass: require('./groups'),
46 47 48 49 50 51 52 53
        join: {
          from: 'users.id',
          through: {
            from: 'userGroups.userId',
            to: 'userGroups.groupId'
          },
          to: 'groups.id'
        }
NGPixel's avatar
NGPixel committed
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
      },
      provider: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./authentication'),
        join: {
          from: 'users.providerKey',
          to: 'authentication.key'
        }
      },
      defaultEditor: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./editors'),
        join: {
          from: 'users.editorKey',
          to: 'editors.key'
        }
      },
      locale: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./locales'),
        join: {
          from: 'users.localeCode',
          to: 'locales.code'
        }
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
      }
    }
  }

  async $beforeUpdate(opt, context) {
    await super.$beforeUpdate(opt, context)

    this.updatedAt = new Date().toISOString()

    if (!(opt.patch && this.password === undefined)) {
      await this.generateHash()
    }
  }
  async $beforeInsert(context) {
    await super.$beforeInsert(context)

    this.createdAt = new Date().toISOString()
    this.updatedAt = new Date().toISOString()

    await this.generateHash()
  }

  async generateHash() {
    if (this.password) {
      if (bcryptRegexp.test(this.password)) { return }
      this.password = await bcrypt.hash(this.password, 12)
    }
  }

  async verifyPassword(pwd) {
108
    if (await bcrypt.compare(pwd, this.password) === true) {
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
      return true
    } else {
      throw new WIKI.Error.AuthLoginFailed()
    }
  }

  async enableTFA() {
    let tfaInfo = tfa.generateSecret({
      name: WIKI.config.site.title
    })
    return this.$query.patch({
      tfaIsActive: true,
      tfaSecret: tfaInfo.secret
    })
  }

  async disableTFA() {
    return this.$query.patch({
      tfaIsActive: false,
      tfaSecret: ''
    })
  }

  async verifyTFA(code) {
    let result = tfa.verifyToken(this.tfaSecret, code)
    return (result && _.has(result, 'delta') && result.delta === 0)
  }

  static async processProfile(profile) {
    let primaryEmail = ''
    if (_.isArray(profile.emails)) {
      let e = _.find(profile.emails, ['primary', true])
      primaryEmail = (e) ? e.value : _.first(profile.emails).value
    } else if (_.isString(profile.email) && profile.email.length > 5) {
      primaryEmail = profile.email
    } else if (_.isString(profile.mail) && profile.mail.length > 5) {
      primaryEmail = profile.mail
    } else if (profile.user && profile.user.email && profile.user.email.length > 5) {
      primaryEmail = profile.user.email
    } else {
      return Promise.reject(new Error(WIKI.lang.t('auth:errors.invaliduseremail')))
    }

    profile.provider = _.lowerCase(profile.provider)
    primaryEmail = _.toLower(primaryEmail)

155
    let user = await WIKI.models.users.query().findOne({
156 157 158 159 160 161 162 163 164 165 166
      email: primaryEmail,
      provider: profile.provider
    })
    if (user) {
      user.$query().patchAdnFetch({
        email: primaryEmail,
        provider: profile.provider,
        providerId: profile.id,
        name: profile.displayName || _.split(primaryEmail, '@')[0]
      })
    } else {
167
      user = await WIKI.models.users.query().insertAndFetch({
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
        email: primaryEmail,
        provider: profile.provider,
        providerId: profile.id,
        name: profile.displayName || _.split(primaryEmail, '@')[0]
      })
    }

    // Handle unregistered accounts
    // if (!user && profile.provider !== 'local' && (WIKI.config.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) {
    //   let nUsr = {
    //     email: primaryEmail,
    //     provider: profile.provider,
    //     providerId: profile.id,
    //     password: '',
    //     name: profile.displayName || profile.name || profile.cn,
    //     rights: [{
    //       role: 'read',
    //       path: '/',
    //       exact: false,
    //       deny: false
    //     }]
    //   }
190
    //   return WIKI.models.users.query().insert(nUsr)
191 192 193 194 195 196
    // }

    return user
  }

  static async login (opts, context) {
NGPixel's avatar
NGPixel committed
197
    if (_.has(WIKI.auth.strategies, opts.strategy)) {
198 199 200 201 202
      _.set(context.req, 'body.email', opts.username)
      _.set(context.req, 'body.password', opts.password)

      // Authenticate
      return new Promise((resolve, reject) => {
203
        WIKI.auth.passport.authenticate(opts.strategy, { session: false }, async (err, user, info) => {
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
          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({
                tfaRequired: true,
                tfaLoginToken: loginToken
              })
            } catch (err) {
              WIKI.logger.warn(err)
              return reject(new WIKI.Error.AuthGenericError())
            }
          } else {
            // No 2FA, log in user
222
            return context.req.logIn(user, { session: false }, async err => {
223
              if (err) { return reject(err) }
224
              const jwtToken = await WIKI.models.users.refreshToken(user)
225
              resolve({
226
                jwt: jwtToken.token,
227 228 229 230 231 232 233 234 235 236 237
                tfaRequired: false
              })
            })
          }
        })(context.req, context.res, () => {})
      })
    } else {
      throw new WIKI.Error.AuthProviderInvalid()
    }
  }

238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254
  static async refreshToken(user) {
    if (_.isSafeInteger(user)) {
      user = await WIKI.models.users.query().findById(user)
      if (!user) {
        WIKI.logger.warn(`Failed to refresh token for user ${user}: Not found.`)
        throw new WIKI.Error.AuthGenericError()
      }
    }
    return {
      token: jwt.sign({
        id: user.id,
        email: user.email,
        name: user.name,
        pictureUrl: user.pictureUrl,
        timezone: user.timezone,
        localeCode: user.localeCode,
        defaultEditor: user.defaultEditor,
Nicolas Giard's avatar
Nicolas Giard committed
255
        permissions: ['manage:system']
256
      }, WIKI.config.sessionSecret, {
Nicolas Giard's avatar
Nicolas Giard committed
257
        expiresIn: '30m',
258 259 260 261 262 263 264
        audience: 'urn:wiki.js', // TODO: use value from admin
        issuer: 'urn:wiki.js'
      }),
      user
    }
  }

265 266 267 268 269 270
  static async loginTFA(opts, context) {
    if (opts.securityCode.length === 6 && opts.loginToken.length === 64) {
      let result = await WIKI.redis.get(`tfa:${opts.loginToken}`)
      if (result) {
        let userId = _.toSafeInteger(result)
        if (userId && userId > 0) {
271
          let user = await WIKI.models.users.query().findById(userId)
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
          if (user && user.verifyTFA(opts.securityCode)) {
            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()
  }
}