Support for per-user API keys

This commit is contained in:
Robin Ward 2013-10-22 15:53:08 -04:00
parent 5e2d8dcf37
commit 348e2e3ef2
45 changed files with 670 additions and 87 deletions

View file

@ -0,0 +1,67 @@
/**
This controller supports the interface for dealing with API keys
@class AdminApiController
@extends Ember.ArrayController
@namespace Discourse
@module Discourse
**/
Discourse.AdminApiController = Ember.ArrayController.extend({
actions: {
/**
Generates a master api key
@method generateMasterKey
@param {Discourse.ApiKey} the key to regenerate
**/
generateMasterKey: function(key) {
var self = this;
Discourse.ApiKey.generateMasterKey().then(function (key) {
self.get('model').pushObject(key);
});
},
/**
Creates an API key instance with internal user object
@method regenerateKey
@param {Discourse.ApiKey} the key to regenerate
**/
regenerateKey: function(key) {
bootbox.confirm(I18n.t("admin.api.confirm_regen"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
if (result) {
key.regenerate();
}
});
},
/**
Revokes an API key
@method revokeKey
@param {Discourse.ApiKey} the key to revoke
**/
revokeKey: function(key) {
var self = this;
bootbox.confirm(I18n.t("admin.api.confirm_revoke"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
if (result) {
key.revoke().then(function() {
self.get('model').removeObject(key);
});
}
});
}
},
/**
Has a master key already been generated?
@property hasMasterKey
@type {Boolean}
**/
hasMasterKey: function() {
return !!this.get('model').findBy('user', null);
}.property('model.@each')
});

View file

@ -27,6 +27,28 @@ Discourse.AdminUserController = Discourse.ObjectController.extend({
});
this.send('toggleTitleEdit');
},
generateApiKey: function() {
this.get('model').generateApiKey();
},
regenerateApiKey: function() {
var self = this;
bootbox.confirm(I18n.t("admin.api.confirm_regen"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
if (result) {
self.get('model').generateApiKey();
}
});
},
revokeApiKey: function() {
var self = this;
bootbox.confirm(I18n.t("admin.api.confirm_revoke"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
if (result) {
self.get('model').revokeApiKey();
}
});
}
}

View file

@ -1,29 +0,0 @@
Discourse.AdminApi = Discourse.Model.extend({
VALID_KEY_LENGTH: 64,
keyExists: function(){
var key = this.get('key') || '';
return key && key.length === this.VALID_KEY_LENGTH;
}.property('key'),
generateKey: function(){
var adminApi = this;
Discourse.ajax('/admin/api/generate_key', {type: 'POST'}).then(function (result) {
adminApi.set('key', result.key);
});
},
regenerateKey: function(){
alert(I18n.t('not_implemented'));
}
});
Discourse.AdminApi.reopenClass({
find: function() {
var model = Discourse.AdminApi.create();
Discourse.ajax("/admin/api").then(function(data) {
model.setProperties(data);
});
return model;
}
});

View file

@ -8,6 +8,34 @@
**/
Discourse.AdminUser = Discourse.User.extend({
/**
Generates an API key for the user. Will regenerate if they already have one.
@method generateApiKey
@returns {Promise} a promise that resolves to the newly generated API key
**/
generateApiKey: function() {
var self = this;
return Discourse.ajax("/admin/users/" + this.get('id') + "/generate_api_key", {type: 'POST'}).then(function (result) {
var apiKey = Discourse.ApiKey.create(result.api_key);
self.set('api_key', apiKey);
return apiKey;
});
},
/**
Revokes a user's current API key
@method revokeApiKey
@returns {Promise} a promise that resolves when the API key has been deleted
**/
revokeApiKey: function() {
var self = this;
return Discourse.ajax("/admin/users/" + this.get('id') + "/revoke_api_key", {type: 'DELETE'}).then(function (result) {
self.set('api_key', null);
});
},
deleteAllPosts: function() {
this.set('can_delete_all_posts', false);
var user = this;

View file

@ -0,0 +1,81 @@
/**
Our data model for representing an API key in the system
@class ApiKey
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.ApiKey = Discourse.Model.extend({
/**
Regenerates the api key
@method regenerate
@returns {Promise} a promise that resolves to the key
**/
regenerate: function() {
var self = this;
return Discourse.ajax('/admin/api/key', {type: 'PUT', data: {id: this.get('id')}}).then(function (result) {
self.set('key', result.api_key.key);
return self;
});
},
/**
Revokes the current key
@method revoke
@returns {Promise} a promise that resolves when the key has been revoked
**/
revoke: function() {
var self = this;
return Discourse.ajax('/admin/api/key', {type: 'DELETE', data: {id: this.get('id')}});
}
});
Discourse.ApiKey.reopenClass({
/**
Creates an API key instance with internal user object
@method create
@param {Object} the properties to create
@returns {Discourse.ApiKey} the ApiKey instance
**/
create: function(apiKey) {
var result = this._super(apiKey);
if (result.user) {
result.user = Discourse.AdminUser.create(result.user);
}
return result;
},
/**
Finds a list of API keys
@method find
@returns {Promise} a promise that resolves to the array of `Discourse.ApiKey` instances
**/
find: function() {
return Discourse.ajax("/admin/api").then(function(keys) {
return keys.map(function (key) {
return Discourse.ApiKey.create(key);
});
});
},
/**
Generates a master api key and returns it.
@method generateMasterKey
@returns {Promise} a promise that resolves to a master `Discourse.ApiKey`
**/
generateMasterKey: function() {
return Discourse.ajax("/admin/api/key", {type: 'POST'}).then(function (result) {
return Discourse.ApiKey.create(result.api_key);
});
}
});

View file

@ -9,7 +9,7 @@
Discourse.AdminApiRoute = Discourse.Route.extend({
model: function() {
return Discourse.AdminApi.find();
return Discourse.ApiKey.find();
}
});

View file

@ -1,13 +1,33 @@
<h3>{{i18n admin.api.long_title}}</h3>
{{#if keyExists}}
<strong>{{i18n admin.api.key}}:</strong> {{key}}
<button class='btn' {{action regenerateKey target="model"}}>
{{i18n admin.api.regenerate}}
</button>
<p>{{{i18n admin.api.note_html}}}</p>
{{#if model}}
<table class='api-keys'>
<tr>
<th>{{i18n admin.api.key}}</th>
<th>{{i18n admin.api.user}}</th>
<th>&nbsp;</th>
</tr>
{{#each model}}
<tr>
<td class='key'>{{key}}</td>
<td>
{{#if user}}
{{#link-to 'adminUser' user}}
{{avatar user imageSize="small"}}
{{/link-to}}
{{else}}
{{i18n admin.api.all_users}}
{{/if}}
</td>
<td>
<button class='btn' {{action regenerateKey this}}>{{i18n admin.api.regenerate}}</button>
<button class='btn' {{action revokeKey this}}>{{i18n admin.api.revoke}}</button>
</td>
</tr>
{{/each}}
</table>
{{else}}
<p>{{{i18n admin.api.info_html}}}</p>
<button class='btn' {{action generateKey target="model"}}>
{{i18n admin.api.generate}}
</button>
<p>{{i18n admin.api.none}}</p>
{{/if}}
{{#unless hasMasterKey}}
<button class='btn' {{action generateMasterKey}}>{{i18n admin.api.generate_master}}</button>
{{/unless }}

View file

@ -125,6 +125,25 @@
</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.api.key}}</div>
{{#if api_key}}
<div class='long-value'>
{{api_key.key}}
<button class='btn' {{action regenerateApiKey}}>{{i18n admin.api.regenerate}}</button>
<button {{action revokeApiKey}} class="btn">{{i18n admin.api.revoke}}</button>
</div>
{{else}}
<div class='value'>
&mdash;
</div>
<div class='controls'>
<button {{action generateApiKey}} class="btn">{{i18n admin.api.generate}}</button>
</div>
{{/if}}
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.admin}}</div>
<div class='value'>{{admin}}</div>

View file

@ -206,6 +206,17 @@ table {
float: left;
margin-left: 12px;
}
.long-value {
width: 800px;
float: left;
margin-left: 12px;
font-size: 13px;
button {
margin-left: 10px;
}
}
.controls {
.btn {
margin-right: 5px;
@ -374,6 +385,24 @@ table {
}
}
table.api-keys {
margin-top: 10px;
width: 100%;
th {
text-align: left;
padding: 5px;
}
td {
padding: 5px;
}
td.key {
font-size: 12px;
}
}
.dashboard-stats {
margin-bottom: 30px;
width: 460px;

View file

@ -1,10 +1,28 @@
class Admin::ApiController < Admin::AdminController
def index
render json: {key: SiteSetting.api_key}
render_serialized(ApiKey.all, ApiKeySerializer)
end
def generate_key
SiteSetting.generate_api_key!
render json: {key: SiteSetting.api_key}
def regenerate_key
api_key = ApiKey.where(id: params[:id]).first
raise Discourse::NotFound.new if api_key.blank?
api_key.regenerate!(current_user)
render_serialized(api_key, ApiKeySerializer)
end
def revoke_key
api_key = ApiKey.where(id: params[:id]).first
raise Discourse::NotFound.new if api_key.blank?
api_key.destroy
render nothing: true
end
def create_master_key
api_key = ApiKey.create_master_key
render_serialized(api_key, ApiKeySerializer)
end
end

View file

@ -4,7 +4,21 @@ require_dependency 'boost_trust_level'
class Admin::UsersController < Admin::AdminController
before_filter :fetch_user, only: [:ban, :unban, :refresh_browsers, :revoke_admin, :grant_admin, :revoke_moderation, :grant_moderation, :approve, :activate, :deactivate, :block, :unblock, :trust_level]
before_filter :fetch_user, only: [:ban,
:unban,
:refresh_browsers,
:revoke_admin,
:grant_admin,
:revoke_moderation,
:grant_moderation,
:approve,
:activate,
:deactivate,
:block,
:unblock,
:trust_level,
:generate_api_key,
:revoke_api_key]
def index
query = ::AdminUserIndexQuery.new(params)
@ -52,6 +66,16 @@ class Admin::UsersController < Admin::AdminController
render nothing: true
end
def generate_api_key
api_key = @user.generate_api_key(current_user)
render_serialized(api_key, ApiKeySerializer)
end
def revoke_api_key
@user.revoke_api_key
render nothing: true
end
def grant_admin
guardian.ensure_can_grant_admin!(@user)
@user.grant_admin!

View file

@ -279,7 +279,7 @@ class ApplicationController < ActionController::Base
protected
def api_key_valid?
request["api_key"] && SiteSetting.api_key_valid?(request["api_key"])
request["api_key"] && ApiKey.where(key: request["api_key"]).exists?
end
# returns an array of integers given a param key

22
app/models/api_key.rb Normal file
View file

@ -0,0 +1,22 @@
class ApiKey < ActiveRecord::Base
belongs_to :user
belongs_to :created_by, class_name: User
validates_presence_of :key
validates_uniqueness_of :user_id
def regenerate!(updated_by)
self.key = SecureRandom.hex(32)
self.created_by = updated_by
save!
end
def self.create_master_key
api_key = ApiKey.where('user_id IS NULL').first
if api_key.blank?
api_key = ApiKey.create(key: SecureRandom.hex(32), created_by: Discourse.system_user)
end
api_key
end
end

View file

@ -18,7 +18,6 @@ class SiteSetting < ActiveRecord::Base
client_setting(:tos_url, '')
client_setting(:faq_url, '')
client_setting(:privacy_policy_url, '')
setting(:api_key, '')
client_setting(:traditional_markdown_linebreaks, false)
client_setting(:top_menu, 'latest|new|unread|favorited|categories')
client_setting(:post_menu, 'like|edit|flag|delete|share|bookmark|reply')
@ -273,15 +272,6 @@ class SiteSetting < ActiveRecord::Base
setting(:dominating_topic_minimum_percent, 20)
def self.generate_api_key!
self.api_key = SecureRandom.hex(32)
end
def self.api_key_valid?(tested)
t = tested.strip
t.length == 64 && t == self.api_key
end
def self.call_discourse_hub?
self.enforce_global_nicknames? && self.discourse_org_access_key.present?
end

View file

@ -43,6 +43,7 @@ class User < ActiveRecord::Base
has_many :secure_categories, through: :groups, source: :categories
has_one :user_search_data, dependent: :destroy
has_one :api_key, dependent: :destroy
belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy
@ -479,6 +480,19 @@ class User < ActiveRecord::Base
self.save!
end
def generate_api_key(created_by)
if api_key.present?
api_key.regenerate!(created_by)
api_key
else
ApiKey.create(user: self, key: SecureRandom.hex(32), created_by: created_by)
end
end
def revoke_api_key
ApiKey.where(user_id: self.id).delete_all
end
protected
def cook
@ -567,6 +581,7 @@ class User < ActiveRecord::Base
end
end
private
end

View file

@ -16,6 +16,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:can_be_deleted
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :api_key, serializer: ApiKeySerializer, embed: :objects
def can_revoke_admin
scope.can_revoke_admin?(object)
@ -49,4 +50,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
object.topics.count
end
def include_api_key?
api_key.present?
end
end

View file

@ -0,0 +1,12 @@
class ApiKeySerializer < ApplicationSerializer
attributes :id,
:key
has_one :user, serializer: BasicUserSerializer, embed: :objects
def include_user_id?
!object.user_id.nil?
end
end

View file

@ -1169,12 +1169,18 @@ en:
delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed."
api:
generate_master: "Generate Master API Key"
none: "There are no active API keys right now."
user: "User"
title: "API"
long_title: "API Information"
key: "Key"
generate: "Generate API Key"
regenerate: "Regenerate API Key"
key: "API Key"
generate: "Generate"
regenerate: "Regenerate"
revoke: "Revoke"
confirm_regen: "Are you sure you want to replace that API Key with a new one?"
confirm_revoke: "Are you sure you want to revoke that key?"
info_html: "Your API key will allow you to create and update topics using JSON calls."
all_users: "All Users"
note_html: "Keep this key <strong>secret</strong>, all users that have it may create arbitrary posts on the forum as any user."
customize:

View file

@ -493,7 +493,6 @@ cs:
company_full_name: "Plné jméno společnosti, která provozuje tento web, používá se v dokumentech jako je /tos"
company_short_name: "Krátké jméno společnosti, která provozuje tento web, používá se v dokumentech jako je /tos"
company_domain: "Doménové jméno vlastněné společností, která provozuje tento web, používá se v dokumentech jako je /tos"
api_key: "Zabezpečený API klíč, který se používá pro vytváření a aktualizaci témat, použijte sekci /admin/api k nastavení"
queue_jobs: "Zařazovat úlohy do fronty v sidekiq, není-li nastaveno, jsou úlohy vyřizovány okamžitě"
crawl_images: "Povolit získávání obrázků z webů třetích stran"
ninja_edit_window: "Jak rychle smíte udělat změnu, aniž by se uložila jako nová verze"

View file

@ -463,7 +463,6 @@ de:
company_full_name: "Voller Name des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
company_short_name: "Kurzname des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
company_domain: "Domainname des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
api_key: "Sicherer API-Schlüssel, um Themen zu erstellen und zu aktualisieren. Benutze /admin/api, um ihn einzurichten."
queue_jobs: "Benutze die Sidekiq-Queue, falls falsche Queues inline sind."
crawl_images: "Lade Bilder von Dritten herunter, um ihre Höhe und Breite zu bestimmen."
ninja_edit_window: "Sekunden nach Empfang eines Beitrag, in denen Bearbeitungen nicht als neue Version gelten."

View file

@ -495,7 +495,6 @@ en:
company_full_name: "The full name of the company that runs this site, used in legal documents like the /tos"
company_short_name: "The short name of the company that runs this site, used in legal documents like the /tos"
company_domain: "The domain name owned by the company that runs this site, used in legal documents like the /tos"
api_key: "The secure API key used to create and update topics, use the /admin/api section to set it up"
queue_jobs: "DEVELOPER ONLY! WARNING! By default, queue jobs in sidekiq. If disabled, your site will be broken."
crawl_images: "Enable retrieving images from third party sources to insert width and height dimensions"
ninja_edit_window: "Number of seconds after posting where edits do not create a new version"

View file

@ -460,7 +460,6 @@ fr:
company_full_name: "Le nom complet de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
company_short_name: "Le nom de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
company_domain: "Le nom de domaine de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
api_key: "La clé API sécurisé à utiliser pour créer et mettre à jour des discussions. Utilisez la section /admin/api pour la configurer."
queue_jobs: "DÉVELOPPEURS SEULEMENT ! ATTENTION ! Par défaut, empiler les travaux dans sidekiq. Si désactivé, votre site sera cassé."
crawl_images: "Permettre aux images provenant de sources tierces d'insérer la hauteur et la largeur de celles-ci"
ninja_edit_window: "Temps d'édition avant de sauvegarder une nouvelle version, en secondes."

View file

@ -423,7 +423,6 @@ it:
company_full_name: "Il nome completo di chi gestisce il sito, usato in documenti legali come /tos"
company_short_name: "Il nome abbreviato di chi gestisce il sito, usato in documenti legali come /tos"
company_domain: "Il dominio di chi gestisce il sito, usato in documenti legali come /tos"
api_key: "La chiave API segreta usata per creare e aggiornare topic, usa la sezione /admin/api per impostarla"
queue_jobs: "Metti in coda diversi job in sidekiq, se false le code sono inline"
crawl_images: "Abilita la ricezione di immagini da sorgenti terze parti"
ninja_edit_window: "Numero di secondi trascorsi affinché una modifica del post appena inviato, non venga considerata come nuova revisione"

View file

@ -417,7 +417,6 @@ ko:
company_full_name: "The full name of the company that runs this site, used in legal documents like the /tos"
company_short_name: "The short name of the company that runs this site, used in legal documents like the /tos"
company_domain: "The domain name owned by the company that runs this site, used in legal documents like the /tos"
api_key: "The secure API key used to create and update topics, use the /admin/api section to set it up"
access_password: "When restricted access is enabled, this password must be entered"
queue_jobs: "Queue various jobs in sidekiq, if false queues are inline"
crawl_images: "Enable retrieving images from third party sources to insert width and height dimensions"

View file

@ -465,7 +465,6 @@ nl:
company_full_name: "De volledige naam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
company_short_name: "De korte naam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
company_domain: "De domeinnaam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
api_key: "De beveiligde API-sleutel wordt gebruikt om topics te maken en bij te werken. Gebruik /admin/api om deze in te stellen"
queue_jobs: "DEVELOPERS ONLY! WARNING! Zet verschillende taken in een queue binnen sidekiq, bij 'false' worden taken ineens uitgevoerd"
crawl_images: Zet het ophalen van afbeeldingen van externe bronnen aan
ninja_edit_window: "Hoe snel je een aanpassing kan maken zonder dat er een nieuwe versie wordt opgeslagen, in seconden."

View file

@ -543,8 +543,6 @@ pseudo:
íɳ łéǧáł ďóčůɱéɳťš łíǩé ťĥé /ťóš ]]'
company_domain: '[[ Ťĥé ďóɱáíɳ ɳáɱé óŵɳéď ƀý ťĥé čóɱƿáɳý ťĥáť řůɳš ťĥíš šíťé,
ůšéď íɳ łéǧáł ďóčůɱéɳťš łíǩé ťĥé /ťóš ]]'
api_key: '[[ Ťĥé šéčůřé ÁРÍ ǩéý ůšéď ťó čřéáťé áɳď ůƿďáťé ťóƿíčš, ůšé ťĥé /áďɱíɳ/áƿí
šéčťíóɳ ťó šéť íť ůƿ ]]'
queue_jobs: '[[ Ƣůéůé νáříóůš ʲóƀš íɳ šíďéǩíƣ, íƒ ƒáłšé ƣůéůéš ářé íɳłíɳé ]]'
crawl_images: '[[ Éɳáƀłé řéťříéνíɳǧ íɱáǧéš ƒřóɱ ťĥířď ƿářťý šóůřčéš ťó íɳšéřť
ŵíďťĥ áɳď ĥéíǧĥť ďíɱéɳšíóɳš ]]'

View file

@ -489,7 +489,6 @@ pt_BR:
company_full_name: "Nome completo da companhia que mantém este site, usada nos documentos legais como o /tos"
company_short_name: "Nome curto da companhia que mantém este site, usada nos documentos legais como o /tos"
company_domain: "Nome de domínio pertencente a companhia que mantém este site, usada nos documentos legais como o /tos"
api_key: "A chave de API segura usada para criar e modificar tópicos, acesse a seção /admin/api para defini-lá"
queue_jobs: "APENAS DESENVOLVEDORES! ATENÇÃO! Por padrão, enfileira tarefas no sidekiq. Se desativado, seu site ficará defeituoso."
crawl_images: "permitir mostrar imagens de sites terceiros"
ninja_edit_window: "quão rápido é possivél fazer uma alteração sem guardar uma nova versão, em segundos."

View file

@ -484,7 +484,6 @@ ru:
company_full_name: 'Полное название компании, которой принадлежит сайт, используется в правовой документации как /tos'
company_short_name: 'Короткое название компании, которой принадлежит сайт, используется в правовой документации как /tos'
company_domain: 'Имя домена, принадлежащего компании, заведующей сайтом, используется в правовой документации как /tos'
api_key: 'Секретный API ключ, используемый для создания и обновления тем. Зайдите в секцию /admin/api , чтобы его задать'
queue_jobs: 'ТОЛЬКО ДЛЯ РАЗРАБОТЧИКОВ! ВНИМАНИЕ! По умолчанию задачи обрабатываются асинхронно в очереди sidekiq. Если настройка выключена, ваш сайт может не работать.'
crawl_images: 'Разрешить извлечение изображений из сторонних источников, ширина и высота'
ninja_edit_window: 'Количество секунд после размещения сообщения, в течение которых внесение правок в сообщение не повлечет его изменение'

View file

@ -346,7 +346,6 @@ sv:
company_full_name: "Det fullständiga namnet för företaget som driver denna webbplats, används i juridiska dokument så som /tos"
company_short_name: "Det korta namnet för företaget som driver denna webbplats, används i juridiska dokument så som /tos"
company_domain: "Domännamnet som ägs av företaget som driver denna webbplats, används i juridiska dokument så som /tos"
api_key: "Den säkra API-nyckeln som används för att skapa och uppdatera trådar, använd /admin/api för att skapa en"
queue_jobs: "Köa diverse jobb i sidekiq, om urkryssat så körs köer infogat"
crawl_images: "Aktivera hämtning av bilder från tredjepartskällor för att infoga bredd och höjd"
ninja_edit_window: "Antal sekunder efter ett inlägg när en ändring inte skapar en ny version"

View file

@ -119,8 +119,8 @@ zh_CN:
replace_paragraph: "[用一段简短的分类描述替换此第一段内容请不要超过200个字符。]"
post_template: "%{replace_paragraph}\n\n使用下面的空间输入分类的详细描述信息可包括在此分类下讨论的规则、内容导向等等。"
this_year: "今年"
trust_levels:
newuser:
title: "访客"
@ -442,7 +442,6 @@ zh_CN:
company_full_name: "运行本站点的公司全称,用于法律文档,例如服务条款 /tos"
company_short_name: "运行本站点的公司短名,用于法律文档,例如服务条款 /tos"
company_domain: "运行本站点的公司域名,用于法律文档,例如服务条款 /tos"
api_key: "加密的应用开发接口密钥API key用于创建和更新主题。使用 /admin/api 来对它进行设置。"
queue_jobs: "如果失败队列在排队,使用 Sidekiq 消息引擎对不同的工作排队"
crawl_images: "允许从第三方获取图片来插入宽、高数值"
ninja_edit_window: "在多少秒钟之内,对帖子的多次编辑不生成新版本"

View file

@ -423,7 +423,6 @@ zh_TW:
company_full_name: "運行本站點的公司全稱,用于法律文檔,例如服務條款 /tos"
company_short_name: "運行本站點的公司短名,用于法律文檔,例如服務條款 /tos"
company_domain: "運行本站點的公司域名,用于法律文檔,例如服務條款 /tos"
api_key: "加密的應用開發接口密鑰API key用于創建和更新主題。使用 /admin/api 來對它進行設置。"
queue_jobs: "如果失敗隊列在排隊,使用 Sidekiq 消息引擎對不同的工作排隊"
crawl_images: "允許從第三方獲取圖片來插入寬、高數值"
ninja_edit_window: "在多少秒鍾之內,對帖子的多次編輯不生成新版本"

View file

@ -43,6 +43,8 @@ Discourse::Application.routes.draw do
put 'unban'
put 'revoke_admin', constraints: AdminConstraint.new
put 'grant_admin', constraints: AdminConstraint.new
post 'generate_api_key', constraints: AdminConstraint.new
delete 'revoke_api_key', constraints: AdminConstraint.new
put 'revoke_moderation', constraints: AdminConstraint.new
put 'grant_moderation', constraints: AdminConstraint.new
put 'approve'
@ -89,7 +91,9 @@ Discourse::Application.routes.draw do
end
resources :api, only: [:index], constraints: AdminConstraint.new do
collection do
post 'generate_key'
post 'key' => 'api#create_master_key'
put 'key' => 'api#regenerate_key'
delete 'key' => 'api#revoke_key'
end
end
end

View file

@ -0,0 +1,16 @@
class CreateApiKeys < ActiveRecord::Migration
def change
create_table :api_keys do |t|
t.string :key, limit: 64, null: false
t.integer :user_id, null: true
t.integer :created_by_id
t.timestamps
end
add_index :api_keys, :key
add_index :api_keys, :user_id, unique: true
execute "INSERT INTO api_keys (key, created_at, updated_at) SELECT value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM site_settings WHERE name = 'api_key'"
execute "DELETE FROM site_settings WHERE name = 'api_key'"
end
end

View file

@ -38,12 +38,17 @@ class Auth::DefaultCurrentUserProvider
# possible we have an api call, impersonate
unless current_user
if api_key = request["api_key"]
if api_username = request["api_username"]
if SiteSetting.api_key_valid?(api_key)
@env[API_KEY] = true
if api_key_value = request["api_key"]
api_key = ApiKey.where(key: api_key_value).includes(:user).first
if api_key.present?
@env[API_KEY] = true
if api_key.user.present?
current_user = api_key.user
elsif api_username = request["api_username"]
current_user = User.where(username_lower: api_username.downcase).first
end
end
end
end

View file

@ -1,8 +1,6 @@
desc "generate api key if missing, return existing if already there"
task "api_key:get" => :environment do
if SiteSetting.api_key.blank?
SiteSetting.generate_api_key!
end
api_key = ApiKey.create_master_key
puts SiteSetting.api_key
puts api_key.key
end

View file

@ -0,0 +1,56 @@
require 'spec_helper'
describe Admin::ApiController do
it "is a subclass of AdminController" do
(Admin::ApiController < Admin::AdminController).should be_true
end
let!(:user) { log_in(:admin) }
context '.index' do
it "succeeds" do
xhr :get, :index
response.should be_success
end
end
context '.regenerate_key' do
let(:api_key) { Fabricate(:api_key) }
it "returns 404 when there is no key" do
xhr :put, :regenerate_key, id: 1234
response.should_not be_success
response.status.should == 404
end
it "delegates to the api key's `regenerate!` method" do
ApiKey.any_instance.expects(:regenerate!)
xhr :put, :regenerate_key, id: api_key.id
end
end
context '.revoke_key' do
let(:api_key) { Fabricate(:api_key) }
it "returns 404 when there is no key" do
xhr :delete, :revoke_key, id: 1234
response.should_not be_success
response.status.should == 404
end
it "delegates to the api key's `regenerate!` method" do
ApiKey.any_instance.expects(:destroy)
xhr :delete, :revoke_key, id: api_key.id
end
end
context '.create_master_key' do
it "creates a record" do
lambda {
xhr :post, :create_master_key
}.should change(ApiKey, :count).by(1)
end
end
end

View file

@ -62,6 +62,26 @@ describe Admin::UsersController do
end
context '.generate_api_key' do
let(:evil_trout) { Fabricate(:evil_trout) }
it 'calls generate_api_key' do
User.any_instance.expects(:generate_api_key).with(@user)
xhr :post, :generate_api_key, user_id: evil_trout.id
end
end
context '.revoke_api_key' do
let(:evil_trout) { Fabricate(:evil_trout) }
it 'calls revoke_api_key' do
User.any_instance.expects(:revoke_api_key)
xhr :delete, :revoke_api_key, user_id: evil_trout.id
end
end
context '.approve' do
let(:evil_trout) { Fabricate(:evil_trout) }

View file

@ -15,10 +15,18 @@ describe 'api' do
Fabricate(:post)
end
let(:api_key) { user.generate_api_key(user) }
let(:master_key) { ApiKey.create_master_key }
# choosing an arbitrarily easy to mock trusted activity
it 'allows users with api key to bookmark posts' do
PostAction.expects(:act).with(user, post, PostActionType.types[:bookmark]).once
put :bookmark, bookmarked: "true", post_id: post.id, api_key: SiteSetting.api_key, api_username: user.username, format: :json
put :bookmark, bookmarked: "true", post_id: post.id, api_key: api_key.key, format: :json
end
it 'allows users with a master api key to bookmark posts' do
PostAction.expects(:act).with(user, post, PostActionType.types[:bookmark]).once
put :bookmark, bookmarked: "true", post_id: post.id, api_key: master_key.key, api_username: user.username, format: :json
end
it 'disallows phonies to bookmark posts' do

View file

@ -0,0 +1,3 @@
Fabricator(:api_key) do
key '1dfb7d427400cb8ef18052fd412781af134cceca5725dd74f34bbc6b9e35ddc9'
end

View file

@ -0,0 +1,16 @@
# encoding: utf-8
require 'spec_helper'
require_dependency 'api_key'
describe ApiKey do
it { should belong_to :user }
it { should belong_to :created_by }
it { should validate_presence_of :key }
it 'validates uniqueness of user_id' do
Fabricate(:api_key)
should validate_uniqueness_of(:user_id)
end
end

View file

@ -863,4 +863,55 @@ describe User do
expect(user.update_avatar(upload)).to be_true
end
end
describe 'api keys' do
let(:admin) { Fabricate(:admin) }
let(:other_admin) { Fabricate(:admin) }
let(:user) { Fabricate(:user) }
describe '.generate_api_key' do
it "generates an api key when none exists, and regenerates when it does" do
expect(user.api_key).to be_blank
# Generate a key
api_key = user.generate_api_key(admin)
expect(api_key.user).to eq(user)
expect(api_key.key).to be_present
expect(api_key.created_by).to eq(admin)
user.reload
expect(user.api_key).to eq(api_key)
# Regenerate a key. Keeps the same record, updates the key
new_key = user.generate_api_key(other_admin)
expect(new_key.id).to eq(api_key.id)
expect(new_key.key).to_not eq(api_key.key)
expect(new_key.created_by).to eq(other_admin)
end
end
describe '.revoke_api_key' do
it "revokes an api key when exists" do
expect(user.api_key).to be_blank
# Revoke nothing does nothing
user.revoke_api_key
user.reload
expect(user.api_key).to be_blank
# When a key is present it is removed
user.generate_api_key(admin)
user.reload
user.revoke_api_key
user.reload
expect(user.api_key).to be_blank
end
end
end
end

9
test/fixtures/api_keys.yml vendored Normal file
View file

@ -0,0 +1,9 @@
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html
one:
key: MyString
user_id: 1
two:
key: MyString
user_id: 1

View file

@ -0,0 +1,30 @@
module("Discourse.AdminUser");
asyncTestDiscourse('generate key', function() {
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve({api_key: {id: 1234, key: 'asdfasdf'}}));
var adminUser = Discourse.AdminUser.create({id: 333});
blank(adminUser.get('api_key'), 'it has no api key by default');
adminUser.generateApiKey().then(function() {
start();
ok(Discourse.ajax.calledWith("/admin/users/333/generate_api_key", { type: 'POST' }), "it POSTed to the url");
present(adminUser.get('api_key'), 'it has an api_key now');
});
});
asyncTestDiscourse('revoke key', function() {
var apiKey = Discourse.ApiKey.create({id: 1234, key: 'asdfasdf'}),
adminUser = Discourse.AdminUser.create({id: 333, api_key: apiKey});
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve());
equal(adminUser.get('api_key'), apiKey, 'it has the api key in the beginning');
adminUser.revokeApiKey().then(function() {
start();
ok(Discourse.ajax.calledWith("/admin/users/333/revoke_api_key", { type: 'DELETE' }), "it DELETEd to the url");
blank(adminUser.get('api_key'), 'it cleared the api_key');
});
});

View file

@ -0,0 +1,45 @@
module("Discourse.ApiKey");
test('create', function() {
var apiKey = Discourse.ApiKey.create({id: 123, user: {id: 345}});
present(apiKey, 'it creates the api key');
present(apiKey.get('user'), 'it creates the user inside');
});
asyncTestDiscourse('find', function() {
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve([]));
Discourse.ApiKey.find().then(function() {
start();
ok(Discourse.ajax.calledWith("/admin/api"), "it GETs the keys");
});
});
asyncTestDiscourse('generateMasterKey', function() {
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve([]));
Discourse.ApiKey.generateMasterKey().then(function() {
start();
ok(Discourse.ajax.calledWith("/admin/api/key", {type: 'POST'}), "it POSTs to create a master key");
});
});
asyncTestDiscourse('regenerate', function() {
var apiKey = Discourse.ApiKey.create({id: 3456});
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve({api_key: {id: 3456}}));
apiKey.regenerate().then(function() {
start();
ok(Discourse.ajax.calledWith("/admin/api/key", {type: 'PUT', data: {id: 3456}}), "it PUTs the key");
});
});
asyncTestDiscourse('revoke', function() {
var apiKey = Discourse.ApiKey.create({id: 3456});
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve([]));
apiKey.revoke().then(function() {
start();
ok(Discourse.ajax.calledWith("/admin/api/key", {type: 'DELETE', data: {id: 3456}}), "it DELETES the key");
});
});

View file

@ -0,0 +1,7 @@
require 'test_helper'
class ApiKeyTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end