diff --git a/Gemfile.lock b/Gemfile.lock index 53ede95..3bce75e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,31 @@ GEM remote: https://rubygems.org/ specs: + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.6) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + drb (2.2.1) + i18n (1.14.3) + concurrent-ruby (~> 1.0) + racc (~> 1.7) json (2.7.1) language_server-protocol (3.17.0.3) + minitest (5.22.2) + mutex_m (0.2.0) parallel (1.24.0) - parser (3.3.0.4) + parser (3.3.0.5) ast (~> 2.4.1) racc prettier_print (1.2.1) @@ -13,7 +33,7 @@ GEM rainbow (3.1.1) regexp_parser (2.9.0) rexml (3.2.6) - rubocop (1.60.0) + rubocop (1.61.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -24,22 +44,27 @@ GEM rubocop-ast (>= 1.30.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.1) + parser (>= 3.3.0.4) rubocop-capybara (2.20.0) rubocop (~> 1.41) - rubocop-discourse (3.6.0) + rubocop-discourse (3.7.1) + activesupport (>= 6.1) rubocop (>= 1.59.0) + rubocop-capybara (>= 2.0.0) + rubocop-factory_bot (>= 2.0.0) rubocop-rspec (>= 2.25.0) rubocop-factory_bot (2.25.1) rubocop (~> 1.41) - rubocop-rspec (2.26.1) + rubocop-rspec (2.27.1) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) syntax_tree (6.2.0) prettier_print (>= 1.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) PLATFORMS diff --git a/lib/oauth2_basic_authenticator.rb b/lib/oauth2_basic_authenticator.rb new file mode 100644 index 0000000..5f6e2a5 --- /dev/null +++ b/lib/oauth2_basic_authenticator.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +class OAuth2BasicAuthenticator < Auth::ManagedAuthenticator + def name + "oauth2_basic" + end + + def can_revoke? + SiteSetting.oauth2_allow_association_change + end + + def can_connect_existing_user? + SiteSetting.oauth2_allow_association_change + end + + def register_middleware(omniauth) + omniauth.provider :oauth2_basic, + name: name, + setup: + lambda { |env| + opts = env["omniauth.strategy"].options + opts[:client_id] = SiteSetting.oauth2_client_id + opts[:client_secret] = SiteSetting.oauth2_client_secret + opts[:provider_ignores_state] = SiteSetting.oauth2_disable_csrf + opts[:client_options] = { + authorize_url: SiteSetting.oauth2_authorize_url, + token_url: SiteSetting.oauth2_token_url, + token_method: SiteSetting.oauth2_token_url_method.downcase.to_sym, + } + opts[:authorize_options] = SiteSetting + .oauth2_authorize_options + .split("|") + .map(&:to_sym) + + if SiteSetting.oauth2_authorize_signup_url.present? && + ActionDispatch::Request.new(env).params["signup"].present? + opts[:client_options][ + :authorize_url + ] = SiteSetting.oauth2_authorize_signup_url + end + + if SiteSetting.oauth2_send_auth_header? && + SiteSetting.oauth2_send_auth_body? + # For maximum compatibility we include both header and body auth by default + # This is a little unusual, and utilising multiple authentication methods + # is technically disallowed by the spec (RFC2749 Section 5.2) + opts[:client_options][:auth_scheme] = :request_body + opts[:token_params] = { + headers: { + "Authorization" => basic_auth_header, + }, + } + elsif SiteSetting.oauth2_send_auth_header? + opts[:client_options][:auth_scheme] = :basic_auth + else + opts[:client_options][:auth_scheme] = :request_body + end + + unless SiteSetting.oauth2_scope.blank? + opts[:scope] = SiteSetting.oauth2_scope + end + + opts[:client_options][:connection_build] = lambda do |builder| + if SiteSetting.oauth2_debug_auth && defined?(OAuth2FaradayFormatter) + builder.response :logger, + Rails.logger, + { bodies: true, formatter: OAuth2FaradayFormatter } + end + + builder.request :url_encoded # form-encode POST params + builder.adapter FinalDestination::FaradayAdapter # make requests with FinalDestination::HTTP + end + } + end + + def basic_auth_header + "Basic " + + Base64.strict_encode64("#{SiteSetting.oauth2_client_id}:#{SiteSetting.oauth2_client_secret}") + end + + def walk_path(fragment, segments, seg_index = 0) + first_seg = segments[seg_index] + return if first_seg.blank? || fragment.blank? + return nil unless fragment.is_a?(Hash) || fragment.is_a?(Array) + first_seg = segments[seg_index].scan(/([\d+])/).length > 0 ? first_seg.split("[")[0] : first_seg + if fragment.is_a?(Hash) + deref = fragment[first_seg] || fragment[first_seg.to_sym] + else + array_index = 0 + if (seg_index > 0) + last_index = segments[seg_index - 1].scan(/([\d+])/).flatten() || [0] + array_index = last_index.length > 0 ? last_index[0].to_i : 0 + end + if fragment.any? && fragment.length >= array_index - 1 + deref = fragment[array_index][first_seg] + else + deref = nil + end + end + + if (deref.blank? || seg_index == segments.size - 1) + deref + else + seg_index += 1 + walk_path(deref, segments, seg_index) + end + end + + def json_walk(result, user_json, prop, custom_path: nil) + path = custom_path || SiteSetting.public_send("oauth2_json_#{prop}_path") + if path.present? + #this.[].that is the same as this.that, allows for both this[0].that and this.[0].that path styles + path = path.gsub(".[].", ".").gsub(".[", "[") + segments = parse_segments(path) + val = walk_path(user_json, segments) + result[prop] = val if val.present? + end + end + + def parse_segments(path) + segments = [+""] + quoted = false + escaped = false + + path + .split("") + .each do |char| + next_char_escaped = false + if !escaped && (char == '"') + quoted = !quoted + elsif !escaped && !quoted && (char == ".") + segments.append +"" + elsif !escaped && (char == '\\') + next_char_escaped = true + else + segments.last << char + end + escaped = next_char_escaped + end + + segments + end + + def log(info) + Rails.logger.warn("OAuth2 Debugging: #{info}") if SiteSetting.oauth2_debug_auth + end + + def fetch_user_details(token, id) + user_json_url = SiteSetting.oauth2_user_json_url.sub(":token", token.to_s).sub(":id", id.to_s) + user_json_method = SiteSetting.oauth2_user_json_url_method.downcase.to_sym + + bearer_token = "Bearer #{token}" + connection = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } + headers = { "Authorization" => bearer_token, "Accept" => "application/json" } + user_json_response = connection.run_request(user_json_method, user_json_url, nil, headers) + + log <<-LOG + user_json request: #{user_json_method} #{user_json_url} + + request headers: #{headers} + + response status: #{user_json_response.status} + + response body: + #{user_json_response.body} + LOG + + if user_json_response.status == 200 + user_json = JSON.parse(user_json_response.body) + + log("user_json:\n#{user_json.to_yaml}") + + result = {} + if user_json.present? + json_walk(result, user_json, :user_id) + json_walk(result, user_json, :username) + json_walk(result, user_json, :name) + json_walk(result, user_json, :email) + json_walk(result, user_json, :email_verified) + json_walk(result, user_json, :avatar) + + DiscoursePluginRegistry.oauth2_basic_additional_json_paths.each do |detail| + prop = "extra:#{detail}" + json_walk(result, user_json, prop, custom_path: detail) + end + end + result + else + nil + end + end + + def primary_email_verified?(auth) + return true if SiteSetting.oauth2_email_verified + verified = auth["info"]["email_verified"] + verified = true if verified == "true" + verified = false if verified == "false" + verified + end + + def always_update_user_email? + SiteSetting.oauth2_overrides_email + end + + def after_authenticate(auth, existing_account: nil) + log <<-LOG + after_authenticate response: + + creds: + #{auth["credentials"].to_hash.to_yaml} + + uid: #{auth["uid"]} + + info: + #{auth["info"].to_hash.to_yaml} + + extra: + #{auth["extra"].to_hash.to_yaml} + LOG + + if SiteSetting.oauth2_fetch_user_details? && SiteSetting.oauth2_user_json_url.present? + if fetched_user_details = fetch_user_details(auth["credentials"]["token"], auth["uid"]) + auth["uid"] = fetched_user_details[:user_id] if fetched_user_details[:user_id] + auth["info"]["nickname"] = fetched_user_details[:username] if fetched_user_details[ + :username + ] + auth["info"]["image"] = fetched_user_details[:avatar] if fetched_user_details[:avatar] + %w[name email email_verified].each do |property| + auth["info"][property] = fetched_user_details[property.to_sym] if fetched_user_details[ + property.to_sym + ] + end + + DiscoursePluginRegistry.oauth2_basic_additional_json_paths.each do |detail| + auth["extra"][detail] = fetched_user_details["extra:#{detail}"] + end + + DiscoursePluginRegistry.oauth2_basic_required_json_paths.each do |x| + if fetched_user_details[x[:path]] != x[:required_value] + result = Auth::Result.new + result.failed = true + result.failed_reason = x[:error_message] + return result + end + end + else + result = Auth::Result.new + result.failed = true + result.failed_reason = I18n.t("login.authenticator_error_fetch_user_details") + return result + end + end + + super(auth, existing_account: existing_account) + end + + def enabled? + SiteSetting.oauth2_enabled + end +end diff --git a/lib/oauth2_faraday_formatter.rb b/lib/oauth2_faraday_formatter.rb new file mode 100644 index 0000000..f9be55a --- /dev/null +++ b/lib/oauth2_faraday_formatter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "faraday/logging/formatter" + +class OAuth2FaradayFormatter < Faraday::Logging::Formatter + def request(env) + warn <<~LOG + OAuth2 Debugging: request #{env.method.upcase} #{env.url} + + Headers: + #{env.request_headers.to_yaml} + + Body: + #{env[:body].to_yaml} + LOG + end + + def response(env) + warn <<~LOG + OAuth2 Debugging: response status #{env.status} + + From #{env.method.upcase} #{env.url} + + Headers: + #{env.request_headers.to_yaml} + + Body: + #{env[:body].to_yaml} + LOG + end +end diff --git a/lib/omniauth/strategies/oauth2_basic.rb b/lib/omniauth/strategies/oauth2_basic.rb new file mode 100644 index 0000000..d493c8a --- /dev/null +++ b/lib/omniauth/strategies/oauth2_basic.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class OmniAuth::Strategies::Oauth2Basic < ::OmniAuth::Strategies::OAuth2 + option :name, "oauth2_basic" + + uid do + if path = SiteSetting.oauth2_callback_user_id_path.split(".") + recurse(access_token, [*path]) if path.present? + end + end + + info do + if paths = SiteSetting.oauth2_callback_user_info_paths.split("|") + result = Hash.new + paths.each do |p| + segments = p.split(":") + if segments.length == 2 + key = segments.first + path = [*segments.last.split(".")] + result[key] = recurse(access_token, path) + end + end + result + end + end + + def callback_url + Discourse.base_url_no_prefix + script_name + callback_path + end + + def recurse(obj, keys) + return nil if !obj + k = keys.shift + result = obj.respond_to?(k) ? obj.send(k) : obj[k] + keys.empty? ? result : recurse(result, keys) + end +end diff --git a/plugin.rb b/plugin.rb index 215b888..e0e2d31 100644 --- a/plugin.rb +++ b/plugin.rb @@ -9,70 +9,9 @@ enabled_site_setting :oauth2_enabled -class ::OmniAuth::Strategies::Oauth2Basic < ::OmniAuth::Strategies::OAuth2 - option :name, "oauth2_basic" - - uid do - if path = SiteSetting.oauth2_callback_user_id_path.split(".") - recurse(access_token, [*path]) if path.present? - end - end - - info do - if paths = SiteSetting.oauth2_callback_user_info_paths.split("|") - result = Hash.new - paths.each do |p| - segments = p.split(":") - if segments.length == 2 - key = segments.first - path = [*segments.last.split(".")] - result[key] = recurse(access_token, path) - end - end - result - end - end - - def callback_url - Discourse.base_url_no_prefix + script_name + callback_path - end - - def recurse(obj, keys) - return nil if !obj - k = keys.shift - result = obj.respond_to?(k) ? obj.send(k) : obj[k] - keys.empty? ? result : recurse(result, keys) - end -end - -require "faraday/logging/formatter" -class OAuth2FaradayFormatter < Faraday::Logging::Formatter - def request(env) - warn <<~LOG - OAuth2 Debugging: request #{env.method.upcase} #{env.url} - - Headers: - #{env.request_headers.to_yaml} - - Body: - #{env[:body].to_yaml} - LOG - end - - def response(env) - warn <<~LOG - OAuth2 Debugging: response status #{env.status} - - From #{env.method.upcase} #{env.url} - - Headers: - #{env.request_headers.to_yaml} - - Body: - #{env[:body].to_yaml} - LOG - end -end +require_relative "lib/omniauth/strategies/oauth2_basic" +require_relative "lib/oauth2_faraday_formatter" +require_relative "lib/oauth2_basic_authenticator" # You should use this register if you want to add custom paths to traverse the user details JSON. # We'll store the value in the user associated account's extra attribute hash using the full path as the key. @@ -90,268 +29,6 @@ DiscoursePluginRegistry.define_filtered_register :oauth2_basic_additional_json_p # }, self) DiscoursePluginRegistry.define_filtered_register :oauth2_basic_required_json_paths -class ::OAuth2BasicAuthenticator < Auth::ManagedAuthenticator - def name - "oauth2_basic" - end - - def can_revoke? - SiteSetting.oauth2_allow_association_change - end - - def can_connect_existing_user? - SiteSetting.oauth2_allow_association_change - end - - def register_middleware(omniauth) - omniauth.provider :oauth2_basic, - name: name, - setup: - lambda { |env| - opts = env["omniauth.strategy"].options - opts[:client_id] = SiteSetting.oauth2_client_id - opts[:client_secret] = SiteSetting.oauth2_client_secret - opts[:provider_ignores_state] = SiteSetting.oauth2_disable_csrf - opts[:client_options] = { - authorize_url: SiteSetting.oauth2_authorize_url, - token_url: SiteSetting.oauth2_token_url, - token_method: SiteSetting.oauth2_token_url_method.downcase.to_sym, - } - opts[:authorize_options] = SiteSetting - .oauth2_authorize_options - .split("|") - .map(&:to_sym) - - if SiteSetting.oauth2_authorize_signup_url.present? && - ActionDispatch::Request.new(env).params["signup"].present? - opts[:client_options][ - :authorize_url - ] = SiteSetting.oauth2_authorize_signup_url - end - - if SiteSetting.oauth2_send_auth_header? && - SiteSetting.oauth2_send_auth_body? - # For maximum compatibility we include both header and body auth by default - # This is a little unusual, and utilising multiple authentication methods - # is technically disallowed by the spec (RFC2749 Section 5.2) - opts[:client_options][:auth_scheme] = :request_body - opts[:token_params] = { - headers: { - "Authorization" => basic_auth_header, - }, - } - elsif SiteSetting.oauth2_send_auth_header? - opts[:client_options][:auth_scheme] = :basic_auth - else - opts[:client_options][:auth_scheme] = :request_body - end - - unless SiteSetting.oauth2_scope.blank? - opts[:scope] = SiteSetting.oauth2_scope - end - - opts[:client_options][:connection_build] = lambda do |builder| - if SiteSetting.oauth2_debug_auth && defined?(OAuth2FaradayFormatter) - builder.response :logger, - Rails.logger, - { bodies: true, formatter: OAuth2FaradayFormatter } - end - - builder.request :url_encoded # form-encode POST params - builder.adapter FinalDestination::FaradayAdapter # make requests with FinalDestination::HTTP - end - } - end - - def basic_auth_header - "Basic " + - Base64.strict_encode64("#{SiteSetting.oauth2_client_id}:#{SiteSetting.oauth2_client_secret}") - end - - def walk_path(fragment, segments, seg_index = 0) - first_seg = segments[seg_index] - return if first_seg.blank? || fragment.blank? - return nil unless fragment.is_a?(Hash) || fragment.is_a?(Array) - first_seg = segments[seg_index].scan(/([\d+])/).length > 0 ? first_seg.split("[")[0] : first_seg - if fragment.is_a?(Hash) - deref = fragment[first_seg] || fragment[first_seg.to_sym] - else - array_index = 0 - if (seg_index > 0) - last_index = segments[seg_index - 1].scan(/([\d+])/).flatten() || [0] - array_index = last_index.length > 0 ? last_index[0].to_i : 0 - end - if fragment.any? && fragment.length >= array_index - 1 - deref = fragment[array_index][first_seg] - else - deref = nil - end - end - - if (deref.blank? || seg_index == segments.size - 1) - deref - else - seg_index += 1 - walk_path(deref, segments, seg_index) - end - end - - def json_walk(result, user_json, prop, custom_path: nil) - path = custom_path || SiteSetting.public_send("oauth2_json_#{prop}_path") - if path.present? - #this.[].that is the same as this.that, allows for both this[0].that and this.[0].that path styles - path = path.gsub(".[].", ".").gsub(".[", "[") - segments = parse_segments(path) - val = walk_path(user_json, segments) - result[prop] = val if val.present? - end - end - - def parse_segments(path) - segments = [+""] - quoted = false - escaped = false - - path - .split("") - .each do |char| - next_char_escaped = false - if !escaped && (char == '"') - quoted = !quoted - elsif !escaped && !quoted && (char == ".") - segments.append +"" - elsif !escaped && (char == '\\') - next_char_escaped = true - else - segments.last << char - end - escaped = next_char_escaped - end - - segments - end - - def log(info) - Rails.logger.warn("OAuth2 Debugging: #{info}") if SiteSetting.oauth2_debug_auth - end - - def fetch_user_details(token, id) - user_json_url = SiteSetting.oauth2_user_json_url.sub(":token", token.to_s).sub(":id", id.to_s) - user_json_method = SiteSetting.oauth2_user_json_url_method.downcase.to_sym - - bearer_token = "Bearer #{token}" - connection = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter } - headers = { "Authorization" => bearer_token, "Accept" => "application/json" } - user_json_response = connection.run_request(user_json_method, user_json_url, nil, headers) - - log <<-LOG - user_json request: #{user_json_method} #{user_json_url} - - request headers: #{headers} - - response status: #{user_json_response.status} - - response body: - #{user_json_response.body} - LOG - - if user_json_response.status == 200 - user_json = JSON.parse(user_json_response.body) - - log("user_json:\n#{user_json.to_yaml}") - - result = {} - if user_json.present? - json_walk(result, user_json, :user_id) - json_walk(result, user_json, :username) - json_walk(result, user_json, :name) - json_walk(result, user_json, :email) - json_walk(result, user_json, :email_verified) - json_walk(result, user_json, :avatar) - - DiscoursePluginRegistry.oauth2_basic_additional_json_paths.each do |detail| - prop = "extra:#{detail}" - json_walk(result, user_json, prop, custom_path: detail) - end - end - result - else - nil - end - end - - def primary_email_verified?(auth) - return true if SiteSetting.oauth2_email_verified - verified = auth["info"]["email_verified"] - verified = true if verified == "true" - verified = false if verified == "false" - verified - end - - def always_update_user_email? - SiteSetting.oauth2_overrides_email - end - - def after_authenticate(auth, existing_account: nil) - log <<-LOG - after_authenticate response: - - creds: - #{auth["credentials"].to_hash.to_yaml} - - uid: #{auth["uid"]} - - info: - #{auth["info"].to_hash.to_yaml} - - extra: - #{auth["extra"].to_hash.to_yaml} - LOG - - if SiteSetting.oauth2_fetch_user_details? && SiteSetting.oauth2_user_json_url.present? - if fetched_user_details = fetch_user_details(auth["credentials"]["token"], auth["uid"]) - auth["uid"] = fetched_user_details[:user_id] if fetched_user_details[:user_id] - auth["info"]["nickname"] = fetched_user_details[:username] if fetched_user_details[ - :username - ] - auth["info"]["image"] = fetched_user_details[:avatar] if fetched_user_details[:avatar] - %w[name email email_verified].each do |property| - auth["info"][property] = fetched_user_details[property.to_sym] if fetched_user_details[ - property.to_sym - ] - end - - DiscoursePluginRegistry.oauth2_basic_additional_json_paths.each do |detail| - auth["extra"][detail] = fetched_user_details["extra:#{detail}"] - end - - DiscoursePluginRegistry.oauth2_basic_required_json_paths.each do |x| - if fetched_user_details[x[:path]] != x[:required_value] - result = Auth::Result.new - result.failed = true - result.failed_reason = x[:error_message] - return result - end - end - else - result = Auth::Result.new - result.failed = true - result.failed_reason = I18n.t("login.authenticator_error_fetch_user_details") - return result - end - end - - super(auth, existing_account: existing_account) - end - - def enabled? - SiteSetting.oauth2_enabled - end -end - auth_provider title_setting: "oauth2_button_title", authenticator: OAuth2BasicAuthenticator.new -load File.expand_path( - "../lib/validators/oauth2_basic/oauth2_fetch_user_details_validator.rb", - __FILE__, - ) +require_relative "lib/validators/oauth2_basic/oauth2_fetch_user_details_validator"