Home Reference Source

src/auth.js

/*
 * OS.js - JavaScript Cloud/Web Desktop Platform
 *
 * Copyright (c) Anders Evenrud <andersevenrud@gmail.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * @author  Anders Evenrud <andersevenrud@gmail.com>
 * @licence Simplified BSD License
 */

const fs = require('fs-extra');
const pathLib = require('path');
const consola = require('consola');
const logger = consola.withTag('Auth');
const nullAdapter = require('./adapters/auth/null.js');

/**
 * TODO: typedef
 * @typedef {Object} AuthAdapter
 */

/**
 * Authentication User Profile
 * @typedef {Object} AuthUserProfile
 * @property {number} id
 * @property {string} username
 * @property {string} name
 * @property {string[]} groups
 */

/**
 * Authentication Service Options
 * @typedef {Object} AuthOptions
 * @property {AuthAdapter} [adapter]
 * @property {string[]} [requiredGroups]
 * @property {string[]} [denyUsers]
 */

/**
 * Authentication Handler
 */
class Auth {

  /**
   * Creates a new instance
   * @param {Core} core Core instance reference
   * @param {AuthOptions} [options={}] Service Provider arguments
   */
  constructor(core, options = {}) {
    const {requiredGroups, denyUsers} = core.configuration.auth;

    /**
     * @type {Core}
     */
    this.core = core;

    /**
     * @type {AuthOptions}
     */
    this.options = {
      adapter: nullAdapter,
      requiredGroups,
      denyUsers,
      ...options
    };

    /**
     * @type {AuthAdapter}
     */
    this.adapter = nullAdapter(core, this.options.config);

    try {
      this.adapter = this.options.adapter(core, this.options.config);
    } catch (e) {
      this.core.logger.warn(e);
    }
  }

  /**
   * Destroys instance
   */
  destroy() {
    if (this.adapter.destroy) {
      this.adapter.destroy();
    }
  }

  /**
   * Initializes adapter
   * @return {Promise<boolean>}
   */
  async init() {
    if (this.adapter.init) {
      return this.adapter.init();
    }

    return true;
  }

  /**
   * Performs a login request
   * @param {Request} req HTTP request
   * @param {Response} res HTTP response
   * @return {Promise<undefined>}
   */
  async login(req, res) {
    const result = await this.adapter.login(req, res);

    if (result) {
      const profile = this.createUserProfile(req.body, result);

      if (profile && this.checkLoginPermissions(profile)) {
        await this.createHomeDirectory(profile, req, res);
        req.session.user = profile;
        req.session.save(() => {
          this.core.emit('osjs/core:logged-in', Object.freeze({
            ...req.session
          }));

          res.status(200).json(profile);
        });
        return;
      }
    }

    res.status(403)
      .json({error: 'Invalid login or permission denied'});
  }

  /**
   * Performs a logout request
   * @param {Request} req HTTP request
   * @param {Response} res HTTP response
   * @return {Promise<undefined>}
   */
  async logout(req, res) {
    this.core.emit('osjs/core:logging-out', Object.freeze({
      ...req.session
    }));

    await this.adapter.logout(req, res);

    try {
      req.session.destroy();
    } catch (e) {
      logger.warn(e);
    }

    res.json({});
  }

  /**
   * Performs a register request
   * @param {Request} req HTTP request
   * @param {Response} res HTTP response
   * @return {Promise<undefined>}
   */
  async register(req, res) {
    if (this.adapter.register) {
      const result = await this.adapter.register(req, res);

      return res.json(result);
    }

    return res.status(403)
      .json({error: 'Registration unavailable'});
  }

  /**
   * Checks if login is allowed for this user
   * @param {AuthUserProfile} profile User profile
   * @return {boolean}
   */
  checkLoginPermissions(profile) {
    const {requiredGroups, denyUsers} = this.options;

    if (denyUsers.indexOf(profile.username) !== -1) {
      return false;
    }

    if (requiredGroups.length > 0) {
      const passes = requiredGroups.every(name => {
        return profile.groups.indexOf(name) !== -1;
      });

      return passes;
    }

    return true;
  }

  /**
   * Creates user profile object
   * @param {object} fields Input fields
   * @param {object} result Login result
   * @return {AuthUserProfile|boolean}
   */
  createUserProfile(fields, result) {
    const ignores = ['password'];
    const required = ['username', 'id'];
    const template = {
      id: 0,
      username: fields.username,
      name: fields.username,
      groups: this.core.config('auth.defaultGroups', [])
    };

    const missing = required
      .filter(k => typeof result[k] === 'undefined');

    if (missing.length) {
      logger.warn('Missing user attributes', missing);
    } else {
      const values = Object.keys(result)
        .filter(k => ignores.indexOf(k) === -1)
        .reduce((o, k) => ({...o, [k]: result[k]}), {});

      return {...template, ...values};
    }

    return false;
  }

  /**
   * Tries to create home directory for a user
   * @param {AuthUserProfile} profile User profile
   * @return {Promise<undefined>}
   */
  async createHomeDirectory(profile) {
    const vfs = this.core.make('osjs/vfs');
    const template = this.core.config('vfs.home.template', []);

    if (typeof template === 'string') {
      // If the template is a string, it is a path to a directory
      // that should be copied to the user's home directory
      const root = await vfs.realpath('home:/', profile);

      await fs.copy(template, root, {overwrite: false});
    } else if (Array.isArray(template)) {
      await this.createHomeDirectoryFromArray(template, vfs, profile);
    }
  }

  /**
   * If the template is an array, it is a list of files that should be copied
   * to the user's home directory
   * @param {Object[]} template Array of objects with a specified path,
   * optionally with specified content but defaulting to an empty string
   * @param {VFSServiceProvider} vfs An instance of the virtual file system
   * @param {AuthUserProfile} profile User profile
   */
  async createHomeDirectoryFromArray(template, vfs, profile) {
    for (const file of template) {
      try {
        const {path, contents = ''} = file;
        const shortcutsFile = await vfs.realpath(`home:/${path}`, profile);
        const dir = pathLib.dirname(shortcutsFile);

        if (!await fs.pathExists(shortcutsFile)) {
          await fs.ensureDir(dir);
          await fs.writeFile(shortcutsFile, contents);
        }
      } catch (e) {
        console.warn(`There was a problem writing '${file.path}' to the home directory template`);
        console.error('ERROR:', e);
      }
    }
  }
}

module.exports = Auth;