// otp-auth.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0

'use strict';

// const striptags = require('striptags');

const mongoose = require('mongoose');
const OtpAccount = mongoose.model('OtpAccount');

const ONE_HOUR = 1000 * 60 * 60;
const OTP_SESSION_DURATION = (process.env.NODE_ENV === 'local') ? (ONE_HOUR * 24) : (ONE_HOUR * 8);

const { authenticator } = require('otplib');
const uuidv4 = require('uuid').v4;

const { SiteService, SiteError } = require('../../lib/site-lib');

class OtpAuthService extends SiteService {

  constructor (dtp) {
    super(dtp, module.exports);
    authenticator.options = {
      algorithm: 'sha1',
      step: 30,
      digits: 6,
    };
  }

  middleware (serviceName, options) {
    options = Object.assign({
      otpRequired: false,
      otpRedirectURL: '/',
      adminRequired: false,
    }, options);
    return async (req, res, next) => {
      res.locals.otp = { }; // will decorate view model with OTP information
      if (!req.session) {
        return next(new SiteError(403, 'Request session is invalid'));
      }
      if (!req.user) {
        return next(new SiteError(403, 'Must be logged in'));
      }
      if (options.adminRequired && !req.user.flags.isAdmin) {
        return next(new SiteError(403, 'Admin privileges are required'));
      }

      req.session.otp = req.session.otp || { };
      if (await this.checkOtpSession(req, serviceName)) {
        return next(); // user is OTP-authenticated on this service
      }

      res.locals.otpOptions = authenticator.options;
      res.locals.otpServiceName = serviceName;
      res.locals.otpAlgorithm = authenticator.options.algorithm.toUpperCase();
      res.locals.otpDigits = authenticator.options.digits;
      res.locals.otpPeriod = authenticator.options.step;

      if (typeof options.otpRedirectURL === 'function') {
        // allows redirect to things like /user/:userId using current session's user
        res.locals.otpRedirectURL = await options.otpRedirectURL(req, res);
      } else {
        res.locals.otpRedirectURL = options.otpRedirectURL;
      }
      
      res.locals.otpAccount = await OtpAccount
        .findOne({
          user: req.user._id,
          service: serviceName,
        });
      if (!res.locals.otpAccount && !options.otpRequired) {
        return next(); // route not guarded (am I a joke to you?)
      }

      if (!res.locals.otpAccount) {
        let issuer;
        if (process.env.NODE_ENV === 'production') {
          issuer = `${this.dtp.config.site.name}: ${serviceName}`;
        } else {
          issuer = `${this.dtp.config.site.name}:${process.env.NODE_ENV}: ${serviceName}`;
        }
        res.locals.otpTempSecret = authenticator.generateSecret();
        res.locals.otpKeyURI = authenticator.keyuri(req.user.username.trim(), issuer, res.locals.otpTempSecret);
        req.session.otp[serviceName] = req.session.otp[serviceName] || { };
        req.session.otp[serviceName].secret = res.locals.otpTempSecret;
        req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL;
        return res.render('otp/welcome');
      }

      res.locals.otpSession = req.session.otp[serviceName];

      this.log.debug('request on OTP-required route with no authentication', {
        service: serviceName,
        session: res.locals.otpSession,
      }, req.user);

      req.session.otp[serviceName] = req.session.otp[serviceName] || { };
      req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL;
      await this.saveSession(req);

      if (!res.locals.otpSession || !res.locals.otpSession.isAuthenticated) {
        return res.render('otp/authenticate');
      }

      return next();
    };
  }

  async createOtpAccount (req, service, secret, passcode) {
    const NOW = new Date();
    const { crypto: cryptoService } = this.dtp.services;
    try {
      this.log.info('verifying user passcode', {
        user: req.user._id,
        username: req.user.username,
        service, secret, passcode,
      }, req.user);
      if (authenticator.check(passcode, secret)) {
        throw new SiteError(403, 'Invalid passcode');
      }

      const backupTokens = [ ];
      for (let i = 0; i < 10; ++i) {
        backupTokens.push({
          token: cryptoService.createHash(secret + uuidv4()),
        });
      }

      const now = new Date();
      const account = await OtpAccount.create({
        created: NOW,
        user: req.user._id,
        service,
        secret,
        algorithm: authenticator.options.algorithm,
        step: authenticator.options.step,
        digits: authenticator.options.digits,
        backupTokens,
        lastVerification: now,
        lastVerificationIp: req.ip,
      });

      return account;
    } catch (error) {
      this.log.error('failed to create OTP account', {
        service, secret, passcode, error,
      }, req.user);
      throw error;
    }
  }

  async startOtpSession (req, serviceName, passcode) {
    if (!passcode || (typeof passcode !== 'string')) {
      throw new SiteError(403, 'Invalid passcode');
    }
    try {
      const account = await OtpAccount
        .findOne({ user: req.user._id, service: serviceName })
        .select('+secret')
        .lean();
      if (!account) {
        throw new SiteError(400, 'Two-Factor Authentication not enabled');
      }

      const now = new Date();
      if (!authenticator.check(passcode, account.secret)) {
        throw new SiteError(403, 'Invalid passcode');
      }

      req.session.otp = req.session.otp || { };
      req.session.otp[serviceName] = req.session.otp[serviceName] || { };
      req.session.otp[serviceName].isAuthenticated = true;
      req.session.otp[serviceName].expiresAt = now.valueOf() + OTP_SESSION_DURATION;
      await this.saveSession(req);
    } catch (error) {
      this.log.error('failed to start OTP session', {
        serviceName, passcode, error,
      });
      throw error;
    }
  }

  async checkOtpSession (req, serviceName) {
    if (!req.session || !req.session.otp) {
      return false;
    }

    const session = req.session.otp[serviceName];
    if (!session) {
      return false;
    }

    if (!session.isAuthenticated) {
      return false;
    }

    const NOW = Date.now();
    if (NOW >= session.expiresAt) {
      session.isAuthenticated = false;
      delete session.expiresAt;
      await this.saveSession(req);
      return false;
    }

    session.expiresAt = NOW + OTP_SESSION_DURATION;
    await this.saveSession(req);

    return true;
  }

  async destroyOtpSession (req, serviceName) {
    delete req.session.otp[serviceName];
    await this.saveSession(req);
  }

  async isUserProtected (user, serviceName) {
    const account = await OtpAccount.findOne({ user: user._id, service: serviceName });
    if (!account) {
      return false;
    }
    return true;
  }

  async removeForUser (user, serviceName) {
    return await OtpAccount.findOneAndDelete({ user: user, service: serviceName });
  }

  async getBackupTokens (user, serviceName) {
    const tokens = await OtpAccount.findOne({ user: user._id, service: serviceName })
    .select('+backupTokens')
    .lean();
    return tokens.backupTokens;
  }
}

module.exports = {
  slug: 'otp-auth',
  name: 'otpAuth',
  create: (dtp) => { return new OtpAuthService(dtp); },
};
