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:
Sam 2016-08-15 17:58:33 +10:00
parent d3c8985030
commit fc095acaaa
10 changed files with 396 additions and 0 deletions

View 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

View 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)
#

View file

@ -3006,6 +3006,7 @@ en:
developer: 'Developer'
embedding: "Embedding"
legal: "Legal"
user_api: 'User API'
uncategorized: 'Other'
backups: "Backups"
login: "Login"

View file

@ -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."

View file

@ -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

View file

@ -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

View 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

View file

@ -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"]

View file

@ -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

View 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