Adding OAuth access scopes, fixing OAuth authorization UI, adding rate limiting

to the API
This commit is contained in:
Eugen Rochko 2016-10-22 19:38:47 +02:00
parent 17122df80d
commit a9e40a3d80
26 changed files with 195 additions and 99 deletions

View file

@ -1,14 +1,60 @@
Rails: Rails:
Enabled: true Enabled: true
Metrics/LineLength:
Enabled: false
Style/PerlBackrefs: Style/PerlBackrefs:
AutoCorrect: false AutoCorrect: false
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
Enabled: false Enabled: false
Documentation: Metrics/BlockNesting:
Max: 2
Metrics/LineLength:
AllowURI: true
Enabled: false Enabled: false
Metrics/MethodLength:
CountComments: false
Max: 10
Metrics/ModuleLength:
Max: 100
Metrics/ParameterLists:
Max: 4
CountKeywordArgs: true
Style/AccessModifierIndentation:
EnforcedStyle: indent
Style/CollectionMethods:
Enabled: true
PreferredMethods:
find_all: 'select'
Style/Documentation:
Enabled: false
Style/DoubleNegation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/SpaceInsideHashLiteralBraces:
EnforcedStyle: space
Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: 'comma'
Style/RegexpLiteral:
Enabled: false
AllCops:
TargetRubyVersion: 2.2
Exclude:
- 'spec/**/*'
- 'db/**/*'
- 'app/views/**/*'
- 'config/**/*'

View file

@ -85,18 +85,7 @@
} }
} }
.prompt { code {
font-size: 16px;
color: #9baec8;
text-align: center;
.prompt-highlight {
font-weight: 500;
color: #fff;
}
}
code.copypasteable {
display: block; display: block;
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
font-weight: 400; font-weight: 400;
@ -110,6 +99,7 @@
.actions { .actions {
margin-top: 30px; margin-top: 30px;
}
button { button {
display: block; display: block;
@ -149,7 +139,6 @@
} }
} }
} }
}
.flash-message { .flash-message {
background: #282c37; background: #282c37;
@ -180,3 +169,18 @@
} }
} }
.oauth-prompt {
margin-bottom: 30px;
text-align: center;
color: #9baec8;
h2 {
font-size: 16px;
margin-bottom: 30px;
}
strong {
color: #d9e1e8;
font-weight: 500;
}
}

View file

@ -1,7 +1,7 @@
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading. # Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
class PublicChannel < ApplicationCable::Channel class PublicChannel < ApplicationCable::Channel
def subscribed def subscribed
stream_from 'timeline:public', -> (encoded_message) do stream_from 'timeline:public', lambda do |encoded_message|
message = ActiveSupport::JSON.decode(encoded_message) message = ActiveSupport::JSON.decode(encoded_message)
status = Status.find_by(id: message['id']) status = Status.find_by(id: message['id'])

View file

@ -1,5 +1,7 @@
class Api::V1::AccountsController < ApiController class Api::V1::AccountsController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
before_action :set_account, except: [:verify_credentials, :suggestions] before_action :set_account, except: [:verify_credentials, :suggestions]
respond_to :json respond_to :json

View file

@ -1,5 +1,5 @@
class Api::V1::FollowsController < ApiController class Api::V1::FollowsController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :follow }
respond_to :json respond_to :json
def create def create

View file

@ -1,5 +1,5 @@
class Api::V1::MediaController < ApiController class Api::V1::MediaController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :write }
respond_to :json respond_to :json
def create def create

View file

@ -1,5 +1,7 @@
class Api::V1::StatusesController < ApiController class Api::V1::StatusesController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
respond_to :json respond_to :json
def show def show

View file

@ -1,7 +1,10 @@
class ApiController < ApplicationController class ApiController < ApplicationController
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
before_action :set_rate_limit_headers
rescue_from ActiveRecord::RecordInvalid do |e| rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: e.to_s }, status: 422 render json: { error: e.to_s }, status: 422
end end
@ -22,8 +25,27 @@ class ApiController < ApplicationController
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end end
def doorkeeper_unauthorized_render_options(*)
{ json: { error: 'Not authorized' } }
end
def doorkeeper_forbidden_render_options(*)
{ json: { error: 'This action is outside the authorized scopes' } }
end
protected protected
def set_rate_limit_headers
return if request.env['rack.attack.throttle_data'].nil?
now = Time.now.utc
match_data = request.env['rack.attack.throttle_data']['api']
response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s
response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s
response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
end
def current_resource_owner def current_resource_owner
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end end

View file

@ -15,6 +15,6 @@ class HomeController < ApplicationController
end end
def find_or_create_access_token def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?) Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, 'read write follow', Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
end end
end end

View file

@ -0,0 +1,9 @@
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
before_action :store_current_location
private
def store_current_location
store_location_for(:user, request.url)
end
end

View file

@ -7,7 +7,7 @@ class Feed
def get(limit, max_id = nil, since_id = nil) def get(limit, max_id = nil, since_id = nil)
max_id = '+inf' if max_id.blank? max_id = '+inf' if max_id.blank?
since_id = '-inf' if since_id.blank? since_id = '-inf' if since_id.blank?
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).collect(&:last).map(&:to_i) unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
# If we're after most recent items and none are there, we need to precompute the feed # If we're after most recent items and none are there, we need to precompute the feed
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf' if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'

View file

@ -35,6 +35,7 @@ class MediaAttachment < ApplicationRecord
end end
private private
def self.file_styles(f) def self.file_styles(f)
if f.instance.image? if f.instance.image?
{ {

View file

@ -1,4 +0,0 @@
.prompt= t('doorkeeper.authorizations.error.title')
#error_explanation
= @pre_auth.error_response.body[:error_description]

View file

@ -1,26 +0,0 @@
.prompt= raw t('.prompt', client_name: "<strong class=\"prompt-highlight\">#{ @pre_auth.client.name }</strong>")
/- if @pre_auth.scopes.count > 0
/ .scope-permission-prompt
/ %p= t('.able_to')
/ %ul.scope-permissions
/ - @pre_auth.scopes.each do |scope|
/ %li= t scope, scope: [:doorkeeper, :scopes]
.actions
= form_tag oauth_authorization_path, method: :post do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit
= form_tag oauth_authorization_path, method: :delete do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative'

View file

@ -1,2 +0,0 @@
.prompt= t('.title')
%code.copypasteable= params[:code]

View file

@ -0,0 +1,2 @@
.flash-message#error_explanation
= @pre_auth.error_response.body[:error_description]

View file

@ -0,0 +1,25 @@
.oauth-prompt
%h2
Application
%strong=@pre_auth.client.name
requests access to your account
%p
It will be able to
= @pre_auth.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.map { |s| "<strong>#{s}</strong>"}.to_sentence.html_safe
= form_tag oauth_authorization_path, method: :post, class: 'simple_form' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit
= form_tag oauth_authorization_path, method: :delete, class: 'simple_form' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative'

View file

@ -0,0 +1 @@
%code= params[:code]

View file

@ -12,7 +12,7 @@ Rails.application.configure do
# Full error reports are disabled and caching is turned on. # Full error reports are disabled and caching is turned on.
config.consider_all_requests_local = false config.consider_all_requests_local = false
config.action_controller.perform_caching = false config.action_controller.perform_caching = true
# Disable serving static files from the `/public` folder by default since # Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this. # Apache or NGINX already handles this.

View file

@ -50,8 +50,8 @@ Doorkeeper.configure do
# Define access token scopes for your provider # Define access token scopes for your provider
# For more information go to # For more information go to
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
# default_scopes :public default_scopes :read
# optional_scopes :write, :follow optional_scopes :write, :follow
# Change the way client credentials are retrieved from the request object. # Change the way client credentials are retrieved from the request object.
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then

View file

@ -1,5 +1,5 @@
Rabl.configure do |config| Rabl.configure do |config|
config.cache_all_output = true config.cache_all_output = false
config.cache_sources = !!Rails.env.production? config.cache_sources = !!Rails.env.production?
config.include_json_root = false config.include_json_root = false
config.view_paths = [Rails.root.join('app/views')] config.view_paths = [Rails.root.join('app/views')]

View file

@ -1,9 +1,19 @@
class Rack::Attack class Rack::Attack
throttle('get-req/ip', limit: 300, period: 5.minutes) do |req| # Rate limits for the API
req.ip if req.get? throttle('api', limit: 150, period: 5.minutes) do |req|
req.ip if req.path.match(/\A\/api\//)
end end
throttle('post-req/ip', limit: 100, period: 5.minutes) do |req| self.throttled_response = lambda do |env|
req.ip if req.post? now = Time.now.utc
match_data = env['rack.attack.match_data']
headers = {
'X-RateLimit-Limit' => match_data[:limit].to_s,
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
}
[429, headers, [{ error: 'Throttled' }.to_json]]
end end
end end

View file

@ -15,6 +15,10 @@ en:
secured_uri: 'must be an HTTPS/SSL URI.' secured_uri: 'must be an HTTPS/SSL URI.'
doorkeeper: doorkeeper:
scopes:
read: read your account's data
write: post on your behalf
follow: follow, block, unblock and unfollow accounts
applications: applications:
confirmations: confirmations:
destroy: 'Are you sure?' destroy: 'Are you sure?'

View file

@ -7,7 +7,9 @@ Rails.application.routes.draw do
mount Sidekiq::Web => '/sidekiq' mount Sidekiq::Web => '/sidekiq'
end end
use_doorkeeper use_doorkeeper do
controllers authorizations: 'oauth/authorizations'
end
get '.well-known/host-meta', to: 'xrd#host_meta', as: :host_meta get '.well-known/host-meta', to: 'xrd#host_meta', as: :host_meta
get '.well-known/webfinger', to: 'xrd#webfinger', as: :webfinger get '.well-known/webfinger', to: 'xrd#webfinger', as: :webfinger

View file

@ -1,2 +1,2 @@
web_app = Doorkeeper::Application.new(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri) web_app = Doorkeeper::Application.new(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow')
web_app.save! web_app.save!

View file

@ -2,9 +2,7 @@ namespace :mastodon do
namespace :media do namespace :media do
desc 'Removes media attachments that have not been assigned to any status for longer than a day' desc 'Removes media attachments that have not been assigned to any status for longer than a day'
task clear: :environment do task clear: :environment do
MediaAttachment.where(status_id: nil).where('created_at < ?', 1.day.ago).find_each do |m| MediaAttachment.where(status_id: nil).where('created_at < ?', 1.day.ago).find_each(&:destroy)
m.destroy
end
end end
end end