REFACTOR: Introduce RouteMatcher class

This consolidates logic used to match routes in ApiKey, UserApiKey and DefaultCurrentUserProvider. This reduces duplicated logic, and will allow UserApiKeysScope to easily re-use the parameter matching logic from ApiKeyScope
This commit is contained in:
David Taylor 2020-10-06 17:20:15 +01:00
parent c8e0547bcc
commit 1cec333f48
9 changed files with 211 additions and 146 deletions

View File

@ -62,10 +62,10 @@ class ApiKey < ActiveRecord::Base
Digest::SHA256.hexdigest key Digest::SHA256.hexdigest key
end end
def request_allowed?(request, route_param) def request_allowed?(env)
return false if allowed_ips.present? && allowed_ips.none? { |ip| ip.include?(request.ip) } return false if allowed_ips.present? && allowed_ips.none? { |ip| ip.include?(Rack::Request.new(env).ip) }
api_key_scopes.blank? || api_key_scopes.any? { |s| s.permits?(route_param) } api_key_scopes.blank? || api_key_scopes.any? { |s| s.permits?(env) }
end end
end end

View File

@ -77,39 +77,15 @@ class ApiKeyScope < ActiveRecord::Base
end end
end end
def permits?(route_param) def permits?(env)
path_params = "#{route_param['controller']}##{route_param['action']}" RouteMatcher.new(**mapping.except(:urls), allowed_param_values: allowed_parameters).match?(env: env)
mapping[:actions].include?(path_params) && (allowed_parameters.blank? || params_allowed?(route_param))
end end
private private
def params_allowed?(route_param)
mapping[:params].all? do |param|
param_alias = mapping.dig(:aliases, param)
allowed_values = [allowed_parameters[param.to_s]].flatten
value = route_param[param.to_s]
alias_value = route_param[param_alias.to_s]
return false if value.present? && alias_value.present?
value = value || alias_value
value = extract_category_id(value) if param_alias == :category_slug_path_with_id
allowed_values.blank? || allowed_values.include?(value)
end
end
def mapping def mapping
@mapping ||= self.class.scope_mappings.dig(resource.to_sym, action.to_sym) @mapping ||= self.class.scope_mappings.dig(resource.to_sym, action.to_sym)
end end
def extract_category_id(category_slug_with_id)
parts = category_slug_with_id.split('/')
!parts.empty? && parts.last =~ /\A\d+\Z/ ? parts.pop : nil
end
end end
# == Schema Information # == Schema Information

View File

@ -5,21 +5,7 @@ class UserApiKey < ActiveRecord::Base
"scopes" # TODO(2020-12-18): remove "scopes" # TODO(2020-12-18): remove
] ]
SCOPES = { REVOKE_MATCHER = RouteMatcher.new(actions: "user_api_keys#revoke", methods: :post, params: [:id])
read: [:get],
write: [:get, :post, :patch, :put, :delete],
message_bus: [[:post, 'message_bus']],
push: nil,
one_time_password: nil,
notifications: [[:post, 'message_bus'], [:get, 'notifications#index'], [:put, 'notifications#mark_read']],
session_info: [
[:get, 'session#current'],
[:get, 'users#topic_tracking_state'],
[:get, 'list#unread'],
[:get, 'list#new'],
[:get, 'list#latest']
]
}
belongs_to :user belongs_to :user
has_many :scopes, class_name: "UserApiKeyScope", dependent: :destroy has_many :scopes, class_name: "UserApiKeyScope", dependent: :destroy
@ -51,35 +37,7 @@ class UserApiKey < ActiveRecord::Base
end end
def self.available_scopes def self.available_scopes
@available_scopes ||= Set.new(SCOPES.keys.map(&:to_s)) @available_scopes ||= Set.new(UserApiKeyScopes.all_scopes.keys.map(&:to_s))
end
def self.allow_permission?(permission, env)
verb, action = permission
actual_verb = env["REQUEST_METHOD"] || ""
return false unless actual_verb.downcase == verb.to_s
return true unless action
# not a rails route, special handling
return true if action == "message_bus" && env["PATH_INFO"] =~ /^\/message-bus\/.*\/poll/
params = env['action_dispatch.request.path_parameters']
return false unless params
actual_action = "#{params[:controller]}##{params[:action]}"
actual_action == action
end
def self.allow_scope?(name, env)
if allowed = SCOPES[name.to_sym]
good = allowed.any? do |permission|
allow_permission?(permission, env)
end
good || allow_permission?([:post, 'user_api_keys#revoke'], env)
end
end end
def has_push? def has_push?
@ -89,9 +47,7 @@ class UserApiKey < ActiveRecord::Base
end end
def allow?(env) def allow?(env)
scopes.any? do |s| scopes.any? { |s| s.permits?(env) } || is_revoke_self_request?(env)
UserApiKey.allow_scope?(s.name, env)
end || is_revoke_self_request?(env)
end end
def self.invalid_auth_redirect?(auth_redirect) def self.invalid_auth_redirect?(auth_redirect)
@ -102,8 +58,12 @@ class UserApiKey < ActiveRecord::Base
private private
def revoke_self_matcher
REVOKE_MATCHER.with_allowed_param_values({ "id" => [nil, id.to_s] })
end
def is_revoke_self_request?(env) def is_revoke_self_request?(env)
UserApiKey.allow_permission?([:post, 'user_api_keys#revoke'], env) && (env[:id].nil? || env[:id].to_i == id) revoke_self_matcher.match?(env: env)
end end
end end

View File

@ -1,6 +1,34 @@
# frozen_string_literal: true # frozen_string_literal: true
class UserApiKeyScope < ActiveRecord::Base class UserApiKeyScope < ActiveRecord::Base
SCOPES = {
read: [ RouteMatcher.new(methods: :get) ],
write: [ RouteMatcher.new(methods: [:get, :post, :patch, :put, :delete]) ],
message_bus: [ RouteMatcher.new(methods: :post, actions: 'message_bus') ],
push: [],
one_time_password: [],
notifications: [
RouteMatcher.new(methods: :post, actions: 'message_bus'),
RouteMatcher.new(methods: :get, actions: 'notifications#index'),
RouteMatcher.new(methods: :put, actions: 'notifications#mark_read')
],
session_info: [ RouteMatcher.new(methods: :get, actions: 'session#current') ]
}
def self.all_scopes
SCOPES
end
def permits?(env)
matchers.any? { |m| m.match?(env: env) }
end
private
def matchers
@matchers ||= Array(self.class.all_scopes[name.to_sym])
end
end end
# == Schema Information # == Schema Information

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative '../route_matcher'
class Auth::DefaultCurrentUserProvider class Auth::DefaultCurrentUserProvider
@ -20,9 +21,9 @@ class Auth::DefaultCurrentUserProvider
BAD_TOKEN ||= "_DISCOURSE_BAD_TOKEN" BAD_TOKEN ||= "_DISCOURSE_BAD_TOKEN"
PARAMETER_API_PATTERNS ||= [ PARAMETER_API_PATTERNS ||= [
{ RouteMatcher.new(
method: :get, methods: :get,
route: [ actions: [
"posts#latest", "posts#latest",
"posts#user_posts_feed", "posts#user_posts_feed",
"groups#posts_feed", "groups#posts_feed",
@ -37,18 +38,18 @@ class Auth::DefaultCurrentUserProvider
*[:all, :yearly, :quarterly, :monthly, :weekly, :daily].map { |p| "list#top_#{p}_feed" }, *[:all, :yearly, :quarterly, :monthly, :weekly, :daily].map { |p| "list#top_#{p}_feed" },
*[:latest, :unread, :new, :read, :posted, :bookmarks].map { |f| "tags#show_#{f}" } *[:latest, :unread, :new, :read, :posted, :bookmarks].map { |f| "tags#show_#{f}" }
], ],
format: :rss formats: :rss
}, ),
{ RouteMatcher.new(
method: :get, methods: :get,
route: "users#bookmarks", actions: "users#bookmarks",
format: :ics formats: :ics
}, ),
{ RouteMatcher.new(
method: :post, methods: :post,
route: "admin/email#handle_mail", actions: "admin/email#handle_mail",
format: "*" formats: nil
} )
] ]
# do all current user initialization here # do all current user initialization here
@ -335,8 +336,7 @@ class Auth::DefaultCurrentUserProvider
if api_key = ApiKey.active.with_key(api_key_value).includes(:user).first if api_key = ApiKey.active.with_key(api_key_value).includes(:user).first
api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME] api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME]
params = @env['action_dispatch.request.parameters'] unless api_key.request_allowed?(@env)
unless api_key.request_allowed?(request, params)
Rails.logger.warn("[Unauthorized API Access] username: #{api_username}, IP address: #{request.ip}") Rails.logger.warn("[Unauthorized API Access] username: #{api_username}, IP address: #{request.ip}")
return nil return nil
end end
@ -370,17 +370,7 @@ class Auth::DefaultCurrentUserProvider
# However, in some scenarios it is essential to send them via url parameters # However, in some scenarios it is essential to send them via url parameters
# so we need to add some exceptions # so we need to add some exceptions
def api_parameter_allowed? def api_parameter_allowed?
request_method = @env["REQUEST_METHOD"]&.downcase&.to_sym parameter_api_patterns.any? { |p| p.match?(env: @env) }
request_format = @env['action_dispatch.request.formats']&.first&.symbol
path_params = @env['action_dispatch.request.path_parameters']
request_route = "#{path_params[:controller]}##{path_params[:action]}" if path_params
parameter_api_patterns.any? do |p|
(p[:method] == "*" || Array(p[:method]).include?(request_method)) &&
(p[:format] == "*" || Array(p[:format]).include?(request_format)) &&
(p[:route] == "*" || Array(p[:route]).include?(request_route))
end
end end
def header_api_key? def header_api_key?

View File

@ -797,15 +797,37 @@ class Plugin::Instance
# in a query parameter rather than a header. For example: # in a query parameter rather than a header. For example:
# #
# add_api_parameter_route( # add_api_parameter_route(
# method: :get, # methods: :get,
# route: "users#bookmarks", # actions: "users#bookmarks",
# format: :ics # formats: :ics
# ) # )
# #
# See Auth::DefaultCurrentUserProvider::PARAMETER_API_PATTERNS for more examples # See Auth::DefaultCurrentUserProvider::PARAMETER_API_PATTERNS for more examples
# and Auth::DefaultCurrentUserProvider#api_parameter_allowed? for implementation # and Auth::DefaultCurrentUserProvider#api_parameter_allowed? for implementation
def add_api_parameter_route(method:, route:, format:) def add_api_parameter_route(method: nil, methods: nil,
DiscoursePluginRegistry.register_api_parameter_route({ method: method, route: route, format: format }, self) route: nil, actions: nil,
format: nil, formats: nil)
if Array(format).include?("*")
Discourse.deprecate("* is no longer a valid api_parameter_route format matcher. Use `nil` instead", drop_from: "2.7")
# Old API used * as wildcard. New api uses `nil`
format = nil
end
# Backwards compatibility with old parameter names:
if method || route || format
Discourse.deprecate("method, route and format parameters for api_parameter_routes are deprecated. Use methods, actions and formats instead.", drop_from: "2.7")
methods ||= method
actions ||= route
formats ||= format
end
DiscoursePluginRegistry.register_api_parameter_route(
RouteMatcher.new(
methods: methods,
actions: actions,
formats: formats
), self)
end end
protected protected

85
lib/route_matcher.rb Normal file
View File

@ -0,0 +1,85 @@
# frozen_string_literal: true
class RouteMatcher
attr_reader :actions, :params, :methods, :aliases, :formats, :allowed_param_values
def initialize(actions: nil, params: nil, methods: nil, formats: nil, aliases: nil, allowed_param_values: nil)
@actions = Array(actions) if actions
@params = Array(params) if params
@methods = Array(methods) if methods
@formats = Array(formats) if formats
@aliases = aliases
@allowed_param_values = allowed_param_values
end
# Return an identical route matcher, with the allowed_param_values replaced
def with_allowed_param_values(new_allowed_param_values)
RouteMatcher.new(
actions: actions,
params: params,
methods: methods,
formats: formats,
aliases: aliases,
allowed_param_values: new_allowed_param_values
)
end
def match?(env:)
request = ActionDispatch::Request.new(env)
action_allowed?(request) &&
params_allowed?(request) &&
method_allowed?(request) &&
format_allowed?(request)
end
private
def action_allowed?(request)
return true if actions.nil? # actions are unrestricted
path_params = request.path_parameters
# message_bus is not a rails route, special handling
return true if actions.include?("message_bus") && request.fullpath =~ /^\/message-bus\/.*\/poll/
actions.include? "#{path_params[:controller]}##{path_params[:action]}"
end
def params_allowed?(request)
return true if params.nil? || allowed_param_values.blank? # params are unrestricted
requested_params = request.parameters
params.all? do |param|
param_alias = aliases&.[](param)
allowed_values = [allowed_param_values[param.to_s]].flatten
value = requested_params[param.to_s]
alias_value = requested_params[param_alias.to_s]
return false if value.present? && alias_value.present?
value = value || alias_value
value = extract_category_id(value) if param_alias == :category_slug_path_with_id
allowed_values.blank? || allowed_values.include?(value)
end
end
def extract_category_id(category_slug_with_id)
parts = category_slug_with_id.split('/')
!parts.empty? && parts.last =~ /\A\d+\Z/ ? parts.pop : nil
end
def method_allowed?(request)
return true if methods.nil?
request_method = request.request_method&.downcase&.to_sym
methods.include?(request_method)
end
def format_allowed?(request)
return true if formats.nil?
request_format = request.formats&.first&.symbol
formats.include?(request_format)
end
end

View File

@ -98,55 +98,56 @@ describe ApiKey do
end end
describe "#request_allowed?" do describe "#request_allowed?" do
let(:request_mock) { OpenStruct.new(ip: '133.45.67.99') } let(:request) {
let(:route_path) { { 'controller' => 'topics', 'action' => 'show', 'topic_id' => '3' } } ActionDispatch::TestRequest.create.tap do |request|
request.path_parameters = { controller: "topics", action: "show", topic_id: "3" }
request.remote_addr = "133.45.67.99"
end
}
let(:env) { request.env }
let(:scope) do let(:scope) do
ApiKeyScope.new(resource: 'topics', action: 'read', allowed_parameters: { topic_id: '3' }) ApiKeyScope.new(resource: 'topics', action: 'read', allowed_parameters: { topic_id: '3' })
end end
let(:key) { ApiKey.new(api_key_scopes: [scope]) } let(:key) { ApiKey.new(api_key_scopes: [scope]) }
it 'allows the request if there are no allowed IPs' do it 'allows the request if there are no allowed IPs' do
key.allowed_ips = nil key.allowed_ips = nil
key.api_key_scopes = [] key.api_key_scopes = []
expect(key.request_allowed?(env)).to eq(true)
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end end
it 'rejects the request if the IP is not allowed' do it 'rejects the request if the IP is not allowed' do
key.allowed_ips = %w[115.65.76.87] key.allowed_ips = %w[115.65.76.87]
expect(key.request_allowed?(env)).to eq(false)
expect(key.request_allowed?(request_mock, route_path)).to eq(false)
end end
it 'allow the request if there are not allowed params' do it 'allow the request if there are not allowed params' do
scope.allowed_parameters = nil scope.allowed_parameters = nil
expect(key.request_allowed?(env)).to eq(true)
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end end
it 'rejects the request when params are different' do it 'rejects the request when params are different' do
route_path['topic_id'] = '4' request.path_parameters = { controller: "topics", action: "show", topic_id: "4" }
expect(key.request_allowed?(env)).to eq(false)
expect(key.request_allowed?(request_mock, route_path)).to eq(false)
end end
it 'accepts the request if one of the parameters match' do it 'accepts the request if one of the parameters match' do
route_path['topic_id'] = '4' request.path_parameters = { controller: "topics", action: "show", topic_id: "4" }
scope.allowed_parameters = { topic_id: %w[3 4] } scope.allowed_parameters = { topic_id: %w[3 4] }
expect(key.request_allowed?(env)).to eq(true)
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end end
it 'allow the request when the scope has an alias' do it 'allow the request when the scope has an alias' do
route_path = { 'controller' => 'topics', 'action' => 'show', 'id' => '3' } request.path_parameters = { controller: "topics", action: "show", id: "3" }
expect(key.request_allowed?(env)).to eq(true)
expect(key.request_allowed?(request_mock, route_path)).to eq(true)
end end
it 'rejects the request when the main parameter and the alias are both used' do it 'rejects the request when the main parameter and the alias are both used' do
route_path = { 'controller' => 'topics', 'action' => 'show', 'topic_id' => '3', 'id' => '4' } request.path_parameters = { controller: "topics", action: "show", topic_id: "3", id: "3" }
expect(key.request_allowed?(env)).to eq(false)
expect(key.request_allowed?(request_mock, route_path)).to eq(false)
end end
end end
end end

View File

@ -4,37 +4,40 @@ require 'rails_helper'
describe UserApiKey do describe UserApiKey do
context "#allow?" do context "#allow?" do
def request_env(method, path, **path_parameters)
ActionDispatch::TestRequest.create.tap do |request|
request.request_method = method
request.path = path
request.path_parameters = path_parameters
end.env
end
it "can look up permissions correctly" do it "can look up permissions correctly" do
key = UserApiKey.new(scopes: ['message_bus', 'notifications'].map { |name| UserApiKeyScope.new(name: name) }) key = UserApiKey.new(scopes: ['message_bus', 'notifications'].map { |name| UserApiKeyScope.new(name: name) })
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(false) expect(key.allow?(request_env("GET", "/random"))).to eq(false)
expect(key.allow?("PATH_INFO" => "/message-bus/1234/poll", "REQUEST_METHOD" => "POST")).to eq(true) expect(key.allow?(request_env("POST", "/message-bus/1234/poll"))).to eq(true)
expect(key.allow?("action_dispatch.request.path_parameters" => { controller: "notifications", action: "mark_read" }, expect(key.allow?(request_env("PUT", "/xyz", controller: "notifications", action: "mark_read"))).to eq(true)
"PATH_INFO" => "/xyz", "REQUEST_METHOD" => "PUT")).to eq(true)
expect(key.allow?("action_dispatch.request.path_parameters" => { controller: "user_api_keys", action: "revoke" },
"PATH_INFO" => "/xyz", "REQUEST_METHOD" => "POST")).to eq(true)
expect(key.allow?(request_env("POST", "/xyz", controller: "user_api_keys", action: "revoke"))).to eq(true)
end end
it "can allow all correct scopes to write" do it "can allow all correct scopes to write" do
key = UserApiKey.new(scopes: ["write"].map { |name| UserApiKeyScope.new(name: name) }) key = UserApiKey.new(scopes: ["write"].map { |name| UserApiKeyScope.new(name: name) })
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true) expect(key.allow?(request_env("GET", "/random"))).to eq(true)
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(true) expect(key.allow?(request_env("PUT", "/random"))).to eq(true)
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PATCH")).to eq(true) expect(key.allow?(request_env("PATCH", "/random"))).to eq(true)
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "DELETE")).to eq(true) expect(key.allow?(request_env("DELETE", "/random"))).to eq(true)
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "POST")).to eq(true) expect(key.allow?(request_env("POST", "/random"))).to eq(true)
end end
it "can allow blanket read" do it "can allow blanket read" do
key = UserApiKey.new(scopes: ["read"].map { |name| UserApiKeyScope.new(name: name) }) key = UserApiKey.new(scopes: ["read"].map { |name| UserApiKeyScope.new(name: name) })
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true) expect(key.allow?(request_env("GET", "/random"))).to eq(true)
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(false) expect(key.allow?(request_env("PUT", "/random"))).to eq(false)
end end
end end
end end