FEATURE: user API now contains scopes so permission is granular

previously we supported blanket read and write for user API, this
change amends it so we can define more limited scopes. A scope only
covers a few routes. You can not grant access to part of the site and
leave a large amount of the information hidden to API consumer.
This commit is contained in:
Sam 2016-10-14 16:05:27 +11:00
parent becff2de4d
commit f4f5524190
16 changed files with 164 additions and 75 deletions

View File

@ -336,7 +336,13 @@
{{else}} {{else}}
{{d-button action="revokeApiKey" actionParam=key class="btn" label="user.revoke_access"}} {{d-button action="revokeApiKey" actionParam=key class="btn" label="user.revoke_access"}}
{{/if}} {{/if}}
<p><span>{{i18n "user.api_permissions"}}</span> {{#if key.write}}{{i18n "user.api_read_write"}}{{else}}{{i18n "user.api_read"}}{{/if}}</p> <p>
<ul>
{{#each key.scopes as |scope|}}
<li>{{scope}}</li>
{{/each}}
</ul>
</p>
<p><span>{{i18n "user.api_approved"}}</span> {{bound-date key.created_at}}</p> <p><span>{{i18n "user.api_approved"}}</span> {{bound-date key.created_at}}</p>
</div> </div>
{{/each}} {{/each}}

View File

@ -6,7 +6,7 @@ class UserApiKeysController < ApplicationController
skip_before_filter :check_xhr, :preload_json skip_before_filter :check_xhr, :preload_json
before_filter :ensure_logged_in, only: [:create, :revoke, :undo_revoke] before_filter :ensure_logged_in, only: [:create, :revoke, :undo_revoke]
AUTH_API_VERSION ||= 1 AUTH_API_VERSION ||= 2
def new def new
@ -34,14 +34,14 @@ class UserApiKeysController < ApplicationController
return return
end end
@access_description = params[:access].include?("w") ? t("user_api_key.read_write") : t("user_api_key.read")
@application_name = params[:application_name] @application_name = params[:application_name]
@public_key = params[:public_key] @public_key = params[:public_key]
@nonce = params[:nonce] @nonce = params[:nonce]
@access = params[:access]
@client_id = params[:client_id] @client_id = params[:client_id]
@auth_redirect = params[:auth_redirect] @auth_redirect = params[:auth_redirect]
@push_url = params[:push_url] @push_url = params[:push_url]
@localized_scopes = params[:scopes].split(",").map{|s| I18n.t("user_api_key.scopes.#{s}")}
@scopes = params[:scopes]
rescue Discourse::InvalidAccess rescue Discourse::InvalidAccess
@generic_error = true @generic_error = true
@ -60,10 +60,6 @@ class UserApiKeysController < ApplicationController
raise Discourse::InvalidAccess unless meets_tl? raise Discourse::InvalidAccess unless meets_tl?
request_read = params[:access].include? 'r'
request_read ||= params[:access].include? 'p'
request_write = params[:access].include? 'w'
validate_params validate_params
# destroy any old keys we had # destroy any old keys we had
@ -72,12 +68,10 @@ class UserApiKeysController < ApplicationController
key = UserApiKey.create!( key = UserApiKey.create!(
application_name: params[:application_name], application_name: params[:application_name],
client_id: params[:client_id], client_id: params[:client_id],
read: request_read,
push: params[:push_url].present?,
user_id: current_user.id, user_id: current_user.id,
write: request_write, push_url: params[:push_url],
key: SecureRandom.hex, key: SecureRandom.hex,
push_url: params[:push_url] scopes: params[:scopes].split(",")
) )
# we keep the payload short so it encrypts easily with public key # we keep the payload short so it encrypts easily with public key
@ -85,7 +79,8 @@ class UserApiKeysController < ApplicationController
payload = { payload = {
key: key.key, key: key.key,
nonce: params[:nonce], nonce: params[:nonce],
access: key.access push: key.has_push?,
api: AUTH_API_VERSION
}.to_json }.to_json
public_key = OpenSSL::PKey::RSA.new(params[:public_key]) public_key = OpenSSL::PKey::RSA.new(params[:public_key])
@ -100,7 +95,7 @@ class UserApiKeysController < ApplicationController
if current_key = request.env['HTTP_USER_API_KEY'] if current_key = request.env['HTTP_USER_API_KEY']
request_key = UserApiKey.find_by(key: current_key) request_key = UserApiKey.find_by(key: current_key)
revoke_key ||= request_key revoke_key ||= request_key
if request_key && request_key.id != revoke_key.id && !request_key.write if request_key && request_key.id != revoke_key.id && !request_key.scopes.include?("write")
raise Discourse::InvalidAccess raise Discourse::InvalidAccess
end end
end end
@ -127,7 +122,7 @@ class UserApiKeysController < ApplicationController
[ [
:public_key, :public_key,
:nonce, :nonce,
:access, :scopes,
:client_id, :client_id,
:auth_redirect, :auth_redirect,
:application_name :application_name
@ -135,13 +130,9 @@ class UserApiKeysController < ApplicationController
end end
def validate_params def validate_params
request_read = params[:access].include? 'r' requested_scopes = Set.new(params[:scopes].split(","))
request_read ||= params[:access].include? 'p'
request_write = params[:access].include? 'w'
raise Discourse::InvalidAccess unless request_read || request_push raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(requested_scopes)
raise Discourse::InvalidAccess if request_read && !SiteSetting.allow_read_user_api_keys
raise Discourse::InvalidAccess if request_write && !SiteSetting.allow_write_user_api_keys
# our pk has got to parse # our pk has got to parse
OpenSSL::PKey::RSA.new(params[:public_key]) OpenSSL::PKey::RSA.new(params[:public_key])

View File

@ -1,10 +1,63 @@
class UserApiKey < ActiveRecord::Base class UserApiKey < ActiveRecord::Base
SCOPES = {
read: [:get],
write: [:get, :post, :patch],
message_bus: [[:post, 'message_bus']],
push: nil,
notifications: [[:post, 'message_bus'], [:get, 'notifications#index'], [:put, 'notifications#mark_read']],
session_info: [[:get, 'session#current'], [:get, 'users#topic_tracking_state']]
}
belongs_to :user belongs_to :user
def access def self.allowed_scopes
has_push = push && push_url.present? && SiteSetting.allowed_user_api_push_urls.include?(push_url) Set.new(SiteSetting.allow_user_api_key_scopes.split("|"))
"#{read ? "r" : ""}#{write ? "w" : ""}#{has_push ? "p" : ""}"
end end
def self.available_scopes
@available_scopes ||= Set.new(SCOPES.keys.map(&:to_s))
end
def self.allow_permission?(permission, env)
verb, action = permission
actual_verb = env["REQUEST_METHOD"] || ""
# safe in Ruby 2.3 which is only one supported
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
def has_push?
(scopes.include?("push") || scopes.include?("notifications")) && push_url.present? && SiteSetting.allowed_user_api_push_urls.include?(push_url)
end
def allow?(env)
scopes.any? do |name|
UserApiKey.allow_scope?(name, env)
end
end
end end
# == Schema Information # == Schema Information

View File

@ -148,8 +148,7 @@ class UserSerializer < BasicUserSerializer
{ {
id: k.id, id: k.id,
application_name: k.application_name, application_name: k.application_name,
read: k.read, scopes: k.scopes.map{|s| I18n.t("user_api_key.scopes.#{s}")},
write: k.write,
created_at: k.created_at created_at: k.created_at
} }
end end

View File

@ -396,9 +396,9 @@ class PostAlerter
end end
def push_notification(user, payload) def push_notification(user, payload)
if SiteSetting.allow_push_user_api_keys && SiteSetting.allowed_user_api_push_urls.present? if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present?
clients = user.user_api_keys clients = user.user_api_keys
.where('push AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL', .where("'push' = ANY(scopes) AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL",
SiteSetting.allowed_user_api_push_urls) SiteSetting.allowed_user_api_push_urls)
.pluck(:client_id, :push_url) .pluck(:client_id, :push_url)

View File

@ -1,5 +1,5 @@
<h1><%= t "user_api_key.title" %></h1> <h1><%= t "user_api_key.title" %></h1>
<div> <div class='authorize-api-key'>
<% if @no_trust_level %> <% if @no_trust_level %>
<h3> <h3>
<%= t("user_api_key.no_trust_level") %> <%= t("user_api_key.no_trust_level") %>
@ -10,7 +10,14 @@
</h3> </h3>
<% else %> <% else %>
<p> <p>
<%= t("user_api_key.description", application_name: @application_name, access: @access_description) %> <%= t("user_api_key.description", application_name: @application_name) %>
</p>
<p>
<ul class='scopes'>
<%- @localized_scopes.each do |scope| %>
<li><%= scope %></li>
<%- end %>
</ul>
</p> </p>
<%= form_tag(user_api_key_path) do %> <%= form_tag(user_api_key_path) do %>
<%= hidden_field_tag 'application_name', @application_name %> <%= hidden_field_tag 'application_name', @application_name %>
@ -20,6 +27,7 @@
<%= hidden_field_tag 'auth_redirect', @auth_redirect %> <%= hidden_field_tag 'auth_redirect', @auth_redirect %>
<%= hidden_field_tag 'push_url', @push_url %> <%= hidden_field_tag 'push_url', @push_url %>
<%= hidden_field_tag 'public_key', @public_key%> <%= hidden_field_tag 'public_key', @public_key%>
<%= hidden_field_tag 'scopes', @scopes%>
<%= submit_tag t('user_api_key.authorize'), class: 'btn btn-danger', id: 'submit' %> <%= submit_tag t('user_api_key.authorize'), class: 'btn btn-danger', id: 'submit' %>
<% end %> <% end %>
<script> <script>

View File

@ -570,10 +570,7 @@ en:
apps: "Apps" apps: "Apps"
revoke_access: "Revoke Access" revoke_access: "Revoke Access"
undo_revoke_access: "Undo Revoke Access" undo_revoke_access: "Undo Revoke Access"
api_permissions: "Permissions:"
api_approved: "Approved:" api_approved: "Approved:"
api_read: "read"
api_read_write: "read and write"
staff_counters: staff_counters:
flags_given: "helpful flags" flags_given: "helpful flags"

View File

@ -650,9 +650,16 @@ en:
authorize: "Authorize" authorize: "Authorize"
read: "read" read: "read"
read_write: "read/write" read_write: "read/write"
description: "Would you like to grant \"%{application_name}\" %{access} access to your account?" description: "\"%{application_name}\" is requesting the following access to your account:"
no_trust_level: "Sorry, you do not have the required trust level to access the user API" no_trust_level: "Sorry, you do not have the required trust level to access the user API"
generic_error: "Sorry, we are unable to issue user API keys, this feature may be disabled by the site admin" generic_error: "Sorry, we are unable to issue user API keys, this feature may be disabled by the site admin"
scopes:
message_bus: "Live updates"
notifications: "Read and clear notifications"
push: "Push notifications to external services"
session_info: "Read user session info"
read: "Read all"
write: "Write all"
reports: reports:
visits: visits:
@ -1387,9 +1394,8 @@ en:
max_user_api_reqs_per_day: "Maximum number of user API requests per key per day" max_user_api_reqs_per_day: "Maximum number of user API requests per key per day"
max_user_api_reqs_per_minute: "Maximum number of user API requests per key per minute" max_user_api_reqs_per_minute: "Maximum number of user API requests per key per minute"
allow_read_user_api_keys: "Allow generation of readonly user API keys" allow_user_api_keys: "Allow generation of user API keys"
allow_write_user_api_keys: "Allow generation of write user API keys" allow_user_api_key_scopes: "List of scopes allowed for user API keys"
allow_push_user_api_keys: "Allow generation of push user API keys"
max_api_keys_per_user: "Maximum number of user API keys per user" max_api_keys_per_user: "Maximum number of user API keys per user"
min_trust_level_for_user_api_key: "Trust level required for generation of user API keys" min_trust_level_for_user_api_key: "Trust level required for generation of user API keys"
allowed_user_api_auth_redirects: "Allowed URL for authentication redirect for user API keys" allowed_user_api_auth_redirects: "Allowed URL for authentication redirect for user API keys"

View File

@ -1287,12 +1287,11 @@ user_api:
default: 2880 default: 2880
max_user_api_reqs_per_minute: max_user_api_reqs_per_minute:
default: 20 default: 20
allow_read_user_api_keys: allow_user_api_keys:
default: true
allow_write_user_api_keys:
default: true
allow_push_user_api_keys:
default: true default: true
allow_user_api_key_scopes:
default: 'read|write|message_bus|push|notifications|session_info'
type: list
max_api_keys_per_user: max_api_keys_per_user:
default: 10 default: 10
push_api_secret_key: push_api_secret_key:

View File

@ -0,0 +1,13 @@
class AddScopesToUserApiKeys < ActiveRecord::Migration
def change
add_column :user_api_keys, :scopes, :text, array: true, null: false, default: []
execute "UPDATE user_api_keys SET scopes = scopes || ARRAY['write'] WHERE write"
execute "UPDATE user_api_keys SET scopes = scopes || ARRAY['read'] WHERE read"
execute "UPDATE user_api_keys SET scopes = scopes || ARRAY['push'] WHERE push"
remove_column :user_api_keys, :read
remove_column :user_api_keys, :write
remove_column :user_api_keys, :push
end
end

View File

@ -6,6 +6,7 @@ class Auth::DefaultCurrentUserProvider
CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER".freeze CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER".freeze
API_KEY ||= "api_key".freeze API_KEY ||= "api_key".freeze
USER_API_KEY ||= "HTTP_USER_API_KEY".freeze USER_API_KEY ||= "HTTP_USER_API_KEY".freeze
USER_API_CLIENT_ID ||= "HTTP_USER_API_CLIENT_ID".freeze
API_KEY_ENV ||= "_DISCOURSE_API".freeze API_KEY_ENV ||= "_DISCOURSE_API".freeze
TOKEN_COOKIE ||= "_t".freeze TOKEN_COOKIE ||= "_t".freeze
PATH_INFO ||= "PATH_INFO".freeze PATH_INFO ||= "PATH_INFO".freeze
@ -90,7 +91,7 @@ class Auth::DefaultCurrentUserProvider
limiter_min.performed! limiter_min.performed!
end end
current_user = lookup_user_api_user(api_key) current_user = lookup_user_api_user_and_update_key(api_key, @env[USER_API_CLIENT_ID])
raise Discourse::InvalidAccess unless current_user raise Discourse::InvalidAccess unless current_user
limiter_min.performed! limiter_min.performed!
@ -176,16 +177,14 @@ class Auth::DefaultCurrentUserProvider
protected protected
WHITELISTED_WRITE_PATHS ||= [/^\/message-bus\/.*\/poll/, /^\/user-api-key\/revoke$/] def lookup_user_api_user_and_update_key(user_api_key, client_id)
def lookup_user_api_user(user_api_key)
if api_key = UserApiKey.where(key: user_api_key, revoked_at: nil).includes(:user).first if api_key = UserApiKey.where(key: user_api_key, revoked_at: nil).includes(:user).first
unless api_key.write unless api_key.allow?(@env)
if @env["REQUEST_METHOD"] != "GET" raise Discourse::InvalidAccess
path = @env["PATH_INFO"] end
unless WHITELISTED_WRITE_PATHS.any?{|whitelisted| path =~ whitelisted}
raise Discourse::InvalidAccess if client_id.present? && client_id != api_key.client_id
end api_key.update_columns(client_id: client_id)
end
end end
api_key.user api_key.user

View File

@ -163,9 +163,7 @@ describe Auth::DefaultCurrentUserProvider do
UserApiKey.create!( UserApiKey.create!(
application_name: 'my app', application_name: 'my app',
client_id: '1234', client_id: '1234',
read: true, scopes: ['read'],
write: false,
push: false,
key: SecureRandom.hex, key: SecureRandom.hex,
user_id: user.id user_id: user.id
) )

View File

@ -35,7 +35,7 @@ TXT
let :args do let :args do
{ {
access: 'r', scopes: 'read',
client_id: "x"*32, client_id: "x"*32,
auth_redirect: 'http://over.the/rainbow', auth_redirect: 'http://over.the/rainbow',
application_name: 'foo', application_name: 'foo',
@ -48,7 +48,7 @@ TXT
it "supports a head request cleanly" do it "supports a head request cleanly" do
head :new head :new
expect(response.code).to eq("200") expect(response.code).to eq("200")
expect(response.headers["Auth-Api-Version"]).to eq("1") expect(response.headers["Auth-Api-Version"]).to eq("2")
end end
end end
@ -67,7 +67,6 @@ TXT
end end
it "will allow tokens for staff without TL" do it "will allow tokens for staff without TL" do
SiteSetting.min_trust_level_for_user_api_key = 2 SiteSetting.min_trust_level_for_user_api_key = 2
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect] SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
@ -96,7 +95,7 @@ TXT
SiteSetting.min_trust_level_for_user_api_key = 0 SiteSetting.min_trust_level_for_user_api_key = 0
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect] SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
SiteSetting.allow_read_user_api_keys = false SiteSetting.allow_user_api_key_scopes = "write"
user = Fabricate(:user, trust_level: 0) user = Fabricate(:user, trust_level: 0)
@ -143,7 +142,7 @@ TXT
SiteSetting.min_trust_level_for_user_api_key = 0 SiteSetting.min_trust_level_for_user_api_key = 0
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect] SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
args[:access] = "pr" args[:scopes] = "push,read"
args[:push_url] = "https://push.it/here" args[:push_url] = "https://push.it/here"
user = Fabricate(:user, trust_level: 0) user = Fabricate(:user, trust_level: 0)
@ -164,22 +163,21 @@ TXT
parsed = JSON.parse(key.private_decrypt(encrypted)) parsed = JSON.parse(key.private_decrypt(encrypted))
expect(parsed["nonce"]).to eq(args[:nonce]) expect(parsed["nonce"]).to eq(args[:nonce])
expect(parsed["access"].split('').sort).to eq(['r']) expect(parsed["push"]).to eq(false)
expect(parsed["api"]).to eq(2)
key = user.user_api_keys.first key = user.user_api_keys.first
expect(key.push).to eq(true) expect(key.scopes).to include("push")
expect(key.push_url).to eq("https://push.it/here") expect(key.push_url).to eq("https://push.it/here")
end end
it "will redirect correctly with valid token" do it "will redirect correctly with valid token" do
SiteSetting.min_trust_level_for_user_api_key = 0 SiteSetting.min_trust_level_for_user_api_key = 0
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect] SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
SiteSetting.allowed_user_api_push_urls = "https://push.it/here" SiteSetting.allowed_user_api_push_urls = "https://push.it/here"
SiteSetting.allow_write_user_api_keys = true
args[:access] = "prw" args[:scopes] = "push,notifications,message_bus,session_info"
args[:push_url] = "https://push.it/here" args[:push_url] = "https://push.it/here"
user = Fabricate(:user, trust_level: 0) user = Fabricate(:user, trust_level: 0)
@ -200,14 +198,12 @@ TXT
parsed = JSON.parse(key.private_decrypt(encrypted)) parsed = JSON.parse(key.private_decrypt(encrypted))
expect(parsed["nonce"]).to eq(args[:nonce]) expect(parsed["nonce"]).to eq(args[:nonce])
expect(parsed["access"].split('').sort).to eq(['p','r', 'w']) expect(parsed["push"]).to eq(true)
api_key = UserApiKey.find_by(key: parsed["key"]) api_key = UserApiKey.find_by(key: parsed["key"])
expect(api_key.user_id).to eq(user.id) expect(api_key.user_id).to eq(user.id)
expect(api_key.read).to eq(true) expect(api_key.scopes.sort).to eq(["push", "message_bus", "notifications", "session_info"].sort)
expect(api_key.write).to eq(true)
expect(api_key.push).to eq(true)
expect(api_key.push_url).to eq("https://push.it/here") expect(api_key.push_url).to eq("https://push.it/here")

View File

@ -1,8 +1,6 @@
Fabricator(:readonly_user_api_key, from: :user_api_key) do Fabricator(:readonly_user_api_key, from: :user_api_key) do
user user
read true scopes ['read']
write false
push false
client_id { SecureRandom.hex } client_id { SecureRandom.hex }
key { SecureRandom.hex } key { SecureRandom.hex }
application_name 'some app' application_name 'some app'

View File

@ -0,0 +1,28 @@
require 'rails_helper'
describe UserApiKey do
context "#allow?" do
it "can look up permissions correctly" do
key = UserApiKey.new(scopes: ['message_bus', 'notifications'])
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(false)
expect(key.allow?("PATH_INFO" => "/message-bus/1234/poll", "REQUEST_METHOD" => "POST")).to eq(true)
expect(key.allow?("action_dispatch.request.path_parameters" => {:controller => "notifications", :action => "mark_read"},
"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)
end
it "can allow blanket read" do
key = UserApiKey.new(scopes: ['read'])
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "GET")).to eq(true)
expect(key.allow?("PATH_INFO" => "/random", "REQUEST_METHOD" => "PUT")).to eq(false)
end
end
end

View File

@ -338,9 +338,7 @@ describe PostAlerter do
client_id: "xxx#{i}", client_id: "xxx#{i}",
key: "yyy#{i}", key: "yyy#{i}",
application_name: "iPhone#{i}", application_name: "iPhone#{i}",
read: true, scopes: ['push'],
write: true,
push: true,
push_url: "https://site2.com/push") push_url: "https://site2.com/push")
end end