### Copyright 2018 Pixar
###
###    Licensed under the Apache License, Version 2.0 (the "Apache License")
###    with the following modification; you may not use this file except in
###    compliance with the Apache License and the following modification to it:
###    Section 6. Trademarks. is deleted and replaced with:
###
###    6. Trademarks. This License does not grant permission to use the trade
###       names, trademarks, service marks, or product names of the Licensor
###       and its affiliates, except as required to comply with Section 4(c) of
###       the License and to reproduce the content of the NOTICE file.
###
###    You may obtain a copy of the Apache License at
###
###        http://www.apache.org/licenses/LICENSE-2.0
###
###    Unless required by applicable law or agreed to in writing, software
###    distributed under the Apache License with the above modification is
###    distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
###    KIND, either express or implied. See the Apache License for the specific
###    language governing permissions and limitations under the Apache License.
###
###

###
module D3
  module Admin

    ### This module contains methods for dealing with d3 admin authentication
    ### getting, & storing the passwords needed to connect to the JSS and the
    ### database as a d3 admin.
    ###
    module Auth
      extend self

      ################# Module Constants #################

      RW_CREDENTIAL_KINDS = [:jss, :db, :dist]

      KEYCHAIN_SERVICE_BASE  = "d3admin"
      KEYCHAIN_LABEL_BASE = "com.pixar.d3.admin"

      KEYCHAIN_JSS_SERVICE = KEYCHAIN_SERVICE_BASE + ".jss-api"
      KEYCHAIN_JSS_LABEL = KEYCHAIN_LABEL_BASE + ".jss-api"

      KEYCHAIN_DB_SERVICE = KEYCHAIN_SERVICE_BASE + ".db"
      KEYCHAIN_DB_LABEL = KEYCHAIN_LABEL_BASE + ".db"

      KEYCHAIN_DIST_ACCT = "defined_in_jss"
      KEYCHAIN_DIST_SERVICE = KEYCHAIN_SERVICE_BASE + ".distribution"
      KEYCHAIN_DIST_LABEL = KEYCHAIN_LABEL_BASE + ".distribution"

      @@connected = false


      ### Connect to the JSS API and MySQL DB
      ### with admin credentials from the keychain
      ###
      ### @return [String] the JSS hostname to which the connection was made
      ###
      def connect (alt_db = false)
        api = rw_credentials :jss
        db = rw_credentials :db

        JSS::DB_CNX.connect :user => db[:user], :pw => db[:password], :connect_timeout => 10
        JSS::API.connect :user => api[:user], :pw => api[:password], :open_timeout => 10
        D3::Database.check_schema_version

        @@connected = true

        return JSS::API.cnx.options[:server]
      end

      ### Disconnect admin credentials from the JSS API and MySQL DB
      ###
      ### @return [void]
      ###
      def disconnect
        JSS::API.disconnect if JSS::API.connected?
        JSS::DB_CNX.disconnect if JSS::DB_CNX.connected?
        @@connected = false
      end

      ### Are we currently connected as an admin?
      ###
      ### return [Boolean]
      ###
      def connected?
        @@connected
      end


      ### Fetch read-write credentials from the login keychain
      ###
      ### If the login keychain is locked, the user will be prompted
      ### to unlock it in the GUI.
      ###
      ### @param kind[Symbol] which kind of credentials? :jss, :db, or :dist
      ###
      ### @param checking_for_existence[Boolean] Are we just checking to see if
      ###   this value has been set? If so, and it hasn't, don't prompt for
      ###   saving, just return an empty hash.
      ###
      ### @return [Hash{Symbol => String}] A Hash with :user and :password values.
      ###   or empty if unset and checking for existence.
      ###
      def rw_credentials(kind, checking_for_existence = false)
        Keychain.user_interaction_allowed = true
        unlock_keychain
        search_conditions = case kind
        when :jss
          {:service => KEYCHAIN_JSS_SERVICE, :label => KEYCHAIN_JSS_LABEL}
        when :db
          {:service => KEYCHAIN_DB_SERVICE, :label => KEYCHAIN_DB_LABEL}
        when :dist
          {:service => KEYCHAIN_DIST_SERVICE, :account => KEYCHAIN_DIST_ACCT, :label => KEYCHAIN_DIST_LABEL}
        else
          raise JSS::InvalidDataError, "argument must be one of :#{RW_CREDENTIAL_KINDS.join ', :'}"
        end #pw_item = case kind

        pw_item = Keychain.default.generic_passwords.where(search_conditions).first
        return {} if pw_item.nil? and checking_for_existence

        if pw_item
          return {:user => pw_item.account, :password =>  pw_item.password}
        else
          # doesn't exist in the keychain, so get from the user and save in the keychain
          ask_for_rw_credentials(kind)
        end # if pw_item
      end

      ### Prompt for a JSS or MySQL server hostname & port.
      ### Test that its a valid server,
      ### and save it to the User-level JSS config file
      ###
      ### @param type[Symbol] either :jss or :db
      ###
      ### @return [void]
      ###
      def get_server (type)

        case type
        when :jss
          thing = "API"
          current = JSS::CONFIG.api_server_name
          current_port = JSS::CONFIG.api_server_port
          current_port ||= JSS::APIConnection::SSL_PORT
        when :db
          thing = "MySQL DB"
          current = JSS::CONFIG.db_server_name
          current_port = JSS::CONFIG.db_server_port
          current_port ||= JSS::DBConnection::DFT_PORT
        else
          raise JSS::InvalidDataError, "Argument must be :jss or :db"
        end

        got_it = false
        until got_it
          puts
          puts "Enter the server hostname for the JSS #{thing}"
          puts "Hit return for #{current}" if current
          print "JSS #{thing} Server: "
          server_entered = $stdin.gets.chomp
          server_entered = current if server_entered.empty?

          next if server_entered.empty?

          puts
          puts "Enter the port number for the JSS #{thing} on #{server_entered}"
          puts "Hit return for #{current_port}" if current_port
          print "JSS #{thing} port: "
          port_entered = $stdin.gets.chomp
          port_entered = current_port if port_entered.empty?

          got_it = test_server_available(type, server_entered, port_entered)

          if got_it
            case type
            when :jss then
              JSS::CONFIG.api_server_name = server_entered
              JSS::CONFIG.api_server_port = port_entered
            when :db
              JSS::CONFIG.db_server_name = server_entered
              JSS::CONFIG.db_server_port = port_entered
            end # case
            JSS::CONFIG.save :user
          end # if got_it
        end # until
        return server_entered
      end # def get server

      ### Test that a given hostname is actually a server of the given type
      ### by testing the connection without actually logging in.
      ### Displays the connection error if unable to connect.
      ###
      ### @param type[Symbol] either :jss or :db
      ###
      ### @param server[String] the hostname to try connecting to
      ###
      ### @return [Boolean] does the host run a server of that type?
      ###
      def test_server_available (type, server, port)
        puts "Checking connection to #{server}"
        case type
        when :jss then
          if JSS::API.valid_server? server
            JSS::CONFIG.api_server_name = server
            JSS::CONFIG.save :user
            return true
          else
            failure = "'#{server}' does not host a JSS API server"
          end # if

        when :db
          if JSS::DB_CNX.valid_server? server, port
            JSS::CONFIG.db_server_name = server
            JSS::CONFIG.save :user
            return true
          else
            failure = "'#{server}' does not host a MySQL server"
          end # if

        else
          failure = "Unknown Server Type: #{type}"
        end # case

        puts "Sorry, that server is invalid: #{failure}"
        return false
      end # test_server_available

      ### Prompt for read-write credentials & store them in the default (login) keychain
      ###
      ### Raises an exception after 3 failures
      ###
      ### @param kind[Symbol] which kind of credentials? :jss, :db, or :dist
      ###
      ### @return [Hash{Symbol => String}] A Hash with :user and :password values.
      ###
      def ask_for_rw_credentials(kind)

        #$stdin.reopen("/dev/tty")

        # make sure we have a server, which will be stored in the user-level JSS::Configuration
        get_server (kind) unless kind == :dist

        # make sure the keychain is unlocked
        unlock_keychain

        thing_to_access = case kind
        when :jss then "the JSS API on #{JSS::API.hostname}"
        when :db then "the JSS MySQL db at #{JSS::DB_CNX.hostname}"
        when :dist then "read-write access to the JSS Master Distribution Point"
        else raise JSS::InvalidDataError, "argument must be one of :#{RW_CREDENTIAL_KINDS.join ', :'}"
        end # case kind

        # three tries
        begin
          pw = nil
          tries = 0
          while tries != 3

            # for dist we only need a password
            if kind == :dist
              user = KEYCHAIN_DIST_ACCT
              user_text = ''
            else
              print "Username for RW access to #{thing_to_access}: "
              user = $stdin.gets.chomp
              user_text = "#{user} @ "
            end

            print "Password for #{user_text}#{thing_to_access}: "
            system "stty -echo"
            pw = $stdin.gets.chomp
            system "stty echo"
            break if check_credentials(kind, user, pw)

            puts "\nSorry, that was incorrect"
            tries += 1
          end # while

          # did we get it in 3 tries?
          raise JSS::InvalidDataError, "Three wrong attempts, please contact a Jamf Pro administrator." if 3 == tries

          save_credentials(kind, user, pw)
          puts "\nThank you, the credentials have been saved in your OS X login keychain"
        ensure
          # make sure terminal is usable at the end of this
          system "stty echo"
          puts ""
        end # begin

        # we should now have user and pw
        return {:user => user, :password =>  pw}
      end #   def ask_for_rw_credentials(kind)

      ### Check a user and password for validity
      ###
      ### @param kind[Symbol] which kind of credentials? :jss, :db, or :dist
      ###
      ### @param user[String] the username to check
      ###
      ### @param pw[String] the password to try with the username
      ###
      ### @return [Boolean] were the user and  password valid?
      ###
      def check_credentials(kind, user = "", pw = "")
        case kind
        when :jss then check_jss_credentials(user,pw)
        when :db then check_db_credentials(user,pw)
        when :dist then check_dist_credentials(pw)
        else raise JSS::InvalidDataError, "First argument must be one of :#{RW_CREDENTIAL_KINDS.join ', :'}"
        end # case kind
      end

      ### Check a username and passwd for rw access to the JSS API
      ###
      ### Note: this only checks for connectivity, not permissions once connected.
      ###
      ### @param user[String] the username to check
      ###
      ### @param pw[String] the password to try with the username
      ###
      ### @return [Boolean] were the user and  password valid?
      ###
      def check_jss_credentials(user,pw)
        begin
          JSS::API.disconnect  if JSS::API.connected?
          JSS::API.connect :user => user, :pw => pw, :server => JSS::CONFIG.api_server_name
        rescue JSS::AuthenticationError
          return false
        end
        JSS::API.disconnect  if JSS::API.connected?
        return true
      end

      ### Check a username and passwd for rw access to the JSS MySQL DB
      ###
      ### Note: this only checks for connectivity, not permissions once connected.
      ###
      ### @param user[String] the username to check
      ###
      ### @param pw[String] the password to try with the username
      ###
      ### @return [Boolean] were the user and  password valid?
      ###
      def check_db_credentials(user,pw)
        begin
          JSS::DB_CNX.disconnect if JSS::DB_CNX.connected?
          JSS::DB_CNX.connect :user => user, :pw => pw, :server => JSS::CONFIG.db_server_name
        rescue Mysql::ServerError::AccessDeniedError
          return false
        end
        JSS::DB_CNX.disconnect if JSS::DB_CNX.connected?
        return true
      end

      ### Check a passwd for rw access to the master distribution point in the JSS
      ###
      ### @param pw[String] the password to try
      ###
      ### @return [Boolean] was the password valid?
      ###
      def check_dist_credentials(pw)
        D3::Admin::Auth.connect
        ok = JSS::DistributionPoint.master_distribution_point.check_pw :rw, pw
        D3::Admin::Auth.disconnect
        ok
      end

      ### Save a user and password to the login keychain
      ###
      ### Note: assumes the validity of the credentials
      ###
      ### @param kind[Symbol] which kind of credentials? :jss, :db, or :dist
      ###
      ### @param user[String] the username to check
      ###
      ### @param pw[String] the password to try with the username
      ###
      ### @return [Boolean] were the user and  password valid?
      ###
      def save_credentials(kind, user = "", pw = "")
        case kind
        when :jss then save_jss_rw_credentials(user,pw)
        when :db then save_db_rw_credentials(user,pw)
        when :dist then save_dist_rw_credentials(pw)
        else raise JSS::InvalidDataError, "First argument must be one of :#{RW_CREDENTIAL_KINDS.join ', :'}"
        end # case kind
      end

      ### Save the credentials for read-write access to the JSS API in the login keychain
      ###
      ### Note: assumes the validity of the user and passwd. See {#check_jss_pw}
      ###
      ### @param user[String] the username to save
      ###
      ### @param pw[String] the password to save with the username
      ###
      ### @return [void]
      ###
      def save_jss_rw_credentials(user, pw)
        pw_item = Keychain.default.generic_passwords.where(:service => KEYCHAIN_JSS_SERVICE, :label => KEYCHAIN_JSS_LABEL, :account => user).first
        pw_item.delete  if pw_item
        Keychain.default.generic_passwords.create :service => KEYCHAIN_JSS_SERVICE, :label => KEYCHAIN_JSS_LABEL, :account => user, :password => pw
      end

      ### Save the credentials for read-write access to the JSS DB in the login keychain
      ###
      ### Note: assumes the validity of the user and passwd. See {#check_db_pw}
      ###
      ### @param user[String] the username to save
      ###
      ### @param pw[String] the password to save with the username
      ###
      ### @return [void]
      ###
      def save_db_rw_credentials(user, pw)
        pw_item = Keychain.default.generic_passwords.where(:service => KEYCHAIN_DB_SERVICE, :label => KEYCHAIN_DB_LABEL, :account => user).first
        pw_item.delete  if pw_item
        Keychain.default.generic_passwords.create :service => KEYCHAIN_DB_SERVICE, :label => KEYCHAIN_DB_LABEL, :account => user, :password => pw
      end

      ### Save the credentials for read-write access to the JSS  master dist. point
      ### in the login keychain
      ###
      ### Note: assumes the validity of the passwd. See {#check_dist_rw_pw}
      ###
      ### @param pw[String] the password to save with the username
      ###
      ### @return [void]
      ###
      def save_dist_rw_credentials(pw)
        pw_item = Keychain.default.generic_passwords.where(:service => KEYCHAIN_DIST_SERVICE, :label => KEYCHAIN_DIST_LABEL, :account => KEYCHAIN_DIST_ACCT).first
        pw_item.delete  if pw_item
        Keychain.default.generic_passwords.create :service => KEYCHAIN_DIST_SERVICE, :label => KEYCHAIN_DIST_LABEL, :account => KEYCHAIN_DIST_ACCT, :password => pw
      end

      ### Prompt the user to unlock the default keychain
      ###
      ### @return [void]
      ###
      def unlock_keychain
        return true unless Keychain.default.locked?
        begin
          unlocked = false
          tries = 0
          until unlocked or tries == 3
            puts "Please enter the password for your default keychain"
            puts "(#{Keychain.default.path})"
            print "Keychain password: "
            system "stty -echo"
            pw = $stdin.gets.chomp
            system "stty echo"
            begin
              Keychain.default.unlock! pw
              unlocked = true
            rescue Keychain::AuthFailedError
              puts
              puts "Sorry that was incorrect"
              tries += 1
            end # begin..rescue
          end # until
        ensure
          system "stty echo"
        end #begin..ensure

        raise JSS::AuthenticationError, "Three incorrect attempts to unlock keychain" if tries == 3
        return true
      end # unlock keychain
    end # module Auth

    ### @see D3::Admin::Auth.connect
    ###
    def self.connect (alt_db = false)
      Auth.connect alt_db
    end

    ### @see D3::Admin::Auth.disconnect
    ###
    def self.disconnect
      Auth.disconnect
    end

  end # module Admin
end # module D3

