api keys: add IP whitelist and API permission system.
Add the ability to restrict API keys so that they can only be used with certain IP addresses or certain API endpoints. Restricting your key is useful to limit damage in case it gets leaked or stolen. For example, if your key is on a remote server and it gets hacked, or if you accidentally check-in your key to Github. Restricting your key's API permissions is useful if a third-party app or script wants your key, but you don't want to give full access to your account. If you're an app or userscript developer, and your app needs an API key from the user, you should only request a key with the minimum permissions needed by your app. If you have a privileged account, and you have scripts running under your account, you are highly encouraged to restrict your key to limit damage in case your key gets leaked or stolen.
This commit is contained in:
@@ -1,23 +1,34 @@
|
||||
class ApiKeysController < ApplicationController
|
||||
respond_to :html, :json, :xml
|
||||
|
||||
def new
|
||||
@api_key = authorize ApiKey.new(user: CurrentUser.user, **permitted_attributes(ApiKey))
|
||||
respond_with(@api_key)
|
||||
end
|
||||
|
||||
def create
|
||||
@api_key = authorize ApiKey.new(user: CurrentUser.user)
|
||||
@api_key = authorize ApiKey.new(user: CurrentUser.user, **permitted_attributes(ApiKey))
|
||||
@api_key.save
|
||||
respond_with(@api_key, location: user_api_keys_path(CurrentUser.user.id))
|
||||
end
|
||||
|
||||
def edit
|
||||
@api_key = authorize ApiKey.find(params[:id])
|
||||
respond_with(@api_key)
|
||||
end
|
||||
|
||||
def update
|
||||
@api_key = authorize ApiKey.find(params[:id])
|
||||
@api_key.update(permitted_attributes(@api_key))
|
||||
respond_with(@api_key, location: user_api_keys_path(CurrentUser.user.id))
|
||||
end
|
||||
|
||||
def index
|
||||
params[:search][:user_id] = params[:user_id] if params[:user_id].present?
|
||||
@api_keys = authorize ApiKey.visible(CurrentUser.user).paginated_search(params, count_pages: true)
|
||||
respond_with(@api_keys)
|
||||
end
|
||||
|
||||
def show
|
||||
@api_key = authorize ApiKey.find(params[:id])
|
||||
respond_with(@api_key)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_key = authorize ApiKey.find(params[:id])
|
||||
@api_key.destroy
|
||||
|
||||
@@ -69,6 +69,13 @@ form.simple_form {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.stacked-hints {
|
||||
span.hint {
|
||||
display: block;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.inline-form {
|
||||
|
||||
@@ -60,7 +60,7 @@ class SessionLoader
|
||||
end
|
||||
|
||||
def has_api_authentication?
|
||||
request.authorization.present? || params[:login].present? || params[:api_key].present?
|
||||
request.authorization.present? || params[:login].present? || (params[:api_key].present? && params[:api_key].is_a?(String))
|
||||
end
|
||||
|
||||
private
|
||||
@@ -87,9 +87,10 @@ class SessionLoader
|
||||
authenticate_api_key(login, api_key)
|
||||
end
|
||||
|
||||
def authenticate_api_key(name, api_key)
|
||||
user = User.find_by_name(name)&.authenticate_api_key(api_key)
|
||||
def authenticate_api_key(name, key)
|
||||
user, api_key = User.find_by_name(name)&.authenticate_api_key(key)
|
||||
raise AuthenticationFailure if user.blank?
|
||||
raise User::PrivilegeError if !api_key.has_permission?(request.remote_ip, request.params[:controller], request.params[:action])
|
||||
CurrentUser.user = user
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
array_attribute :permissions
|
||||
array_attribute :permitted_ip_addresses
|
||||
|
||||
normalize :permissions, :normalize_permissions
|
||||
normalize :name, :normalize_text
|
||||
|
||||
belongs_to :user
|
||||
validates_uniqueness_of :key
|
||||
validate :validate_permissions, if: :permissions_changed?
|
||||
validates :key, uniqueness: true, if: :key_changed?
|
||||
has_secure_token :key
|
||||
|
||||
def self.visible(user)
|
||||
@@ -16,4 +23,45 @@ class ApiKey < ApplicationRecord
|
||||
q = q.apply_default_order(params)
|
||||
q
|
||||
end
|
||||
|
||||
concerning :PermissionMethods do
|
||||
def has_permission?(ip, controller, action)
|
||||
ip_permitted?(ip) && action_permitted?(controller, action)
|
||||
end
|
||||
|
||||
def ip_permitted?(ip)
|
||||
return true if permitted_ip_addresses.empty?
|
||||
permitted_ip_addresses.any? { |permitted_ip| ip.in?(permitted_ip) }
|
||||
end
|
||||
|
||||
def action_permitted?(controller, action)
|
||||
return true if permissions.empty?
|
||||
|
||||
permissions.any? do |permission|
|
||||
permission == "#{controller}:#{action}"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_permissions
|
||||
permissions.each do |permission|
|
||||
if !permission.in?(ApiKey.permissions_list)
|
||||
errors.add(:permissions, "can't allow invalid permission '#{permission}'")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def normalize_permissions(permissions)
|
||||
permissions.compact_blank
|
||||
end
|
||||
|
||||
def permissions_list
|
||||
Rails.application.routes.routes.select do |route|
|
||||
route.defaults[:controller].present? && !route.internal
|
||||
end.map do |route|
|
||||
"#{route.defaults[:controller]}:#{route.defaults[:action]}"
|
||||
end.uniq.sort
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -209,7 +209,7 @@ class User < ApplicationRecord
|
||||
|
||||
def authenticate_api_key(key)
|
||||
api_key = api_keys.find_by(key: key)
|
||||
api_key.present? && ActiveSupport::SecurityUtils.secure_compare(api_key.key, key) && self
|
||||
api_key.present? && ActiveSupport::SecurityUtils.secure_compare(api_key.key, key) && [self, api_key]
|
||||
end
|
||||
|
||||
def authenticate_password(password)
|
||||
@@ -560,6 +560,7 @@ class User < ApplicationRecord
|
||||
]
|
||||
end
|
||||
|
||||
# XXX
|
||||
def api_token
|
||||
api_keys.first.try(:key)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
class ApiKeyPolicy < ApplicationPolicy
|
||||
def new?
|
||||
!user.is_anonymous?
|
||||
end
|
||||
|
||||
def create?
|
||||
!user.is_anonymous?
|
||||
end
|
||||
@@ -7,10 +11,22 @@ class ApiKeyPolicy < ApplicationPolicy
|
||||
!user.is_anonymous?
|
||||
end
|
||||
|
||||
def edit?
|
||||
record.user == user
|
||||
end
|
||||
|
||||
def update?
|
||||
record.user == user
|
||||
end
|
||||
|
||||
def destroy?
|
||||
record.user == user
|
||||
end
|
||||
|
||||
def permitted_attributes
|
||||
[:name, :permitted_ip_addresses, permissions: []]
|
||||
end
|
||||
|
||||
def api_attributes
|
||||
super - [:key]
|
||||
end
|
||||
|
||||
6
app/views/api_keys/_form.html.erb
Normal file
6
app/views/api_keys/_form.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<%= edit_form_for(api_key, html: { class: "stacked-hints" }) do |f| %>
|
||||
<%= f.input :name, as: :string, hint: "An optional name to help you remember what this key is for." %>
|
||||
<%= f.input :permitted_ip_addresses, label: "IP Addresses", as: :string, hint: "An optional list of IPs allowed to use this key. Leave blank to allow all IPs." %>
|
||||
<%= f.input :permissions, as: :select, collection: ApiKey.permissions_list, hint: "An optional list of API endpoints this key can use. Ctrl+click to select multiple endpoints. Leave blank to allow all API endpoints.", input_html: { multiple: true, size: 10 } %>
|
||||
<%= f.submit "Create" %>
|
||||
<% end %>
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for(:secondary_links) do %>
|
||||
<%= subnav_link_to "Listing", user_api_keys_path(CurrentUser.user.id) %>
|
||||
<%= subnav_link_to "New", user_api_keys_path(CurrentUser.user.id), method: :post %>
|
||||
<%= subnav_link_to "New", new_user_api_key_path(CurrentUser.user.id) %>
|
||||
<%= subnav_link_to "Help", wiki_page_path("help:api") %>
|
||||
<% end %>
|
||||
|
||||
8
app/views/api_keys/edit.html.erb
Normal file
8
app/views/api_keys/edit.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<%= render "secondary_links" %>
|
||||
|
||||
<div id="c-api-keys">
|
||||
<div id="a-edit">
|
||||
<h1>Edit API Key</h1>
|
||||
<%= render "form", api_key: @api_key %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="page-heading">
|
||||
<h1>API Keys</h1>
|
||||
|
||||
<%= link_to user_api_keys_path(CurrentUser.user.id), class: "button-primary", method: :post do %>
|
||||
<%= link_to new_user_api_key_path(CurrentUser.user.id), class: "button-primary" do %>
|
||||
<%= plus_icon %> Add
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<p>If you're a developer, you can use an API key to access the
|
||||
<%= link_to_wiki "#{Danbooru.config.canonical_app_name} API", "help:api" %>. If you're not a
|
||||
developer, you probably don't need an API key.</p>
|
||||
developer, and you're not using any third-party apps, then you probably don't need an API key.</p>
|
||||
|
||||
<p><strong>Your API key is like your password</strong>. Anyone who has it has full access to
|
||||
your account. Don't give your API key to apps or people you don't trust, and don't post your
|
||||
@@ -37,11 +37,20 @@
|
||||
<% end %>
|
||||
|
||||
<% if params[:user_id].present? && !@api_keys.present? %>
|
||||
<%= link_to "Create API key", user_api_keys_path(CurrentUser.user.id), method: :post %>
|
||||
<%= link_to "Create API key", new_user_api_key_path(CurrentUser.user.id) %>
|
||||
<% else %>
|
||||
<%= table_for @api_keys, width: "100%", class: "striped autofit" do |t| %>
|
||||
<% t.column :name %>
|
||||
<% t.column :key, td: { class: "col-expand" } %>
|
||||
|
||||
<% t.column :permissions do |api_key| %>
|
||||
<%= safe_join(api_key.permissions, "<br>".html_safe).presence || "All" %>
|
||||
<% end %>
|
||||
|
||||
<% t.column "IPs" do |api_key| %>
|
||||
<%= safe_join(api_key.permitted_ip_addresses, "<br>".html_safe).presence || "All" %>
|
||||
<% end %>
|
||||
|
||||
<% if !params[:user_id].present? %>
|
||||
<% t.column "User" do |api_key| %>
|
||||
<%= link_to_user api_key.user %>
|
||||
@@ -53,7 +62,8 @@
|
||||
<% end %>
|
||||
|
||||
<% t.column column: "control" do |api_key| %>
|
||||
<%= link_to "Delete", api_key, method: :delete %>
|
||||
<%= link_to "Edit", edit_api_key_path(api_key) %>
|
||||
| <%= link_to "Delete", api_key, method: :delete %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
|
||||
8
app/views/api_keys/new.html.erb
Normal file
8
app/views/api_keys/new.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<%= render "secondary_links" %>
|
||||
|
||||
<div id="c-api-keys">
|
||||
<div id="a-new">
|
||||
<h1>New API Key</h1>
|
||||
<%= render "form", api_key: @api_key %>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user