mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-30 10:58:31 -05:00
Feature: User API key support (server side implementation)
- Supports throttled read and write - No support for push yet, but data is captured about intent
This commit is contained in:
parent
d3c8985030
commit
fc095acaaa
10 changed files with 396 additions and 0 deletions
68
app/controllers/user_api_keys_controller.rb
Normal file
68
app/controllers/user_api_keys_controller.rb
Normal file
|
@ -0,0 +1,68 @@
|
|||
class UserApiKeysController < ApplicationController
|
||||
|
||||
skip_before_filter :redirect_to_login_if_required, only: [:new]
|
||||
skip_before_filter :check_xhr
|
||||
before_filter :ensure_logged_in, only: [:create]
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
[
|
||||
:public_key,
|
||||
:nonce,
|
||||
:access,
|
||||
:client_id,
|
||||
:auth_redirect,
|
||||
:application_name
|
||||
].each{|p| params.require(p)}
|
||||
|
||||
unless SiteSetting.allowed_user_api_auth_redirects
|
||||
.split('|')
|
||||
.any?{|u| params[:auth_redirect] == u}
|
||||
|
||||
raise Discourse::InvalidAccess
|
||||
end
|
||||
|
||||
raise Discourse::InvalidAccess if current_user.trust_level < SiteSetting.min_trust_level_for_user_api_key
|
||||
|
||||
request_read = params[:access].include? 'r'
|
||||
request_push = params[:access].include? 'p'
|
||||
request_write = params[:access].include? 'w'
|
||||
|
||||
raise Discourse::InvalidAccess unless request_read || request_push
|
||||
raise Discourse::InvalidAccess if request_read && !SiteSetting.allow_read_user_api_keys
|
||||
raise Discourse::InvalidAccess if request_write && !SiteSetting.allow_write_user_api_keys
|
||||
raise Discourse::InvalidAccess if request_push && !SiteSetting.allow_push_user_api_keys
|
||||
|
||||
if request_push && !SiteSetting.allowed_user_api_push_urls.split('|').any?{|u| params[:push_url] == u}
|
||||
raise Discourse::InvalidAccess
|
||||
end
|
||||
|
||||
key = UserApiKey.create!(
|
||||
application_name: params[:application_name],
|
||||
client_id: params[:client_id],
|
||||
read: request_read,
|
||||
push: request_push,
|
||||
user_id: current_user.id,
|
||||
write: request_write,
|
||||
key: SecureRandom.hex,
|
||||
push_url: request_push ? params[:push_url] : nil
|
||||
)
|
||||
|
||||
# we keep the payload short so it encrypts easily with public key
|
||||
# it is often restricted to 128 chars
|
||||
payload = {
|
||||
key: key.key,
|
||||
nonce: params[:nonce],
|
||||
access: key.access
|
||||
}.to_json
|
||||
|
||||
public_key = OpenSSL::PKey::RSA.new(params[:public_key])
|
||||
payload = Base64.encode64(public_key.public_encrypt(payload))
|
||||
|
||||
redirect_to "#{params[:auth_redirect]}?payload=#{CGI.escape(payload)}"
|
||||
end
|
||||
|
||||
end
|
30
app/models/user_api_key.rb
Normal file
30
app/models/user_api_key.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
class UserApiKey < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
|
||||
def access
|
||||
"#{read ? "r" : ""}#{write ? "w" : ""}#{push ? "p" : ""}"
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: user_api_keys
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# user_id :integer not null
|
||||
# client_id :string not null
|
||||
# key :string not null
|
||||
# application_name :string not null
|
||||
# read :boolean not null
|
||||
# write :boolean not null
|
||||
# push :boolean not null
|
||||
# push_url :string
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_user_api_keys_on_client_id (client_id)
|
||||
# index_user_api_keys_on_key (key) UNIQUE
|
||||
# index_user_api_keys_on_user_id (user_id)
|
||||
#
|
|
@ -3006,6 +3006,7 @@ en:
|
|||
developer: 'Developer'
|
||||
embedding: "Embedding"
|
||||
legal: "Legal"
|
||||
user_api: 'User API'
|
||||
uncategorized: 'Other'
|
||||
backups: "Backups"
|
||||
login: "Login"
|
||||
|
|
|
@ -1365,6 +1365,16 @@ en:
|
|||
default_categories_tracking: "List of categories that are tracked by default."
|
||||
default_categories_muted: "List of categories that are muted by default."
|
||||
|
||||
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"
|
||||
allow_read_user_api_keys: "Allow generation of readonly user API keys"
|
||||
allow_write_user_api_keys: "Allow generation of write 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"
|
||||
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_push_urls: "Allowed URLs for server push to user API"
|
||||
|
||||
tagging_enabled: "Enable tags on topics?"
|
||||
min_trust_to_create_tag: "The minimum trust level required to create a tag."
|
||||
max_tags_per_topic: "The maximum tags that can be applied to a topic."
|
||||
|
|
|
@ -661,5 +661,9 @@ Discourse::Application.routes.draw do
|
|||
# special case for top
|
||||
root to: "list#top", constraints: HomePageConstraint.new("top"), :as => "top_lists"
|
||||
|
||||
get "/user-api-key/new" => "user_api_keys#new"
|
||||
post "/user-api-key/new" => "user_api_keys#create"
|
||||
|
||||
get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new
|
||||
|
||||
end
|
||||
|
|
|
@ -1243,6 +1243,28 @@ user_preferences:
|
|||
type: category_list
|
||||
default: ''
|
||||
|
||||
user_api:
|
||||
max_user_api_reqs_per_day:
|
||||
default: 2880
|
||||
max_user_api_reqs_per_minute:
|
||||
default: 12
|
||||
allow_read_user_api_keys:
|
||||
default: true
|
||||
allow_write_user_api_keys:
|
||||
default: false
|
||||
allow_push_user_api_keys:
|
||||
default: true
|
||||
max_api_keys_per_user:
|
||||
default: 10
|
||||
min_trust_level_for_user_api_key:
|
||||
default: 1
|
||||
allowed_user_api_push_urls:
|
||||
default: ''
|
||||
type: list
|
||||
allowed_user_api_auth_redirects:
|
||||
default: 'https://api.discourse.org/api/auth_redirect'
|
||||
type: list
|
||||
|
||||
tags:
|
||||
tagging_enabled:
|
||||
client: true
|
||||
|
|
19
db/migrate/20160815002002_add_user_api_keys.rb
Normal file
19
db/migrate/20160815002002_add_user_api_keys.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
class AddUserApiKeys < ActiveRecord::Migration
|
||||
def change
|
||||
create_table :user_api_keys do |t|
|
||||
t.integer :user_id, null: false
|
||||
t.string :client_id, null: false
|
||||
t.string :key, null: false
|
||||
t.string :application_name, null: false
|
||||
t.boolean :read, null: false
|
||||
t.boolean :write, null: false
|
||||
t.boolean :push, null: false
|
||||
t.string :push_url
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_api_keys, [:key], unique: true
|
||||
add_index :user_api_keys, [:user_id]
|
||||
add_index :user_api_keys, [:client_id]
|
||||
end
|
||||
end
|
|
@ -5,6 +5,7 @@ class Auth::DefaultCurrentUserProvider
|
|||
|
||||
CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER".freeze
|
||||
API_KEY ||= "api_key".freeze
|
||||
USER_API_KEY ||= "USER_API_KEY".freeze
|
||||
API_KEY_ENV ||= "_DISCOURSE_API".freeze
|
||||
TOKEN_COOKIE ||= "_t".freeze
|
||||
PATH_INFO ||= "PATH_INFO".freeze
|
||||
|
@ -75,10 +76,35 @@ class Auth::DefaultCurrentUserProvider
|
|||
@env[API_KEY_ENV] = true
|
||||
end
|
||||
|
||||
# user api key handling
|
||||
if api_key = @env[USER_API_KEY]
|
||||
|
||||
limiter_min = RateLimiter.new(nil, "user_api_min_#{api_key}", SiteSetting.max_user_api_reqs_per_minute, 60)
|
||||
limiter_day = RateLimiter.new(nil, "user_api_day_#{api_key}", SiteSetting.max_user_api_reqs_per_day, 86400)
|
||||
|
||||
unless limiter_day.can_perform?
|
||||
raise RateLimiter::LimitExceeded, "User API calls per minute exceeded"
|
||||
end
|
||||
|
||||
unless limiter_min.can_perform?
|
||||
raise RateLimiter::LimitExceeded, "User API calls per day exceeded"
|
||||
end
|
||||
|
||||
current_user = lookup_user_api_user(api_key)
|
||||
raise Discourse::InvalidAccess unless current_user
|
||||
|
||||
limiter_min.performed!
|
||||
limiter_day.performed!
|
||||
|
||||
@env[API_KEY_ENV] = true
|
||||
end
|
||||
|
||||
@env[CURRENT_USER_KEY] = current_user
|
||||
end
|
||||
|
||||
def refresh_session(user, session, cookies)
|
||||
return if is_api?
|
||||
|
||||
if user && (!user.auth_token_updated_at || user.auth_token_updated_at <= 1.hour.ago)
|
||||
user.update_column(:auth_token_updated_at, Time.zone.now)
|
||||
cookies[TOKEN_COOKIE] = { value: user.auth_token, httponly: true, expires: SiteSetting.maximum_session_age.hours.from_now }
|
||||
|
@ -150,6 +176,16 @@ class Auth::DefaultCurrentUserProvider
|
|||
|
||||
protected
|
||||
|
||||
def lookup_user_api_user(user_api_key)
|
||||
if api_key = UserApiKey.where(key: user_api_key).includes(:user).first
|
||||
if !api_key.write && @env["REQUEST_METHOD"] != "GET"
|
||||
raise Discourse::InvalidAccess
|
||||
end
|
||||
|
||||
api_key.user
|
||||
end
|
||||
end
|
||||
|
||||
def lookup_api_user(api_key_value, request)
|
||||
if api_key = ApiKey.where(key: api_key_value).includes(:user).first
|
||||
api_username = request["api_username"]
|
||||
|
|
|
@ -153,5 +153,78 @@ describe Auth::DefaultCurrentUserProvider do
|
|||
freeze_time 3.hours.from_now
|
||||
expect(provider("/", "HTTP_COOKIE" => "_t=#{user.auth_token}").current_user).to eq(nil)
|
||||
end
|
||||
|
||||
context "user api" do
|
||||
let :user do
|
||||
Fabricate(:user)
|
||||
end
|
||||
|
||||
let :api_key do
|
||||
UserApiKey.create!(
|
||||
application_name: 'my app',
|
||||
client_id: '1234',
|
||||
read: true,
|
||||
write: false,
|
||||
push: false,
|
||||
key: SecureRandom.hex,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
it "allows user API access correctly" do
|
||||
params = {
|
||||
"REQUEST_METHOD" => "GET",
|
||||
"USER_API_KEY" => api_key.key,
|
||||
}
|
||||
|
||||
expect(provider("/", params).current_user.id).to eq(user.id)
|
||||
|
||||
expect {
|
||||
provider("/", params.merge({"REQUEST_METHOD" => "POST"})).current_user
|
||||
}.to raise_error(Discourse::InvalidAccess)
|
||||
|
||||
end
|
||||
|
||||
it "rate limits api usage" do
|
||||
|
||||
RateLimiter.stubs(:disabled?).returns(false)
|
||||
limiter1 = RateLimiter.new(nil, "user_api_day_#{api_key.key}", 10, 60)
|
||||
limiter2 = RateLimiter.new(nil, "user_api_min_#{api_key.key}", 10, 60)
|
||||
limiter1.clear!
|
||||
limiter2.clear!
|
||||
|
||||
SiteSetting.max_user_api_reqs_per_day = 3
|
||||
SiteSetting.max_user_api_reqs_per_minute = 4
|
||||
|
||||
params = {
|
||||
"REQUEST_METHOD" => "GET",
|
||||
"USER_API_KEY" => api_key.key,
|
||||
}
|
||||
|
||||
3.times do
|
||||
provider("/", params).current_user
|
||||
end
|
||||
|
||||
expect {
|
||||
provider("/", params).current_user
|
||||
}.to raise_error(RateLimiter::LimitExceeded)
|
||||
|
||||
|
||||
SiteSetting.max_user_api_reqs_per_day = 4
|
||||
SiteSetting.max_user_api_reqs_per_minute = 3
|
||||
|
||||
limiter1.clear!
|
||||
limiter2.clear!
|
||||
|
||||
3.times do
|
||||
provider("/", params).current_user
|
||||
end
|
||||
|
||||
expect {
|
||||
provider("/", params).current_user
|
||||
}.to raise_error(RateLimiter::LimitExceeded)
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
133
spec/controllers/user_api_keys_controller_spec.rb
Normal file
133
spec/controllers/user_api_keys_controller_spec.rb
Normal file
|
@ -0,0 +1,133 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe UserApiKeysController do
|
||||
|
||||
let :public_key do
|
||||
<<TXT
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDh7BS7Ey8hfbNhlNAW/47pqT7w
|
||||
IhBz3UyBYzin8JurEQ2pY9jWWlY8CH147KyIZf1fpcsi7ZNxGHeDhVsbtUKZxnFV
|
||||
p16Op3CHLJnnJKKBMNdXMy0yDfCAHZtqxeBOTcCo1Vt/bHpIgiK5kmaekyXIaD0n
|
||||
w0z/BYpOgZ8QwnI5ZwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
TXT
|
||||
end
|
||||
|
||||
let :private_key do
|
||||
<<TXT
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICWwIBAAKBgQDh7BS7Ey8hfbNhlNAW/47pqT7wIhBz3UyBYzin8JurEQ2pY9jW
|
||||
WlY8CH147KyIZf1fpcsi7ZNxGHeDhVsbtUKZxnFVp16Op3CHLJnnJKKBMNdXMy0y
|
||||
DfCAHZtqxeBOTcCo1Vt/bHpIgiK5kmaekyXIaD0nw0z/BYpOgZ8QwnI5ZwIDAQAB
|
||||
AoGAeHesbjzCivc+KbBybXEEQbBPsThY0Y+VdgD0ewif2U4UnNhzDYnKJeTZExwQ
|
||||
vAK2YsRDV3KbhljnkagQduvmgJyCKuV/CxZvbJddwyIs3+U2D4XysQp3e1YZ7ROr
|
||||
YlOIoekHCx1CNm6A4iImqGxB0aJ7Owdk3+QSIaMtGQWaPTECQQDz2UjJ+bomguNs
|
||||
zdcv3ZP7W3U5RG+TpInSHiJXpt2JdNGfHItozGJCxfzDhuKHK5Cb23bgldkvB9Xc
|
||||
p/tngTtNAkEA7S4cqUezA82xS7aYPehpRkKEmqzMwR3e9WeL7nZ2cdjZAHgXe49l
|
||||
3mBhidEyRmtPqbXo1Xix8LDuqik0IdnlgwJAQeYTnLnHS8cNjQbnw4C/ECu8Nzi+
|
||||
aokJ0eXg5A0tS4ttZvGA31Z0q5Tz5SdbqqnkT6p0qub0JZiZfCNNdsBe9QJAaGT5
|
||||
fJDwfGYW+YpfLDCV1bUFhMc2QHITZtSyxL0jmSynJwu02k/duKmXhP+tL02gfMRy
|
||||
vTMorxZRllgYeCXeXQJAEGRXR8/26jwqPtKKJzC7i9BuOYEagqj0nLG2YYfffCMc
|
||||
d3JGCf7DMaUlaUE8bJ08PtHRJFSGkNfDJLhLKSjpbw==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
TXT
|
||||
end
|
||||
|
||||
let :args do
|
||||
{
|
||||
access: 'r',
|
||||
client_id: "x"*32,
|
||||
auth_redirect: 'http://over.the/rainbow',
|
||||
application_name: 'foo',
|
||||
public_key: public_key,
|
||||
nonce: SecureRandom.hex
|
||||
}
|
||||
end
|
||||
|
||||
context 'create' do
|
||||
|
||||
it "does not allow anon" do
|
||||
expect {
|
||||
post :create, args
|
||||
}.to raise_error(Discourse::NotLoggedIn)
|
||||
end
|
||||
|
||||
it "refuses to redirect to disallowed place" do
|
||||
log_in_user(Fabricate(:user))
|
||||
post :create, args
|
||||
expect(response.code).to eq("403")
|
||||
end
|
||||
|
||||
it "will not create token unless TL is met" do
|
||||
SiteSetting.min_trust_level_for_user_api_key = 2
|
||||
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
|
||||
|
||||
user = Fabricate(:user, trust_level: 1)
|
||||
|
||||
log_in_user(user)
|
||||
|
||||
post :create, args
|
||||
expect(response.code).to eq("403")
|
||||
|
||||
end
|
||||
|
||||
it "will deny access if requesting more rights than allowed" do
|
||||
|
||||
SiteSetting.min_trust_level_for_user_api_key = 0
|
||||
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
|
||||
SiteSetting.allow_read_user_api_keys = false
|
||||
|
||||
user = Fabricate(:user, trust_level: 0)
|
||||
|
||||
log_in_user(user)
|
||||
|
||||
post :create, args
|
||||
expect(response.code).to eq("403")
|
||||
|
||||
end
|
||||
|
||||
it "will redirect correctly with valid token" do
|
||||
|
||||
SiteSetting.min_trust_level_for_user_api_key = 0
|
||||
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
|
||||
SiteSetting.allowed_user_api_push_urls = "https://push.it/here"
|
||||
SiteSetting.allow_write_user_api_keys = true
|
||||
|
||||
args[:access] = "prw"
|
||||
args[:push_url] = "https://push.it/here"
|
||||
|
||||
user = Fabricate(:user, trust_level: 0)
|
||||
|
||||
log_in_user(user)
|
||||
|
||||
post :create, args
|
||||
expect(response.code).to eq("302")
|
||||
|
||||
uri = URI.parse(response.redirect_url)
|
||||
|
||||
query = uri.query
|
||||
payload = query.split("payload=")[1]
|
||||
encrypted = Base64.decode64(CGI.unescape(payload))
|
||||
|
||||
key = OpenSSL::PKey::RSA.new(private_key)
|
||||
|
||||
parsed = JSON.parse(key.private_decrypt(encrypted))
|
||||
|
||||
expect(parsed["nonce"]).to eq(args[:nonce])
|
||||
|
||||
api_key = UserApiKey.find_by(key: parsed["key"])
|
||||
|
||||
expect(api_key.user_id).to eq(user.id)
|
||||
expect(api_key.read).to eq(true)
|
||||
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")
|
||||
|
||||
|
||||
uri.query = ""
|
||||
expect(uri.to_s).to eq(args[:auth_redirect] + "?")
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue