Require permitted scopes when registering a client (#29718)

This commit is contained in:
Angus McLeod 2024-11-19 21:28:04 +01:00 committed by GitHub
parent 4f11d16deb
commit ec7de0fd68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 259 additions and 44 deletions

View File

@ -2,26 +2,53 @@
class UserApiKeyClientsController < ApplicationController class UserApiKeyClientsController < ApplicationController
layout "no_ember" layout "no_ember"
requires_login skip_before_action :check_xhr, :preload_json, :verify_authenticity_token
skip_before_action :check_xhr, :preload_json
def register def show
params.require(:client_id)
client = UserApiKeyClient.find_by(client_id: params[:client_id])
raise Discourse::InvalidParameters unless client && client.auth_redirect.present?
head :ok
end
def create
rate_limit
require_params require_params
validate_params
ensure_new_client
client = UserApiKeyClient.find_or_initialize_by(client_id: params[:client_id]) client = UserApiKeyClient.new(client_id: params[:client_id])
client.application_name = params[:application_name] client.application_name = params[:application_name]
client.public_key = params[:public_key] client.public_key = params[:public_key]
client.auth_redirect = params[:auth_redirect] client.auth_redirect = params[:auth_redirect]
if client.save! ActiveRecord::Base.transaction do
client.save!
@scopes.each { |scope| client.scopes.create!(name: scope) }
end
if client.persisted?
render json: success_json render json: success_json
else else
render json: failed_json render json: failed_json
end end
end end
def rate_limit
RateLimiter.new(nil, "user-api-key-clients-#{request.remote_ip}", 1, 24.hours).performed!
end
def require_params def require_params
%i[client_id application_name public_key auth_redirect].each { |p| params.require(p) } %i[client_id application_name public_key auth_redirect scopes].each { |p| params.require(p) }
@scopes = params[:scopes].split(",")
end
def validate_params
raise Discourse::InvalidAccess unless UserApiKeyClientScope.allowed.superset?(Set.new(@scopes))
OpenSSL::PKey::RSA.new(params[:public_key]) OpenSSL::PKey::RSA.new(params[:public_key])
end end
def ensure_new_client
raise Discourse::InvalidAccess if UserApiKeyClient.where(client_id: params[:client_id]).exists?
end
end end

View File

@ -66,7 +66,6 @@ class UserApiKeysController < ApplicationController
@client = UserApiKeyClient.new(client_id: params[:client_id]) if @client.blank? @client = UserApiKeyClient.new(client_id: params[:client_id]) if @client.blank?
@client.application_name = params[:application_name] if params[:application_name].present? @client.application_name = params[:application_name] if params[:application_name].present?
@client.public_key = params[:public_key] if params[:public_key].present?
@client.save! if @client.new_record? || @client.changed? @client.save! if @client.new_record? || @client.changed?
# destroy any old keys the user had with the client # destroy any old keys the user had with the client
@ -88,7 +87,8 @@ class UserApiKeysController < ApplicationController
api: AUTH_API_VERSION, api: AUTH_API_VERSION,
}.to_json }.to_json
public_key = OpenSSL::PKey::RSA.new(@client.public_key) public_key_str = @client.public_key.present? ? @client.public_key : params[:public_key]
public_key = OpenSSL::PKey::RSA.new(public_key_str)
@payload = Base64.encode64(public_key.public_encrypt(@payload)) @payload = Base64.encode64(public_key.public_encrypt(@payload))
if scopes.include?("one_time_password") if scopes.include?("one_time_password")
@ -190,6 +190,9 @@ class UserApiKeysController < ApplicationController
def validate_params def validate_params
requested_scopes = Set.new(params[:scopes].split(",")) requested_scopes = Set.new(params[:scopes].split(","))
raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(requested_scopes) raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(requested_scopes)
if @client&.scopes.present? && !@client.allowed_scopes.superset?(requested_scopes)
raise Discourse::InvalidAccess
end
# our pk has got to parse # our pk has got to parse
OpenSSL::PKey::RSA.new(params[:public_key]) if params[:public_key] OpenSSL::PKey::RSA.new(params[:public_key]) if params[:public_key]

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Jobs
class CleanUpUnusedRegisteredUserApiKeyClients < ::Jobs::Scheduled
every 1.day
def execute(args)
if SiteSetting.unused_registered_user_api_key_clients_days > 0
destroy_days_ago = SiteSetting.unused_registered_user_api_key_clients_days.days.ago
clients =
UserApiKeyClient
.where("auth_redirect IS NOT NULL")
.where(
"id NOT IN (SELECT user_api_key_client_id FROM user_api_keys WHERE user_api_keys.last_used_at > ?)",
destroy_days_ago,
)
.distinct
.destroy_all
end
end
end
end

View File

@ -2,6 +2,14 @@
class UserApiKeyClient < ActiveRecord::Base class UserApiKeyClient < ActiveRecord::Base
has_many :keys, class_name: "UserApiKey", dependent: :destroy has_many :keys, class_name: "UserApiKey", dependent: :destroy
has_many :scopes,
class_name: "UserApiKeyClientScope",
foreign_key: "user_api_key_client_id",
dependent: :destroy
def allowed_scopes
Set.new(scopes.map(&:name))
end
def self.invalid_auth_redirect?(auth_redirect, client: nil) def self.invalid_auth_redirect?(auth_redirect, client: nil)
return false if client&.auth_redirect == auth_redirect return false if client&.auth_redirect == auth_redirect

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class UserApiKeyClientScope < ActiveRecord::Base
belongs_to :client, class_name: "UserApiKeyClient", foreign_key: "user_api_key_client_id"
validates :name,
inclusion: {
in: UserApiKeyScope.all_scopes.keys.map(&:to_s),
message: "%{value} is not a valid scope",
}
def self.allowed
Set.new(SiteSetting.allow_user_api_key_client_scopes.split("|"))
end
end
# == Schema Information
#
# Table name: user_api_key_client_scopes
#
# id :bigint not null, primary key
# user_api_key_client_id :bigint not null
# name :string(100) not null
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -1631,7 +1631,8 @@ Discourse::Application.routes.draw do
get "/user-api-key/otp" => "user_api_keys#otp" get "/user-api-key/otp" => "user_api_keys#otp"
post "/user-api-key/otp" => "user_api_keys#create_otp" post "/user-api-key/otp" => "user_api_keys#create_otp"
post "/user-api-key-client/register" => "user_api_key_clients#register" get "/user-api-key-client" => "user_api_key_clients#show"
post "/user-api-key-client" => "user_api_key_clients#create"
get "/safe-mode" => "safe_mode#index" get "/safe-mode" => "safe_mode#index"
post "/safe-mode" => "safe_mode#enter", :as => "safe_mode_enter" post "/safe-mode" => "safe_mode#enter", :as => "safe_mode_enter"

View File

@ -3120,6 +3120,10 @@ user_api:
allow_user_api_key_scopes: allow_user_api_key_scopes:
default: "read|write|message_bus|push|notifications|session_info|one_time_password" default: "read|write|message_bus|push|notifications|session_info|one_time_password"
type: list type: list
allow_user_api_key_client_scopes:
default: ""
type: list
hidden: true
push_api_secret_key: push_api_secret_key:
default: "" default: ""
hidden: true hidden: true
@ -3147,6 +3151,10 @@ user_api:
default: 0 default: 0
max: 36500 max: 36500
hidden: true hidden: true
unused_registered_user_api_key_clients_days:
default: 30
max: 36500
hidden: true
tags: tags:
tagging_enabled: tagging_enabled:

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class CreateUserApiKeyClientScopes < ActiveRecord::Migration[7.1]
def change
create_table :user_api_key_client_scopes do |t|
t.bigint :user_api_key_client_id, null: false
t.string :name, null: false, limit: 100
t.timestamps
end
end
end

View File

@ -14,9 +14,19 @@ end
Fabricator(:user_api_key_scope) Fabricator(:user_api_key_scope)
Fabricator(:user_api_key_client_scope)
Fabricator(:user_api_key_client) do Fabricator(:user_api_key_client) do
transient :scopes
client_id { SecureRandom.hex } client_id { SecureRandom.hex }
application_name "some app" application_name "some app"
after_create do |client, transients|
if transients[:scopes].present?
[*transients[:scopes]].each { |scope| client.scopes.create!(name: scope) }
end
end
end end
Fabricator(:readonly_user_api_key, from: :user_api_key) do Fabricator(:readonly_user_api_key, from: :user_api_key) do

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
RSpec.describe Jobs::CleanUpUnusedRegisteredUserApiKeyClients do
let!(:client1) { Fabricate(:user_api_key_client, auth_redirect: "https://remote.com/redirect") }
let!(:client2) do
Fabricate(:user_api_key_client, auth_redirect: "https://another-remote.com/redirect")
end
let!(:client3) { Fabricate(:user_api_key_client) }
let!(:key1) { Fabricate(:readonly_user_api_key, client: client1, last_used_at: 1.hour.ago) }
let!(:key2) { Fabricate(:readonly_user_api_key, client: client1, last_used_at: 1.hour.ago) }
let!(:key3) { Fabricate(:readonly_user_api_key, client: client2, last_used_at: 1.hour.ago) }
let!(:key4) { Fabricate(:readonly_user_api_key, client: client3, last_used_at: 1.hour.ago) }
before do
SiteSetting.unused_registered_user_api_key_clients_days = 1
freeze_time
end
context "when registered client has used and unused keys" do
before { key1.update!(last_used_at: 2.days.ago) }
it "does not destroy client or keys" do
expect { described_class.new.execute({}) }.to not_change {
UserApiKeyClient.count
}.and not_change { UserApiKey.count }
end
end
context "when registered client has only unused keys" do
before do
key1.update!(last_used_at: 2.days.ago)
key2.update!(last_used_at: 2.days.ago)
end
it "destroys registered client and associated keys" do
described_class.new.execute({})
expect(UserApiKeyClient.exists?(client1.id)).to eq(false)
expect(UserApiKey.exists?(key1.id)).to eq(false)
expect(UserApiKey.exists?(key2.id)).to eq(false)
expect(UserApiKeyClient.exists?(client2.id)).to eq(true)
expect(UserApiKey.exists?(key3.id)).to eq(true)
expect(UserApiKeyClient.exists?(client3.id)).to eq(true)
expect(UserApiKey.exists?(key4.id)).to eq(true)
end
end
context "when unregistered client has only unused keys" do
before { key4.update!(last_used_at: 2.days.ago) }
it "does not destroy client or keys" do
expect { described_class.new.execute({}) }.to not_change {
UserApiKeyClient.count
}.and not_change { UserApiKey.count }
end
end
end

View File

@ -21,41 +21,69 @@ RSpec.describe UserApiKeyClientsController do
} }
end end
describe "#register" do describe "#show" do
context "without a user" do context "with a registered client" do
it "returns a 403" do before { Fabricate(:user_api_key_client, **args) }
post "/user-api-key-client/register.json", params: args
expect(response.status).to eq(403) it "succeeds" do
head "/user-api-key-client.json", params: { client_id: args[:client_id] }
expect(response.status).to eq(200)
end end
end end
context "with a user" do context "without a registered client" do
before { sign_in(Fabricate(:user)) } it "returns a 400" do
head "/user-api-key-client.json", params: { client_id: args[:client_id] }
expect(response.status).to eq(400)
end
end
end
it "registers a client" do describe "#create" do
post "/user-api-key-client/register.json", params: args context "without scopes" do
expect(response.status).to eq(200) it "returns a 400" do
expect( post "/user-api-key-client.json", params: args
UserApiKeyClient.exists?( expect(response.status).to eq(400)
client_id: args[:client_id], end
application_name: args[:application_name], end
auth_redirect: args[:auth_redirect],
public_key: args[:public_key], context "with scopes" do
), let!(:args_with_scopes) { args.merge(scopes: "user_status") }
).to eq(true)
context "when scopes are not allowed" do
before { SiteSetting.allow_user_api_key_client_scopes = "" }
it "returns a 403" do
post "/user-api-key-client.json", params: args_with_scopes
expect(response.status).to eq(403)
end
end end
it "updates a registered client" do context "when scopes are allowed" do
Fabricate(:user_api_key_client, **args) before { SiteSetting.allow_user_api_key_client_scopes = "user_status" }
args[:application_name] = "bar"
post "/user-api-key-client/register.json", params: args it "registers a client" do
expect(response.status).to eq(200) post "/user-api-key-client.json", params: args_with_scopes
expect( expect(response.status).to eq(200)
UserApiKeyClient.exists?( client =
client_id: args[:client_id], UserApiKeyClient.find_by(
application_name: args[:application_name], client_id: args_with_scopes[:client_id],
), application_name: args_with_scopes[:application_name],
).to eq(true) auth_redirect: args_with_scopes[:auth_redirect],
public_key: args_with_scopes[:public_key],
)
expect(client.present?).to eq(true)
expect(client.scopes.map(&:name)).to match_array(["user_status"])
end
context "if the client is already registered" do
before { Fabricate(:user_api_key_client, **args) }
it "returns a 403" do
post "/user-api-key-client.json", params: args_with_scopes
expect(response.status).to eq(403)
end
end
end end
end end
end end

View File

@ -305,19 +305,34 @@ RSpec.describe UserApiKeysController do
application_name: fixed_args[:application_name], application_name: fixed_args[:application_name],
public_key: public_key, public_key: public_key,
auth_redirect: fixed_args[:auth_redirect], auth_redirect: fixed_args[:auth_redirect],
scopes: "read",
) )
end end
before { sign_in(user) } before { sign_in(user) }
it "does not require allowed_user_api_auth_redirects to contain registered auth_redirect" do context "with allowed scopes" do
post "/user-api-key.json", params: fixed_args it "does not require allowed_user_api_auth_redirects to contain registered auth_redirect" do
expect(response.status).to eq(302) post "/user-api-key.json", params: fixed_args
expect(response.status).to eq(302)
end
it "does not require application_name or public_key params" do
post "/user-api-key.json", params: fixed_args.except(:application_name, :public_key)
expect(response.status).to eq(302)
end
end end
it "does not require application_name or public_key params" do context "without allowed scopes" do
post "/user-api-key.json", params: fixed_args.except(:application_name, :public_key) let!(:invalid_scope_args) do
expect(response.status).to eq(302) fixed_args[:scopes] = "write"
fixed_args
end
it "returns a 403" do
post "/user-api-key.json", params: invalid_scope_args
expect(response.status).to eq(403)
end
end end
end end
end end