auth.js 11 KB
Newer Older
1
const passport = require('passport')
2
const passportJWT = require('passport-jwt')
3
const _ = require('lodash')
4 5
const jwt = require('jsonwebtoken')
const moment = require('moment')
6 7 8
const Promise = require('bluebird')
const crypto = Promise.promisifyAll(require('crypto'))
const pem2jwk = require('pem-jwk').pem2jwk
9

10
const securityHelper = require('../helpers/security')
11 12

/* global WIKI */
13

NGPixel's avatar
NGPixel committed
14
module.exports = {
15
  strategies: {},
16 17 18
  guest: {
    cacheExpiration: moment.utc().subtract(1, 'd')
  },
19
  groups: {},
20
  validApiKeys: [],
21 22 23 24

  /**
   * Initialize the authentication module
   */
25 26 27
  init() {
    this.passport = passport

28
    passport.serializeUser((user, done) => {
29
      done(null, user.id)
NGPixel's avatar
NGPixel committed
30
    })
31

32 33
    passport.deserializeUser(async (id, done) => {
      try {
NGPixel's avatar
NGPixel committed
34
        const user = await WIKI.models.users.query().findById(id).withGraphFetched('groups').modifyGraph('groups', builder => {
35 36
          builder.select('groups.id', 'permissions')
        })
37 38 39 40 41
        if (user) {
          done(null, user)
        } else {
          done(new Error(WIKI.lang.t('auth:errors:usernotfound')), null)
        }
42
      } catch (err) {
43
        done(err, null)
44
      }
45 46
    })

47
    this.reloadGroups()
48
    this.reloadApiKeys()
49

50
    return this
51
  },
52 53 54 55

  /**
   * Load authentication strategies
   */
56 57 58
  async activateStrategies() {
    try {
      // Unload any active strategies
59
      WIKI.auth.strategies = {}
60 61 62 63
      const currentStrategies = _.keys(passport._strategies)
      _.pull(currentStrategies, 'session')
      _.forEach(currentStrategies, stg => { passport.unuse(stg) })

64 65 66
      // Load JWT
      passport.use('jwt', new passportJWT.Strategy({
        jwtFromRequest: securityHelper.extractJWT,
67
        secretOrKey: WIKI.config.certs.public,
68
        audience: WIKI.config.auth.audience,
69 70
        issuer: 'urn:wiki.js',
        algorithms: ['RS256']
71 72 73 74
      }, (jwtPayload, cb) => {
        cb(null, jwtPayload)
      }))

75
      // Load enabled strategies
76
      const enabledStrategies = await WIKI.models.authentication.getStrategies()
77 78
      for (let idx in enabledStrategies) {
        const stg = enabledStrategies[idx]
79 80
        if (!stg.isEnabled) { continue }

81
        const strategy = require(`../modules/authentication/${stg.key}/authentication.js`)
82

83
        stg.config.callbackURL = `${WIKI.config.host}/login/${stg.key}/callback`
84
        strategy.init(passport, stg.config)
85
        strategy.config = stg.config
86

87 88 89
        WIKI.auth.strategies[stg.key] = {
          ...strategy,
          ...stg
Nick's avatar
Nick committed
90
        }
91
        WIKI.logger.info(`Authentication Strategy ${stg.key}: [ OK ]`)
92 93 94 95 96
      }
    } catch (err) {
      WIKI.logger.error(`Authentication Strategy: [ FAILED ]`)
      WIKI.logger.error(err)
    }
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
  },

  /**
   * Authenticate current request
   *
   * @param {Express Request} req
   * @param {Express Response} res
   * @param {Express Next Callback} next
   */
  authenticate(req, res, next) {
    WIKI.auth.passport.authenticate('jwt', {session: false}, async (err, user, info) => {
      if (err) { return next() }

      // Expired but still valid within N days, just renew
      if (info instanceof Error && info.name === 'TokenExpiredError' && moment().subtract(14, 'days').isBefore(info.expiredAt)) {
        const jwtPayload = jwt.decode(securityHelper.extractJWT(req))
        try {
          const newToken = await WIKI.models.users.refreshToken(jwtPayload.id)
          user = newToken.user
116
          user.permissions = user.getGlobalPermissions()
117
          req.user = user
118 119 120 121 122 123 124

          // Try headers, otherwise cookies for response
          if (req.get('content-type') === 'application/json') {
            res.set('new-jwt', newToken.token)
          } else {
            res.cookie('jwt', newToken.token, { expires: moment().add(365, 'days').toDate() })
          }
Nick's avatar
Nick committed
125 126
        } catch (errc) {
          WIKI.logger.warn(errc)
127 128 129 130 131 132
          return next()
        }
      }

      // JWT is NOT valid, set as guest
      if (!user) {
133
        if (WIKI.auth.guest.cacheExpiration.isSameOrBefore(moment.utc())) {
134 135 136 137 138 139 140
          WIKI.auth.guest = await WIKI.models.users.getGuestUser()
          WIKI.auth.guest.cacheExpiration = moment.utc().add(1, 'm')
        }
        req.user = WIKI.auth.guest
        return next()
      }

141 142
      // Process API tokens
      if (_.has(user, 'api')) {
143 144 145
        if (!WIKI.config.api.isEnabled) {
          return next(new Error('API is disabled. You must enable it from the Administration Area first.'))
        } else if (_.includes(WIKI.auth.validApiKeys, user.api)) {
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
          req.user = {
            id: 1,
            email: 'api@localhost',
            name: 'API',
            pictureUrl: null,
            timezone: 'America/New_York',
            localeCode: 'en',
            permissions: _.get(WIKI.auth.groups, `${user.grp}.permissions`, []),
            groups: [user.grp],
            getGlobalPermissions () {
              return req.user.permissions
            },
            getGroups () {
              return req.user.groups
            }
          }
          return next()
        } else {
          return next(new Error('API Key is invalid or was revoked.'))
        }
      }

168
      // JWT is valid
Nick's avatar
Nick committed
169 170
      req.logIn(user, { session: false }, (errc) => {
        if (errc) { return next(errc) }
171 172 173 174 175 176 177 178 179 180 181 182
        next()
      })
    })(req, res, next)
  },

  /**
   * Check if user has access to resource
   *
   * @param {User} user
   * @param {Array<String>} permissions
   * @param {String|Boolean} path
   */
183
  checkAccess(user, permissions = [], page = false) {
184 185
    const userPermissions = user.permissions ? user.permissions : user.getGlobalPermissions()

186
    // System Admin
187
    if (_.includes(userPermissions, 'manage:system')) {
188 189 190 191
      return true
    }

    // Check Global Permissions
192
    if (_.intersection(userPermissions, permissions).length < 1) {
193 194 195 196
      return false
    }

    // Check Page Rules
NGPixel's avatar
NGPixel committed
197
    if (page && user.groups) {
198 199 200 201 202 203 204 205
      let checkState = {
        deny: false,
        match: false,
        specificity: ''
      }
      user.groups.forEach(grp => {
        const grpId = _.isObject(grp) ? _.get(grp, 'id', 0) : grp
        _.get(WIKI.auth.groups, `${grpId}.pageRules`, []).forEach(rule => {
NGPixel's avatar
NGPixel committed
206
          if (_.intersection(rule.roles, permissions).length > 0) {
207 208 209 210
            switch (rule.match) {
              case 'START':
                if (_.startsWith(`/${page.path}`, `/${rule.path}`)) {
                  checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['END', 'REGEX', 'EXACT', 'TAG'] })
211
                }
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
                break
              case 'END':
                if (_.endsWith(page.path, rule.path)) {
                  checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['REGEX', 'EXACT', 'TAG'] })
                }
                break
              case 'REGEX':
                const reg = new RegExp(rule.path)
                if (reg.test(page.path)) {
                  checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: ['EXACT', 'TAG'] })
                }
                break
              case 'TAG':
                _.get(page, 'tags', []).forEach(tag => {
                  if (tag.tag === rule.path) {
                    checkState = this._applyPageRuleSpecificity({
                      rule,
                      checkState,
                      higherPriority: ['EXACT']
                    })
                  }
                })
                break
              case 'EXACT':
                if (`/${page.path}` === `/${rule.path}`) {
                  checkState = this._applyPageRuleSpecificity({ rule, checkState, higherPriority: [] })
                }
                break
            }
241 242 243 244 245 246 247
          }
        })
      })

      return (checkState.match && !checkState.deny)
    }

248
    return false
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280
  },

  /**
   * Check and apply Page Rule specificity
   *
   * @access private
   */
  _applyPageRuleSpecificity ({ rule, checkState, higherPriority = [] }) {
    if (rule.path.length === checkState.specificity.length) {
      // Do not override higher priority rules
      if (_.includes(higherPriority, checkState.match)) {
        return checkState
      }
      // Do not override a previous DENY rule with same match
      if (rule.match === checkState.match && checkState.deny && !rule.deny) {
        return checkState
      }
    } else if (rule.path.length < checkState.specificity.length) {
      // Do not override higher specificity rules
      return checkState
    }

    return {
      deny: rule.deny,
      match: rule.match,
      specificity: rule.path
    }
  },

  /**
   * Reload Groups from DB
   */
281
  async reloadGroups () {
282 283
    const groupsArray = await WIKI.models.groups.query()
    this.groups = _.keyBy(groupsArray, 'id')
284
    WIKI.auth.guest.cacheExpiration = moment.utc().subtract(1, 'd')
285 286
  },

287 288 289 290 291 292 293 294
  /**
   * Reload valid API Keys from DB
   */
  async reloadApiKeys () {
    const keys = await WIKI.models.apiKeys.query().select('id').where('isRevoked', false).andWhere('expiration', '>', moment.utc().toISOString())
    this.validApiKeys = _.map(keys, 'id')
  },

295 296 297
  /**
   * Generate New Authentication Public / Private Key Certificates
   */
298
  async regenerateCertificates () {
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327
    WIKI.logger.info('Regenerating certificates...')

    _.set(WIKI.config, 'sessionSecret', (await crypto.randomBytesAsync(32)).toString('hex'))
    const certs = crypto.generateKeyPairSync('rsa', {
      modulusLength: 2048,
      publicKeyEncoding: {
        type: 'pkcs1',
        format: 'pem'
      },
      privateKeyEncoding: {
        type: 'pkcs1',
        format: 'pem',
        cipher: 'aes-256-cbc',
        passphrase: WIKI.config.sessionSecret
      }
    })

    _.set(WIKI.config, 'certs', {
      jwk: pem2jwk(certs.publicKey),
      public: certs.publicKey,
      private: certs.privateKey
    })

    await WIKI.configSvc.saveToDb([
      'certs',
      'sessionSecret'
    ])

    await WIKI.auth.activateStrategies()
328
    WIKI.events.outbound.emit('reloadAuthStrategies')
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360

    WIKI.logger.info('Regenerated certificates: [ COMPLETED ]')
  },

  /**
   * Reset Guest User
   */
  async resetGuestUser() {
    WIKI.logger.info('Resetting guest account...')
    const guestGroup = await WIKI.models.groups.query().where('id', 2).first()

    await WIKI.models.users.query().delete().where({
      providerKey: 'local',
      email: 'guest@example.com'
    }).orWhere('id', 2)

    const guestUser = await WIKI.models.users.query().insert({
      id: 2,
      provider: 'local',
      email: 'guest@example.com',
      name: 'Guest',
      password: '',
      locale: 'en',
      defaultEditor: 'markdown',
      tfaIsActive: false,
      isSystem: true,
      isActive: true,
      isVerified: true
    })
    await guestUser.$relatedQuery('groups').relate(guestGroup.id)

    WIKI.logger.info('Guest user has been reset: [ COMPLETED ]')
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
  },

  /**
   * Subscribe to HA propagation events
   */
  subscribeToEvents() {
    WIKI.events.inbound.on('reloadGroups', () => {
      WIKI.auth.reloadGroups()
    })
    WIKI.events.inbound.on('reloadApiKeys', () => {
      WIKI.auth.reloadApiKeys()
    })
    WIKI.events.inbound.on('reloadAuthStrategies', () => {
      WIKI.auth.activateStrategies()
    })
NGPixel's avatar
NGPixel committed
376
  }
377
}