API Key in UI

New: view/reset API in General Settings
Fixed: API will reject unauthenticated requests
This commit is contained in:
Mark McDowall 2014-03-13 21:23:47 -07:00
parent 0914441de7
commit 6b423c104c
16 changed files with 1194 additions and 45 deletions

View File

@ -78,6 +78,7 @@ module.exports = function (grunt) {
'**/*.png', '**/*.png',
'**/*.jpg', '**/*.jpg',
'**/*.ico', '**/*.ico',
'**/*.swf',
'**/FontAwesome/*.*', '**/FontAwesome/*.*',
'**/fonts/*.*' '**/fonts/*.*'
], ],

View File

@ -4,6 +4,7 @@ using Nancy;
using Nancy.Bootstrapper; using Nancy.Bootstrapper;
using NzbDrone.Api.Extensions; using NzbDrone.Api.Extensions;
using NzbDrone.Api.Extensions.Pipelines; using NzbDrone.Api.Extensions.Pipelines;
using NzbDrone.Common;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
@ -12,12 +13,12 @@ namespace NzbDrone.Api.Authentication
public class EnableStatelessAuthInNancy : IRegisterNancyPipeline public class EnableStatelessAuthInNancy : IRegisterNancyPipeline
{ {
private readonly IAuthenticationService _authenticationService; private readonly IAuthenticationService _authenticationService;
private readonly IConfigFileProvider _configFileProvider; private static String API_KEY;
public EnableStatelessAuthInNancy(IAuthenticationService authenticationService, IConfigFileProvider configFileProvider) public EnableStatelessAuthInNancy(IAuthenticationService authenticationService, IConfigFileProvider configFileProvider)
{ {
_authenticationService = authenticationService; _authenticationService = authenticationService;
_configFileProvider = configFileProvider; API_KEY = configFileProvider.ApiKey;
} }
public void Register(IPipelines pipelines) public void Register(IPipelines pipelines)
@ -36,9 +37,9 @@ namespace NzbDrone.Api.Authentication
var authorizationHeader = context.Request.Headers.Authorization; var authorizationHeader = context.Request.Headers.Authorization;
var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault(); var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault();
var apiKey = String.IsNullOrWhiteSpace(apiKeyHeader) ? authorizationHeader : apiKeyHeader; var apiKey = apiKeyHeader.IsNullOrWhiteSpace() ? authorizationHeader : apiKeyHeader;
if (context.Request.IsApiRequest() && !ValidApiKey(apiKey) && !_authenticationService.IsAuthenticated(context)) if (context.Request.IsApiRequest() && !ValidApiKey(apiKey) && !IsAuthenticated(context))
{ {
response = new Response { StatusCode = HttpStatusCode.Unauthorized }; response = new Response { StatusCode = HttpStatusCode.Unauthorized };
} }
@ -48,10 +49,15 @@ namespace NzbDrone.Api.Authentication
private bool ValidApiKey(string apiKey) private bool ValidApiKey(string apiKey)
{ {
if (String.IsNullOrWhiteSpace(apiKey)) return false; if (apiKey.IsNullOrWhiteSpace()) return false;
if (!apiKey.Equals(_configFileProvider.ApiKey)) return false; if (!apiKey.Equals(API_KEY)) return false;
return true; return true;
} }
private bool IsAuthenticated(NancyContext context)
{
return _authenticationService.Enabled && _authenticationService.IsAuthenticated(context);
}
} }
} }

View File

@ -1,8 +1,8 @@
using System;
using System.IO; using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Nancy; using Nancy;
using NLog; using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
@ -12,10 +12,12 @@ namespace NzbDrone.Api.Frontend.Mappers
public class IndexHtmlMapper : StaticResourceMapperBase public class IndexHtmlMapper : StaticResourceMapperBase
{ {
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly IConfigFileProvider _configFileProvider;
private readonly string _indexPath; private readonly string _indexPath;
private static readonly Regex ReplaceRegex = new Regex("(?<=(?:href|src|data-main)=\").*?(?=\")", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex ReplaceRegex = new Regex("(?<=(?:href|src|data-main)=\").*?(?=\")", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static String API_KEY;
private static String URL_BASE;
public IndexHtmlMapper(IAppFolderInfo appFolderInfo, public IndexHtmlMapper(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider, IDiskProvider diskProvider,
IConfigFileProvider configFileProvider, IConfigFileProvider configFileProvider,
@ -23,8 +25,10 @@ namespace NzbDrone.Api.Frontend.Mappers
: base(diskProvider, logger) : base(diskProvider, logger)
{ {
_diskProvider = diskProvider; _diskProvider = diskProvider;
_configFileProvider = configFileProvider;
_indexPath = Path.Combine(appFolderInfo.StartUpFolder, "UI", "index.html"); _indexPath = Path.Combine(appFolderInfo.StartUpFolder, "UI", "index.html");
API_KEY = configFileProvider.ApiKey;
URL_BASE = configFileProvider.UrlBase;
} }
protected override string Map(string resourceUrl) protected override string Map(string resourceUrl)
@ -54,12 +58,12 @@ namespace NzbDrone.Api.Frontend.Mappers
{ {
var text = _diskProvider.ReadAllText(_indexPath); var text = _diskProvider.ReadAllText(_indexPath);
text = ReplaceRegex.Replace(text, match => _configFileProvider.UrlBase + match.Value); text = ReplaceRegex.Replace(text, match => URL_BASE + match.Value);
text = text.Replace(".css", ".css?v=" + BuildInfo.Version); text = text.Replace(".css", ".css?v=" + BuildInfo.Version);
text = text.Replace(".js", ".js?v=" + BuildInfo.Version); text = text.Replace(".js", ".js?v=" + BuildInfo.Version);
text = text.Replace("API_ROOT", _configFileProvider.UrlBase + "/api"); text = text.Replace("API_ROOT", URL_BASE + "/api");
text = text.Replace("API_KEY", _configFileProvider.ApiKey); text = text.Replace("API_KEY", API_KEY);
text = text.Replace("APP_VERSION", BuildInfo.Version.ToString()); text = text.Replace("APP_VERSION", BuildInfo.Version.ToString());
return text; return text;

View File

@ -26,7 +26,11 @@ namespace NzbDrone.Api.Frontend.Mappers
public override bool CanHandle(string resourceUrl) public override bool CanHandle(string resourceUrl)
{ {
return resourceUrl.StartsWith("/Content") || resourceUrl.EndsWith(".js") || resourceUrl.EndsWith(".css") || resourceUrl.EndsWith(".ico"); return resourceUrl.StartsWith("/Content") ||
resourceUrl.EndsWith(".js") ||
resourceUrl.EndsWith(".css") ||
resourceUrl.EndsWith(".ico") ||
resourceUrl.EndsWith(".swf");
} }
} }
} }

View File

@ -9,12 +9,14 @@ using NzbDrone.Common.Cache;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Configuration namespace NzbDrone.Core.Configuration
{ {
public interface IConfigFileProvider : IHandleAsync<ApplicationStartedEvent> public interface IConfigFileProvider : IHandleAsync<ApplicationStartedEvent>,
IExecute<ResetApiKeyCommand>
{ {
Dictionary<string, object> GetConfigDictionary(); Dictionary<string, object> GetConfigDictionary();
void SaveConfigDictionary(Dictionary<string, object> configValues); void SaveConfigDictionary(Dictionary<string, object> configValues);
@ -76,6 +78,11 @@ namespace NzbDrone.Core.Configuration
foreach (var configValue in configValues) foreach (var configValue in configValues)
{ {
if (configValue.Key.Equals("ApiKey", StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
object currentValue; object currentValue;
allWithDefaults.TryGetValue(configValue.Key, out currentValue); allWithDefaults.TryGetValue(configValue.Key, out currentValue);
if (currentValue == null) continue; if (currentValue == null) continue;
@ -115,7 +122,7 @@ namespace NzbDrone.Core.Configuration
{ {
get get
{ {
return GetValue("ApiKey", Guid.NewGuid().ToString().Replace("-", "")); return GetValue("ApiKey", GenerateApiKey());
} }
} }
@ -296,9 +303,19 @@ namespace NzbDrone.Core.Configuration
} }
} }
private string GenerateApiKey()
{
return Guid.NewGuid().ToString().Replace("-", "");
}
public void HandleAsync(ApplicationStartedEvent message) public void HandleAsync(ApplicationStartedEvent message)
{ {
DeleteOldValues(); DeleteOldValues();
} }
public void Execute(ResetApiKeyCommand message)
{
SetValue("ApiKey", GenerateApiKey());
}
} }
} }

View File

@ -0,0 +1,15 @@
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.Configuration
{
public class ResetApiKeyCommand : Command
{
public override bool SendUpdatesToClient
{
get
{
return true;
}
}
}
}

View File

@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NLog; using NLog;
using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Configuration.Events;

View File

@ -114,6 +114,7 @@
<Compile Include="Configuration\Events\ConfigSavedEvent.cs" /> <Compile Include="Configuration\Events\ConfigSavedEvent.cs" />
<Compile Include="Configuration\IConfigService.cs" /> <Compile Include="Configuration\IConfigService.cs" />
<Compile Include="Configuration\InvalidConfigFileException.cs" /> <Compile Include="Configuration\InvalidConfigFileException.cs" />
<Compile Include="Configuration\ResetApiKeyCommand.cs" />
<Compile Include="DataAugmentation\DailySeries\DailySeriesDataProxy.cs" /> <Compile Include="DataAugmentation\DailySeries\DailySeriesDataProxy.cs" />
<Compile Include="DataAugmentation\DailySeries\DailySeriesService.cs" /> <Compile Include="DataAugmentation\DailySeries\DailySeriesService.cs" />
<Compile Include="DataAugmentation\Scene\SceneMapping.cs" /> <Compile Include="DataAugmentation\Scene\SceneMapping.cs" />

View File

@ -7,6 +7,7 @@
color : #595959; color : #595959;
margin-right : 5px; margin-right : 5px;
} }
.checkbox { .checkbox {
width : 100px; width : 100px;
margin-left : 0px; margin-left : 0px;
@ -24,7 +25,8 @@
.btn { .btn {
i { i {
margin-right: 0px; margin-right : 0px;
color : inherit;
} }
} }
} }

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
'use strict';
define([
'jquery',
'System/StatusModel',
'zero.clipboard',
'Shared/Messenger'
],
function ($, StatusModel, ZeroClipboard, Messenger) {
$.fn.copyToClipboard = function (input) {
var moviePath = StatusModel.get('urlBase') + '/Content/zero.clipboard.swf';
var client = new ZeroClipboard(this, {
moviePath: moviePath
});
client.on('load', function(client) {
client.on('dataRequested', function (client) {
client.setText(input.val());
});
client.on('complete', function() {
Messenger.show({
message: 'Copied text to clipboard'
});
} );
} );
};
});

View File

@ -1,23 +1,34 @@
'use strict'; 'use strict';
define( define(
[ [
'vent',
'marionette', 'marionette',
'Commands/CommandController',
'Mixins/AsModelBoundView', 'Mixins/AsModelBoundView',
'Mixins/AsValidatedView' 'Mixins/AsValidatedView',
], function (Marionette, AsModelBoundView, AsValidatedView) { 'Mixins/CopyToClipboard'
], function (vent, Marionette, CommandController, AsModelBoundView, AsValidatedView) {
var view = Marionette.ItemView.extend({ var view = Marionette.ItemView.extend({
template: 'Settings/General/GeneralViewTemplate', template: 'Settings/General/GeneralViewTemplate',
events: { events: {
'change .x-auth': '_setAuthOptionsVisibility', 'change .x-auth' : '_setAuthOptionsVisibility',
'change .x-ssl': '_setSslOptionsVisibility' 'change .x-ssl' : '_setSslOptionsVisibility',
'click .x-reset-api-key' : '_resetApiKey'
}, },
ui: { ui: {
authToggle : '.x-auth', authToggle : '.x-auth',
authOptions: '.x-auth-options', authOptions : '.x-auth-options',
sslToggle : '.x-ssl', sslToggle : '.x-ssl',
sslOptions: '.x-ssl-options' sslOptions : '.x-ssl-options',
resetApiKey : '.x-reset-api-key',
copyApiKey : '.x-copy-api-key',
apiKeyInput : '.x-api-key'
},
initialize: function () {
vent.on(vent.Events.CommandComplete, this._commandComplete, this);
}, },
onRender: function(){ onRender: function(){
@ -28,6 +39,17 @@ define(
if(!this.ui.sslToggle.prop('checked')){ if(!this.ui.sslToggle.prop('checked')){
this.ui.sslOptions.hide(); this.ui.sslOptions.hide();
} }
CommandController.bindToCommand({
element: this.ui.resetApiKey,
command: {
name: 'resetApiKey'
}
});
},
onShow: function () {
this.ui.copyApiKey.copyToClipboard(this.ui.apiKeyInput);
}, },
_setAuthOptionsVisibility: function () { _setAuthOptionsVisibility: function () {
@ -54,6 +76,20 @@ define(
else { else {
this.ui.sslOptions.slideUp(); this.ui.sslOptions.slideUp();
} }
},
_resetApiKey: function () {
if (window.confirm("Reset API Key?")) {
CommandController.Execute('resetApiKey', {
name : 'resetApiKey'
});
}
},
_commandComplete: function (options) {
if (options.command.get('name') === 'resetapikey') {
this.model.fetch();
}
} }
}); });

View File

@ -119,6 +119,21 @@
</div> </div>
</div> </div>
</div> </div>
<div class="control-group api-key">
<label class="control-label">API Key</label>
<div class="controls">
<div class="input-append">
<input type="text" name="apiKey" readonly="readonly" class="x-api-key"/>
<button class="btn btn-icon-only x-copy-api-key" title="Copy to clipboard"><i class="icon-copy"></i></button>
<button class="btn btn-danger btn-icon-only x-reset-api-key" title="Reset API Key"><i class="icon-refresh"></i></button>
</div>
<span>
<i class="icon-nd-form-warning" title="Requires restart to take effect"/>
</span>
</div>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
@ -155,27 +170,27 @@
</div> </div>
</div> </div>
{{#if_mono}} <!--{{#if_mono}}-->
<div class="control-group"> <!--<div class="control-group">-->
<label class="control-label">Auto Update</label> <!--<label class="control-label">Auto Update</label>-->
<div class="controls"> <!--<div class="controls">-->
<label class="checkbox toggle well"> <!--<label class="checkbox toggle well">-->
<input type="checkbox" name="autoUpdate"/> <!--<input type="checkbox" name="autoUpdate"/>-->
<p> <!--<p>-->
<span>Yes</span> <!--<span>Yes</span>-->
<span>No</span> <!--<span>No</span>-->
</p> <!--</p>-->
<div class="btn btn-primary slide-button"/> <!--<div class="btn btn-primary slide-button"/>-->
</label> <!--</label>-->
<span class="help-inline-checkbox"> <!--<span class="help-inline-checkbox">-->
<i class="icon-nd-form-info" title="Use drone's built in auto update instead of package manager/manual updating"/> <!--<i class="icon-nd-form-info" title="Use drone's built in auto update instead of package manager/manual updating"/>-->
</span> <!--</span>-->
</div> <!--</div>-->
</div> <!--</div>-->
{{/if_mono}} <!--{{/if_mono}}-->
</fieldset> </fieldset>
</div> </div>

View File

@ -93,3 +93,11 @@ li.save-and-add:hover {
display: none; display: none;
} }
} }
.api-key {
input {
width : 280px;
cursor : text;
}
}

View File

@ -27,6 +27,7 @@ require.config({
'jquery.dotdotdot' : 'JsLibraries/jquery.dotdotdot', 'jquery.dotdotdot' : 'JsLibraries/jquery.dotdotdot',
'messenger' : 'JsLibraries/messenger', 'messenger' : 'JsLibraries/messenger',
'jquery' : 'JsLibraries/jquery', 'jquery' : 'JsLibraries/jquery',
'zero.clipboard' : 'JsLibraries/zero.clipboard',
'libs' : 'JsLibraries/', 'libs' : 'JsLibraries/',
'api': 'Require/require.api' 'api': 'Require/require.api'