From 482c00f4cb40519a3d3e7aac771e3b52c553e5d1 Mon Sep 17 00:00:00 2001 From: "Dr. Strangelove" Date: Tue, 25 Apr 2023 17:34:41 -0400 Subject: [PATCH] feat: Persistent tokens for login (#182) * feat: persistent sessions * feat: expiration tracking for tokens * feat: force new login if token expires in next 5 minutes * feat: improved error handling around token file --- lib/gzr/cli.rb | 5 ++ lib/gzr/commands/session.rb | 56 ++++++++++++++++++++++ lib/gzr/commands/session/login.rb | 50 ++++++++++++++++++++ lib/gzr/commands/session/logout.rb | 47 ++++++++++++++++++ lib/gzr/modules/session.rb | 76 +++++++++++++++++++++++++++--- 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 lib/gzr/commands/session.rb create mode 100644 lib/gzr/commands/session/login.rb create mode 100644 lib/gzr/commands/session/logout.rb diff --git a/lib/gzr/cli.rb b/lib/gzr/cli.rb index 055287a..3c0c6be 100644 --- a/lib/gzr/cli.rb +++ b/lib/gzr/cli.rb @@ -46,6 +46,8 @@ def self.exit_on_failure? class_option :su, type: :string, desc: 'After connecting, change to user_id given' class_option :width, type: :numeric, default: nil, desc: 'Width of rendering for tables' class_option :persistent, type: :boolean, default: false, desc: 'Use persistent connection to communicate with host' + class_option :token, type: :string, default: nil, desc: "Access token to use for authentication" + class_option :token_file, type: :boolean, default: false, desc: "Use access token stored in file for authentication" # Error raised by this runner Error = Class.new(StandardError) @@ -96,5 +98,8 @@ def version require_relative 'commands/folder' register Gzr::Commands::Folder, 'folder', 'folder [SUBCOMMAND]', 'Commands pertaining to folders' + + require_relative 'commands/session' + register Gzr::Commands::Session, 'session', 'session [SUBCOMMAND]', 'Commands pertaining to sessions' end end diff --git a/lib/gzr/commands/session.rb b/lib/gzr/commands/session.rb new file mode 100644 index 0000000..14ce2a2 --- /dev/null +++ b/lib/gzr/commands/session.rb @@ -0,0 +1,56 @@ +# The MIT License (MIT) + +# Copyright (c) 2023 Mike DeAngelo Google, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# frozen_string_literal: true + +require 'thor' + +module Gzr + module Commands + class Session < Thor + + namespace :session + + desc 'login', 'Create a persistent session' + method_option :text, type: :boolean, default: false, + desc: 'output token to screen instead of file' + def login(*) + if options[:help] + invoke :help, ['login'] + else + require_relative 'session/login' + Gzr::Commands::Session::Login.new(options).execute + end + end + + desc 'logout', 'End a persistent session' + def logout(*) + if options[:help] + invoke :help, ['logout'] + else + require_relative 'session/logout' + Gzr::Commands::Session::Logout.new(options).execute + end + end + + end + end +end diff --git a/lib/gzr/commands/session/login.rb b/lib/gzr/commands/session/login.rb new file mode 100644 index 0000000..32bc90b --- /dev/null +++ b/lib/gzr/commands/session/login.rb @@ -0,0 +1,50 @@ +# The MIT License (MIT) + +# Copyright (c) 2023 Mike DeAngelo Google, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# frozen_string_literal: true + +require_relative '../../command' + +module Gzr + module Commands + class Session + class Login < Gzr::Command + def initialize(options) + super() + @options = options + end + + def execute(input: $stdin, output: $stdout) + say_warning(@options) if @options[:debug] + login + if @options[:text] + puts @sdk.access_token + return + end + token_data = read_token_data || {} + token_data[@options[:host].to_sym] ||= {} + token_data[@options[:host].to_sym][@options[:su]&.to_sym || :default] = { token: @sdk.access_token, expiration: @sdk.access_token_expires_at } + write_token_data(token_data) + end + end + end + end +end diff --git a/lib/gzr/commands/session/logout.rb b/lib/gzr/commands/session/logout.rb new file mode 100644 index 0000000..6dfd8fe --- /dev/null +++ b/lib/gzr/commands/session/logout.rb @@ -0,0 +1,47 @@ +# The MIT License (MIT) + +# Copyright (c) 2023 Mike DeAngelo Google, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# frozen_string_literal: true + +require_relative '../../command' + +module Gzr + module Commands + class Session + class Logout < Gzr::Command + def initialize(options) + super() + @options = options + end + + def execute(input: $stdin, output: $stdout) + say_warning(@options) if @options[:debug] + login + @sdk.logout + token_data = read_token_data || {} + token_data[@options[:host].to_sym] ||= {} + token_data[@options[:host].to_sym]&.delete(@options[:su]&.to_sym || :default) + write_token_data(token_data) + end + end + end + end +end diff --git a/lib/gzr/modules/session.rb b/lib/gzr/modules/session.rb index 9afb8f3..e945e6e 100644 --- a/lib/gzr/modules/session.rb +++ b/lib/gzr/modules/session.rb @@ -55,6 +55,38 @@ def sufficient_version?(given_version, minimum_version) !versions.drop_while {|v| v < minimum_version}.reverse.drop_while {|v| v > given_version}.empty? end + def token_file + "#{ENV["HOME"]}/.gzr_auth" + end + + def read_token_data + return nil unless File.exist?(token_file) + s = File.stat(token_file) + if !(s.mode.to_s(8)[3..5] == "600") + say_error "#{token_file} mode is #{s.mode.to_s(8)[3..5]}. It must be 600. Ignoring." + return nil + end + token_data = nil + file = nil + begin + file = File.open(token_file) + token_data = JSON.parse(file.read,{:symbolize_names => true}) + ensure + file.close if file + end + token_data + end + + def write_token_data(token_data) + file = nil + begin + file = File.new(token_file, "wt") + file.chmod(0600) + file.write JSON.pretty_generate(token_data) + ensure + file.close if file + end + end def build_connection_hash(api_version=nil) conn_hash = Hash.new @@ -86,6 +118,9 @@ def build_connection_hash(api_version=nil) } end conn_hash[:user_agent] = "Gazer #{Gzr::VERSION}" + + return conn_hash if @options[:token] || @options[:token_file] + if @options[:client_id] then conn_hash[:client_id] = @options[:client_id] if @options[:client_secret] then @@ -103,12 +138,14 @@ def build_connection_hash(api_version=nil) end def login(min_api_version="4.0") - if (@options[:client_id].nil? && ENV["LOOKERSDK_CLIENT_ID"]) - @options[:client_id] = ENV["LOOKERSDK_CLIENT_ID"] - end + if !@options[:token] && !@options[:token_file] + if (@options[:client_id].nil? && ENV["LOOKERSDK_CLIENT_ID"]) + @options[:client_id] = ENV["LOOKERSDK_CLIENT_ID"] + end - if (@options[:client_secret].nil? && ENV["LOOKERSDK_CLIENT_SECRET"]) - @options[:client_secret] = ENV["LOOKERSDK_CLIENT_SECRET"] + if (@options[:client_secret].nil? && ENV["LOOKERSDK_CLIENT_SECRET"]) + @options[:client_secret] = ENV["LOOKERSDK_CLIENT_SECRET"] + end end if (@options[:verify_ssl] && ENV["LOOKERSDK_VERIFY_SSL"]) @@ -183,6 +220,31 @@ def login(min_api_version="4.0") @sdk = LookerSDK::Client.new(conn_hash.merge(faraday: faraday)) unless @sdk say_ok "check for connectivity: #{@sdk.alive?}" if @options[:debug] + if @options[:token_file] + entry = read_token_data&.fetch(@options[:host].to_sym,nil)&.fetch(@options[:su]&.to_sym || :default,nil) + if entry.nil? + say_error "No token found for host #{@options[:host]} and user #{@options[:su] || "default"}" + say_error "login with `gzr session login --host #{@options[:host]}` to set a token" + raise LookerSDK::Unauthorized.new + end + (day, time, tz) = entry[:expiration].split(' ') + day_parts = day.split('-') + time_parts = time.split(':') + date_time_parts = day_parts + time_parts + [tz] + expiration = Time.new(*date_time_parts) + if expiration < (Time.now + 300) + if expiration < Time.now + say_error "token expired at #{expiration}" + else + say_error "token expires at #{expiration}, which is in the next 5 minutes" + end + say_error "login again with `gzr session login --host #{@options[:host]}`" + raise LookerSDK::Unauthorized.new + end + @sdk.access_token = entry[:token] + elsif @options[:token] + @sdk.access_token = @options[:token] + end say_ok "verify authentication: #{@sdk.authenticated?}" if @options[:debug] rescue LookerSDK::Unauthorized => e say_error "Unauthorized - credentials are not valid" @@ -196,7 +258,7 @@ def login(min_api_version="4.0") raise Gzr::CLI::Error, "Invalid credentials" unless @sdk.authenticated? - if @options[:su] then + if @options[:su] && !(@options[:token] || @options[:token_file])then say_ok "su to user #{@options[:su]}" if @options[:debug] @access_token_stack.push(@sdk.access_token) begin @@ -247,7 +309,7 @@ def with_session(min_api_version="4.0") e.backtrace.each { |b| say_error b } if @options[:debug] raise Gzr::CLI::Error, e.message ensure - logout_all + logout_all unless @options[:token] || @options[:token_file] end end end