1
0
mirror of https://github.com/Radarr/Radarr.git synced 2024-11-09 04:22:30 +01:00

New: Forms authentication

This commit is contained in:
Mark McDowall 2015-01-25 18:03:21 -08:00
parent 7c38fcb9f3
commit 3c756348eb
35 changed files with 707 additions and 81 deletions

View File

@ -9,5 +9,5 @@ require('./copy');
gulp.task('build', function () { gulp.task('build', function () {
return runSequence('clean', return runSequence('clean',
['requireJs', 'less', 'handlebars', 'copyIndex', 'copyContent']); ['requireJs', 'less', 'handlebars', 'copyHtml', 'copyContent']);
}); });

View File

@ -11,9 +11,9 @@ gulp.task('copyJs', function () {
.pipe(gulp.dest(paths.dest.root)); .pipe(gulp.dest(paths.dest.root));
}); });
gulp.task('copyIndex', function () { gulp.task('copyHtml', function () {
return gulp.src(paths.src.index) return gulp.src(paths.src.html)
.pipe(cache('copyIndex')) .pipe(cache('copyHtml'))
.pipe(gulp.dest(paths.dest.root)); .pipe(gulp.dest(paths.dest.root));
}); });

View File

@ -2,7 +2,7 @@ module.exports = {
src: { src: {
root: './src/UI/', root: './src/UI/',
templates: './src/UI/**/*.hbs', templates: './src/UI/**/*.hbs',
index: './src/UI/index.html', html: './src/UI/*.html',
partials: './src/UI/**/*Partial.hbs', partials: './src/UI/**/*Partial.hbs',
scripts: './src/UI/**/*.js', scripts: './src/UI/**/*.js',
less: ['./src/UI/**/*.less'], less: ['./src/UI/**/*.less'],

View File

@ -10,11 +10,11 @@ require('./less.js');
require('./copy.js'); require('./copy.js');
gulp.task('watch', ['jshint', 'handlebars', 'less', 'copyJs','copyIndex', 'copyContent'], function () { gulp.task('watch', ['jshint', 'handlebars', 'less', 'copyJs', 'copyHtml', 'copyContent'], function () {
gulp.watch([paths.src.scripts, paths.src.exclude.libs], ['jshint', 'copyJs']); gulp.watch([paths.src.scripts, paths.src.exclude.libs], ['jshint', 'copyJs']);
gulp.watch(paths.src.templates, ['handlebars']); gulp.watch(paths.src.templates, ['handlebars']);
gulp.watch([paths.src.less, paths.src.exclude.libs], ['less']); gulp.watch([paths.src.less, paths.src.exclude.libs], ['less']);
gulp.watch([paths.src.index], ['copyIndex']); gulp.watch([paths.src.html], ['copyHtml']);
gulp.watch([paths.src.content + '**/*.*', '!**/*.less'], ['copyContent']); gulp.watch([paths.src.content + '**/*.*', '!**/*.less'], ['copyContent']);
}); });
@ -23,7 +23,8 @@ gulp.task('liveReload', ['jshint', 'handlebars', 'less', 'copyJs'], function ()
gulp.watch([ gulp.watch([
'app/**/*.js', 'app/**/*.js',
'app/**/*.css', 'app/**/*.css',
'app/index.html' 'app/index.html',
'app/login.html'
]).on('change', function (file) { ]).on('change', function (file) {
server.changed(file.path); server.changed(file.path);
}); });

View File

@ -0,0 +1,62 @@
using Nancy;
using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using Nancy.Bootstrapper;
using Nancy.Cryptography;
using NzbDrone.Api.Extensions.Pipelines;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Authentication
{
public class EnableAuthInNancy : IRegisterNancyPipeline
{
private readonly IAuthenticationService _authenticationService;
private readonly IConfigService _configService;
private readonly IConfigFileProvider _configFileProvider;
public EnableAuthInNancy(IAuthenticationService authenticationService,
IConfigService configService,
IConfigFileProvider configFileProvider)
{
_authenticationService = authenticationService;
_configService = configService;
_configFileProvider = configFileProvider;
}
public void Register(IPipelines pipelines)
{
RegisterFormsAuth(pipelines);
pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr"));
pipelines.BeforeRequest.AddItemToEndOfPipeline(RequiresAuthentication);
}
private Response RequiresAuthentication(NancyContext context)
{
Response response = null;
if (!_authenticationService.IsAuthenticated(context))
{
response = new Response { StatusCode = HttpStatusCode.Unauthorized };
}
return response;
}
private void RegisterFormsAuth(IPipelines pipelines)
{
var cryptographyConfiguration = new CryptographyConfiguration(
new RijndaelEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase,
new byte[] {1, 2, 3, 4, 5, 6, 7, 8})),
new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase,
new byte[] {1, 2, 3, 4, 5, 6, 7, 8}))
);
FormsAuthentication.Enable(pipelines, new FormsAuthenticationConfiguration
{
RedirectUrl = "~/login",
UserMapper = _authenticationService,
CryptographyConfiguration = cryptographyConfiguration
});
}
}
}

View File

@ -2,14 +2,16 @@
using System.Linq; using System.Linq;
using Nancy; using Nancy;
using Nancy.Authentication.Basic; using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using Nancy.Security; using Nancy.Security;
using NzbDrone.Api.Extensions; using NzbDrone.Api.Extensions;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Authentication namespace NzbDrone.Api.Authentication
{ {
public interface IAuthenticationService : IUserValidator public interface IAuthenticationService : IUserValidator, IUserMapper
{ {
bool IsAuthenticated(NancyContext context); bool IsAuthenticated(NancyContext context);
} }
@ -17,37 +19,49 @@ public interface IAuthenticationService : IUserValidator
public class AuthenticationService : IAuthenticationService public class AuthenticationService : IAuthenticationService
{ {
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
private readonly IUserService _userService;
private static readonly NzbDroneUser AnonymousUser = new NzbDroneUser { UserName = "Anonymous" }; private static readonly NzbDroneUser AnonymousUser = new NzbDroneUser { UserName = "Anonymous" };
private static String API_KEY; private static String API_KEY;
public AuthenticationService(IConfigFileProvider configFileProvider) public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService)
{ {
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_userService = userService;
API_KEY = configFileProvider.ApiKey; API_KEY = configFileProvider.ApiKey;
} }
public IUserIdentity Validate(string username, string password) public IUserIdentity Validate(string username, string password)
{ {
if (!Enabled) if (_configFileProvider.AuthenticationMethod == AuthenticationType.None)
{ {
return AnonymousUser; return AnonymousUser;
} }
if (_configFileProvider.Username.Equals(username) && var user = _userService.FindUser(username, password);
_configFileProvider.Password.Equals(password))
if (user != null)
{ {
return new NzbDroneUser { UserName = username }; return new NzbDroneUser { UserName = user.Username };
} }
return null; return null;
} }
private bool Enabled public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context)
{ {
get if (_configFileProvider.AuthenticationMethod == AuthenticationType.None)
{ {
return _configFileProvider.AuthenticationEnabled; return AnonymousUser;
} }
var user = _userService.FindUser(identifier);
if (user != null)
{
return new NzbDroneUser { UserName = user.Username };
}
return null;
} }
public bool IsAuthenticated(NancyContext context) public bool IsAuthenticated(NancyContext context)
@ -59,13 +73,13 @@ public bool IsAuthenticated(NancyContext context)
return ValidApiKey(apiKey); return ValidApiKey(apiKey);
} }
if (context.Request.IsFeedRequest()) if (_configFileProvider.AuthenticationMethod == AuthenticationType.None)
{
if (!Enabled)
{ {
return true; return true;
} }
if (context.Request.IsFeedRequest())
{
if (ValidUser(context) || ValidApiKey(apiKey)) if (ValidUser(context) || ValidApiKey(apiKey))
{ {
return true; return true;
@ -74,7 +88,12 @@ public bool IsAuthenticated(NancyContext context)
return false; return false;
} }
if (!Enabled) if (context.Request.IsLoginRequest())
{
return true;
}
if (context.Request.IsContentRequest())
{ {
return true; return true;
} }

View File

@ -1,23 +1,46 @@
using Nancy; using System;
using System.Text;
using Nancy;
using Nancy.Authentication.Basic; using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using Nancy.Bootstrapper; using Nancy.Bootstrapper;
using Nancy.Cryptography;
using NzbDrone.Api.Extensions;
using NzbDrone.Api.Extensions.Pipelines; using NzbDrone.Api.Extensions.Pipelines;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Authentication namespace NzbDrone.Api.Authentication
{ {
public class EnableAuthInNancy : IRegisterNancyPipeline public class EnableAuthInNancy : IRegisterNancyPipeline
{ {
private readonly IAuthenticationService _authenticationService; private readonly IAuthenticationService _authenticationService;
private readonly IConfigService _configService;
private readonly IConfigFileProvider _configFileProvider;
public EnableAuthInNancy(IAuthenticationService authenticationService) public EnableAuthInNancy(IAuthenticationService authenticationService,
IConfigService configService,
IConfigFileProvider configFileProvider)
{ {
_authenticationService = authenticationService; _authenticationService = authenticationService;
_configService = configService;
_configFileProvider = configFileProvider;
} }
public void Register(IPipelines pipelines) public void Register(IPipelines pipelines)
{
if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms)
{
RegisterFormsAuth(pipelines);
}
else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic)
{ {
pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr")); pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr"));
}
pipelines.BeforeRequest.AddItemToEndOfPipeline(RequiresAuthentication); pipelines.BeforeRequest.AddItemToEndOfPipeline(RequiresAuthentication);
pipelines.AfterRequest.AddItemToEndOfPipeline(RemoveLoginHooksForApiCalls);
} }
private Response RequiresAuthentication(NancyContext context) private Response RequiresAuthentication(NancyContext context)
@ -31,5 +54,33 @@ private Response RequiresAuthentication(NancyContext context)
return response; return response;
} }
private void RegisterFormsAuth(IPipelines pipelines)
{
var cryptographyConfiguration = new CryptographyConfiguration(
new RijndaelEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))),
new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt)))
);
FormsAuthentication.Enable(pipelines, new FormsAuthenticationConfiguration
{
RedirectUrl = "~/login",
UserMapper = _authenticationService,
CryptographyConfiguration = cryptographyConfiguration
});
}
private void RemoveLoginHooksForApiCalls(NancyContext context)
{
if (context.Request.IsApiRequest())
{
if ((context.Response.StatusCode == HttpStatusCode.SeeOther &&
context.Response.ContentType.Equals("text/html", StringComparison.InvariantCultureIgnoreCase)) ||
context.Response.StatusCode == HttpStatusCode.Unauthorized)
{
context.Response = new { Error = "Unauthorized" }.AsResponse(HttpStatusCode.Unauthorized);
}
}
}
} }
} }

View File

@ -0,0 +1,39 @@
using System;
using Nancy;
using Nancy.Authentication.Forms;
using Nancy.Extensions;
using Nancy.ModelBinding;
using NzbDrone.Core.Authentication;
namespace NzbDrone.Api.Authentication
{
public class LoginModule : NancyModule
{
private readonly IUserService _userService;
public LoginModule(IUserService userService)
{
_userService = userService;
Post["/login"] = x => Login(this.Bind<LoginResource>());
}
private Response Login(LoginResource resource)
{
var user = _userService.FindUser(resource.Username, resource.Password);
if (user == null)
{
return Context.GetRedirect("~/login?returnUrl=" + (string)Request.Query.returnUrl);
}
DateTime? expiry = null;
if (resource.RememberMe)
{
expiry = DateTime.UtcNow.AddDays(7);
}
return this.LoginAndRedirect(user.Identifier, expiry);
}
}
}

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Api.Authentication
{
public class LoginResource
{
public string Username { get; set; }
public string Password { get; set; }
public bool RememberMe { get; set; }
}
}

View File

@ -2,6 +2,8 @@
using System.Reflection; using System.Reflection;
using FluentValidation; using FluentValidation;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update; using NzbDrone.Core.Update;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
@ -13,11 +15,13 @@ namespace NzbDrone.Api.Config
public class HostConfigModule : NzbDroneRestModule<HostConfigResource> public class HostConfigModule : NzbDroneRestModule<HostConfigResource>
{ {
private readonly IConfigFileProvider _configFileProvider; private readonly IConfigFileProvider _configFileProvider;
private readonly IUserService _userService;
public HostConfigModule(IConfigFileProvider configFileProvider) public HostConfigModule(IConfigFileProvider configFileProvider, IUserService userService)
: base("/config/host") : base("/config/host")
{ {
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_userService = userService;
GetResourceSingle = GetHostConfig; GetResourceSingle = GetHostConfig;
GetResourceById = GetHostConfig; GetResourceById = GetHostConfig;
@ -26,8 +30,8 @@ public HostConfigModule(IConfigFileProvider configFileProvider)
SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default");
SharedValidator.RuleFor(c => c.Port).ValidPort(); SharedValidator.RuleFor(c => c.Port).ValidPort();
SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationEnabled); SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None);
SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationEnabled); SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None);
SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl); SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl);
SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl && OsInfo.IsWindows); SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl && OsInfo.IsWindows);
@ -46,6 +50,14 @@ private HostConfigResource GetHostConfig()
resource.InjectFrom(_configFileProvider); resource.InjectFrom(_configFileProvider);
resource.Id = 1; resource.Id = 1;
var user = _userService.FindUser();
if (user != null)
{
resource.Username = user.Username;
resource.Password = user.Password;
}
return resource; return resource;
} }
@ -61,6 +73,11 @@ private void SaveHostConfig(HostConfigResource resource)
.ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null));
_configFileProvider.SaveConfigDictionary(dictionary); _configFileProvider.SaveConfigDictionary(dictionary);
if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace())
{
_userService.Upsert(resource.Username, resource.Password);
}
} }
} }
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using NzbDrone.Api.REST; using NzbDrone.Api.REST;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Update; using NzbDrone.Core.Update;
namespace NzbDrone.Api.Config namespace NzbDrone.Api.Config
@ -11,7 +12,7 @@ public class HostConfigResource : RestResource
public Int32 SslPort { get; set; } public Int32 SslPort { get; set; }
public Boolean EnableSsl { get; set; } public Boolean EnableSsl { get; set; }
public Boolean LaunchBrowser { get; set; } public Boolean LaunchBrowser { get; set; }
public bool AuthenticationEnabled { get; set; } public AuthenticationType AuthenticationMethod { get; set; }
public Boolean AnalyticsEnabled { get; set; } public Boolean AnalyticsEnabled { get; set; }
public String Username { get; set; } public String Username { get; set; }
public String Password { get; set; } public String Password { get; set; }

View File

@ -26,5 +26,15 @@ public static bool IsLocalRequest(this Request request)
request.UserHostAddress.Equals("127.0.0.1") || request.UserHostAddress.Equals("127.0.0.1") ||
request.UserHostAddress.Equals("::1")); request.UserHostAddress.Equals("::1"));
} }
public static bool IsLoginRequest(this Request request)
{
return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase);
}
public static bool IsContentRequest(this Request request)
{
return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase);
}
} }
} }

View File

@ -49,7 +49,7 @@ public override string Map(string resourceUrl)
public override bool CanHandle(string resourceUrl) public override bool CanHandle(string resourceUrl)
{ {
return !resourceUrl.Contains("."); return !resourceUrl.Contains(".") && !resourceUrl.StartsWith("/login");
} }
public override Response GetResponse(string resourceUrl) public override Response GetResponse(string resourceUrl)

View File

@ -0,0 +1,88 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Nancy;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
namespace NzbDrone.Api.Frontend.Mappers
{
public class LoginHtmlMapper : StaticResourceMapperBase
{
private readonly IDiskProvider _diskProvider;
private readonly Func<ICacheBreakerProvider> _cacheBreakProviderFactory;
private readonly string _indexPath;
private static readonly Regex ReplaceRegex = new Regex("(?<=(?:href|src|data-main)=\").*?(?=\")", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static String URL_BASE;
private string _generatedContent;
public LoginHtmlMapper(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider,
IConfigFileProvider configFileProvider,
Func<ICacheBreakerProvider> cacheBreakProviderFactory,
Logger logger)
: base(diskProvider, logger)
{
_diskProvider = diskProvider;
_cacheBreakProviderFactory = cacheBreakProviderFactory;
_indexPath = Path.Combine(appFolderInfo.StartUpFolder, "UI", "login.html");
URL_BASE = configFileProvider.UrlBase;
}
public override string Map(string resourceUrl)
{
return _indexPath;
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/login");
}
public override Response GetResponse(string resourceUrl)
{
var response = base.GetResponse(resourceUrl);
response.Headers["X-UA-Compatible"] = "IE=edge";
return response;
}
protected override Stream GetContentStream(string filePath)
{
var text = GetLoginText();
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(text);
writer.Flush();
stream.Position = 0;
return stream;
}
private string GetLoginText()
{
if (RuntimeInfoBase.IsProduction && _generatedContent != null)
{
return _generatedContent;
}
var text = _diskProvider.ReadAllText(_indexPath);
var cacheBreakProvider = _cacheBreakProviderFactory();
text = ReplaceRegex.Replace(text, match =>
{
var url = cacheBreakProvider.AddCacheBreakerToPath(match.Value);
return URL_BASE + url;
});
_generatedContent = text;
return _generatedContent;
}
}
}

View File

@ -52,6 +52,9 @@
<SpecificVersion>False</SpecificVersion> <SpecificVersion>False</SpecificVersion>
<HintPath>..\packages\Nancy.Authentication.Basic.0.23.2\lib\net40\Nancy.Authentication.Basic.dll</HintPath> <HintPath>..\packages\Nancy.Authentication.Basic.0.23.2\lib\net40\Nancy.Authentication.Basic.dll</HintPath>
</Reference> </Reference>
<Reference Include="Nancy.Authentication.Forms">
<HintPath>..\packages\Nancy.Authentication.Forms.0.23.2\lib\net40\Nancy.Authentication.Forms.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=6.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> <Reference Include="Newtonsoft.Json, Version=6.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion> <SpecificVersion>False</SpecificVersion>
<HintPath>..\packages\Newtonsoft.Json.6.0.6\lib\net40\Newtonsoft.Json.dll</HintPath> <HintPath>..\packages\Newtonsoft.Json.6.0.6\lib\net40\Newtonsoft.Json.dll</HintPath>
@ -80,6 +83,8 @@
</Compile> </Compile>
<Compile Include="Authentication\AuthenticationService.cs" /> <Compile Include="Authentication\AuthenticationService.cs" />
<Compile Include="Authentication\EnableAuthInNancy.cs" /> <Compile Include="Authentication\EnableAuthInNancy.cs" />
<Compile Include="Authentication\LoginModule.cs" />
<Compile Include="Authentication\LoginResource.cs" />
<Compile Include="Authentication\NzbDroneUser.cs" /> <Compile Include="Authentication\NzbDroneUser.cs" />
<Compile Include="Blacklist\BlacklistModule.cs" /> <Compile Include="Blacklist\BlacklistModule.cs" />
<Compile Include="Blacklist\BlacklistResource.cs" /> <Compile Include="Blacklist\BlacklistResource.cs" />
@ -94,6 +99,7 @@
<Compile Include="Commands\CommandResource.cs" /> <Compile Include="Commands\CommandResource.cs" />
<Compile Include="Extensions\AccessControlHeaders.cs" /> <Compile Include="Extensions\AccessControlHeaders.cs" />
<Compile Include="Extensions\Pipelines\CorsPipeline.cs" /> <Compile Include="Extensions\Pipelines\CorsPipeline.cs" />
<Compile Include="Frontend\Mappers\LoginHtmlMapper.cs" />
<Compile Include="Profiles\Delay\DelayProfileModule.cs" /> <Compile Include="Profiles\Delay\DelayProfileModule.cs" />
<Compile Include="Profiles\Delay\DelayProfileResource.cs" /> <Compile Include="Profiles\Delay\DelayProfileResource.cs" />
<Compile Include="Profiles\Delay\DelayProfileValidator.cs" /> <Compile Include="Profiles\Delay\DelayProfileValidator.cs" />

View File

@ -57,7 +57,7 @@ private Response GetStatus()
IsOsx = OsInfo.IsOsx, IsOsx = OsInfo.IsOsx,
IsWindows = OsInfo.IsWindows, IsWindows = OsInfo.IsWindows,
Branch = _configFileProvider.Branch, Branch = _configFileProvider.Branch,
Authentication = _configFileProvider.AuthenticationEnabled, Authentication = _configFileProvider.AuthenticationMethod,
SqliteVersion = _database.Version, SqliteVersion = _database.Version,
UrlBase = _configFileProvider.UrlBase, UrlBase = _configFileProvider.UrlBase,
RuntimeVersion = _runtimeInfo.RuntimeVersion RuntimeVersion = _runtimeInfo.RuntimeVersion

View File

@ -4,6 +4,7 @@
<package id="FluentValidation" version="5.5.0.0" targetFramework="net40" /> <package id="FluentValidation" version="5.5.0.0" targetFramework="net40" />
<package id="Nancy" version="0.23.2" targetFramework="net40" /> <package id="Nancy" version="0.23.2" targetFramework="net40" />
<package id="Nancy.Authentication.Basic" version="0.23.2" targetFramework="net40" /> <package id="Nancy.Authentication.Basic" version="0.23.2" targetFramework="net40" />
<package id="Nancy.Authentication.Forms" version="0.23.2" targetFramework="net40" />
<package id="Newtonsoft.Json" version="6.0.6" targetFramework="net40" /> <package id="Newtonsoft.Json" version="6.0.6" targetFramework="net40" />
<package id="NLog" version="2.1.0" targetFramework="net40" /> <package id="NLog" version="2.1.0" targetFramework="net40" />
<package id="ValueInjecter" version="2.3.3" targetFramework="net40" /> <package id="ValueInjecter" version="2.3.3" targetFramework="net40" />

View File

@ -3,6 +3,7 @@
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@ -126,9 +127,9 @@ public void GetValue_New_Key()
[Test] [Test]
public void GetAuthenticationType_No_Existing_Value() public void GetAuthenticationType_No_Existing_Value()
{ {
var result = Subject.AuthenticationEnabled; var result = Subject.AuthenticationMethod;
result.Should().Be(false); result.Should().Be(AuthenticationType.None);
} }
[Test] [Test]

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Core.Authentication
{
public enum AuthenticationType
{
None = 0,
Basic = 1,
Forms = 2
}
}

View File

@ -0,0 +1,12 @@
using System;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Authentication
{
public class User : ModelBase
{
public Guid Identifier { get; set; }
public string Username { get; set; }
public string Password { get; set; }
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Authentication
{
public interface IUserRepository : IBasicRepository<User>
{
User FindUser(string username);
User FindUser(Guid identifier);
}
public class UserRepository : BasicRepository<User>, IUserRepository
{
public UserRepository(IDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public User FindUser(string username)
{
return Query.Where(u => u.Username == username).SingleOrDefault();
}
public User FindUser(Guid identifier)
{
return Query.Where(u => u.Identifier == identifier).SingleOrDefault();
}
}
}

View File

@ -0,0 +1,121 @@
using System;
using System.Linq;
using System.Xml.Linq;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Authentication
{
public interface IUserService
{
User Add(string username, string password);
User Update(User user);
User Upsert(string username, string password);
User FindUser();
User FindUser(string username, string password);
User FindUser(Guid identifier);
}
public class UserService : IUserService, IHandle<ApplicationStartedEvent>
{
private readonly IUserRepository _repo;
private readonly IAppFolderInfo _appFolderInfo;
private readonly IDiskProvider _diskProvider;
public UserService(IUserRepository repo, IAppFolderInfo appFolderInfo, IDiskProvider diskProvider)
{
_repo = repo;
_appFolderInfo = appFolderInfo;
_diskProvider = diskProvider;
}
public User Add(string username, string password)
{
return _repo.Insert(new User
{
Identifier = Guid.NewGuid(),
Username = username.ToLowerInvariant(),
Password = password.SHA256Hash()
});
}
public User Update(User user)
{
return _repo.Update(user);
}
public User Upsert(string username, string password)
{
var user = FindUser();
if (user == null)
{
return Add(username, password);
}
if (user.Password != password)
{
user.Password = password.SHA256Hash();
}
user.Username = username.ToLowerInvariant();
return Update(user);
}
public User FindUser()
{
return _repo.SingleOrDefault();
}
public User FindUser(string username, string password)
{
var user = _repo.FindUser(username.ToLowerInvariant());
if (user.Password == password.SHA256Hash())
{
return user;
}
return null;
}
public User FindUser(Guid identifier)
{
return _repo.FindUser(identifier);
}
public void Handle(ApplicationStartedEvent message)
{
if (_repo.All().Any())
{
return;
}
var configFile = _appFolderInfo.GetConfigPath();
if (!_diskProvider.FileExists(configFile))
{
return;
}
var xDoc = XDocument.Load(configFile);
var config = xDoc.Descendants("Config").Single();
var usernameElement = config.Descendants("Username").FirstOrDefault();
var passwordElement = config.Descendants("Password").FirstOrDefault();
if (usernameElement == null || passwordElement == null)
{
return;
}
var username = usernameElement.Value;
var password = passwordElement.Value;
Add(username, password);
}
}
}

View File

@ -9,6 +9,7 @@
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
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.Commands;
@ -29,14 +30,11 @@ public interface IConfigFileProvider : IHandleAsync<ApplicationStartedEvent>,
int SslPort { get; } int SslPort { get; }
bool EnableSsl { get; } bool EnableSsl { get; }
bool LaunchBrowser { get; } bool LaunchBrowser { get; }
bool AuthenticationEnabled { get; } AuthenticationType AuthenticationMethod { get; }
bool AnalyticsEnabled { get; } bool AnalyticsEnabled { get; }
string Username { get; }
string Password { get; }
string LogLevel { get; } string LogLevel { get; }
string Branch { get; } string Branch { get; }
string ApiKey { get; } string ApiKey { get; }
bool Torrent { get; }
string SslCertHash { get; } string SslCertHash { get; }
string UrlBase { get; } string UrlBase { get; }
Boolean UpdateAutomatically { get; } Boolean UpdateAutomatically { get; }
@ -163,14 +161,20 @@ public string ApiKey
} }
} }
public bool Torrent public AuthenticationType AuthenticationMethod
{ {
get { return GetValueBoolean("Torrent", false, persist: false); } get
{
var enabled = GetValueBoolean("AuthenticationEnabled", false, false);
if (enabled)
{
SetValue("AuthenticationMethod", AuthenticationType.Basic);
return AuthenticationType.Basic;
} }
public bool AuthenticationEnabled return GetValueEnum("AuthenticationMethod", AuthenticationType.None);
{ }
get { return GetValueBoolean("AuthenticationEnabled", false); }
} }
public bool AnalyticsEnabled public bool AnalyticsEnabled
@ -186,16 +190,6 @@ public string Branch
get { return GetValue("Branch", "master").ToLowerInvariant(); } get { return GetValue("Branch", "master").ToLowerInvariant(); }
} }
public string Username
{
get { return GetValue("Username", ""); }
}
public string Password
{
get { return GetValue("Password", ""); }
}
public string LogLevel public string LogLevel
{ {
get { return GetValue("LogLevel", "Info"); } get { return GetValue("LogLevel", "Info"); }

View File

@ -289,6 +289,26 @@ public bool CleanupMetadataImages
set { SetValue("CleanupMetadataImages", value); } set { SetValue("CleanupMetadataImages", value); }
} }
public String RijndaelPassphrase
{
get { return GetValue("RijndaelPassphrase", Guid.NewGuid().ToString(), true); }
}
public String HmacPassphrase
{
get { return GetValue("HmacPassphrase", Guid.NewGuid().ToString(), true); }
}
public String RijndaelSalt
{
get { return GetValue("RijndaelSalt", Guid.NewGuid().ToString(), true); }
}
public String HmacSalt
{
get { return GetValue("HmacSalt", Guid.NewGuid().ToString(), true); }
}
private string GetValue(string key) private string GetValue(string key)
{ {
return GetValue(key, String.Empty); return GetValue(key, String.Empty);

View File

@ -56,9 +56,14 @@ public interface IConfigService
String TimeFormat { get; set; } String TimeFormat { get; set; }
Boolean ShowRelativeDates { get; set; } Boolean ShowRelativeDates { get; set; }
//Internal //Internal
Boolean CleanupMetadataImages { get; set; } Boolean CleanupMetadataImages { get; set; }
//Forms Auth
string RijndaelPassphrase { get; }
string HmacPassphrase { get; }
string RijndaelSalt { get; }
string HmacSalt { get; }
} }
} }

View File

@ -0,0 +1,38 @@
using System;
using Marr.Data.Converters;
using Marr.Data.Mapping;
namespace NzbDrone.Core.Datastore.Converters
{
public class GuidConverter : IConverter
{
public Object FromDB(ConverterContext context)
{
if (context.DbValue == DBNull.Value)
{
return Guid.Empty;
}
var value = (string)context.DbValue;
return new Guid(value);
}
public Object FromDB(ColumnMap map, Object dbValue)
{
return FromDB(new ConverterContext { ColumnMap = map, DbValue = dbValue });
}
public Object ToDB(Object clrValue)
{
var value = clrValue;
return value.ToString();
}
public Type DbType
{
get { return typeof(string); }
}
}
}

View File

@ -0,0 +1,17 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(76)]
public class add_users_table : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("Users")
.WithColumn("Identifier").AsString().NotNullable().Unique()
.WithColumn("Username").AsString().NotNullable().Unique()
.WithColumn("Password").AsString().NotNullable();
}
}
}

View File

@ -30,6 +30,7 @@
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Core.Authentication;
namespace NzbDrone.Core.Datastore namespace NzbDrone.Core.Datastore
{ {
@ -101,6 +102,7 @@ public static void Map()
Mapper.Entity<Restriction>().RegisterModel("Restrictions"); Mapper.Entity<Restriction>().RegisterModel("Restrictions");
Mapper.Entity<DelayProfile>().RegisterModel("DelayProfiles"); Mapper.Entity<DelayProfile>().RegisterModel("DelayProfiles");
Mapper.Entity<User>().RegisterModel("Users");
} }
private static void RegisterMappers() private static void RegisterMappers()
@ -122,6 +124,7 @@ private static void RegisterMappers()
MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter());
MapRepository.Instance.RegisterTypeConverter(typeof(HashSet<Int32>), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(HashSet<Int32>), new EmbeddedDocumentConverter());
MapRepository.Instance.RegisterTypeConverter(typeof(OsPath), new OsPathConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(OsPath), new OsPathConverter());
MapRepository.Instance.RegisterTypeConverter(typeof(Guid), new GuidConverter());
} }
private static void RegisterProviderSettingConverter() private static void RegisterProviderSettingConverter()

View File

@ -115,6 +115,10 @@
</Compile> </Compile>
<Compile Include="Analytics\AnalyticsService.cs" /> <Compile Include="Analytics\AnalyticsService.cs" />
<Compile Include="Annotations\FieldDefinitionAttribute.cs" /> <Compile Include="Annotations\FieldDefinitionAttribute.cs" />
<Compile Include="Authentication\AuthenticationType.cs" />
<Compile Include="Authentication\User.cs" />
<Compile Include="Authentication\UserRepository.cs" />
<Compile Include="Authentication\UserService.cs" />
<Compile Include="Backup\Backup.cs" /> <Compile Include="Backup\Backup.cs" />
<Compile Include="Backup\BackupCommand.cs" /> <Compile Include="Backup\BackupCommand.cs" />
<Compile Include="Backup\BackupService.cs" /> <Compile Include="Backup\BackupService.cs" />
@ -153,6 +157,7 @@
<Compile Include="Datastore\Converters\EmbeddedDocumentConverter.cs" /> <Compile Include="Datastore\Converters\EmbeddedDocumentConverter.cs" />
<Compile Include="Datastore\Converters\EnumIntConverter.cs" /> <Compile Include="Datastore\Converters\EnumIntConverter.cs" />
<Compile Include="Datastore\Converters\Int32Converter.cs" /> <Compile Include="Datastore\Converters\Int32Converter.cs" />
<Compile Include="Datastore\Converters\GuidConverter.cs" />
<Compile Include="Datastore\Converters\OsPathConverter.cs" /> <Compile Include="Datastore\Converters\OsPathConverter.cs" />
<Compile Include="Datastore\Converters\ProviderSettingConverter.cs" /> <Compile Include="Datastore\Converters\ProviderSettingConverter.cs" />
<Compile Include="Datastore\Converters\QualityIntConverter.cs" /> <Compile Include="Datastore\Converters\QualityIntConverter.cs" />
@ -236,6 +241,7 @@
<Compile Include="Datastore\Migration\069_quality_proper.cs" /> <Compile Include="Datastore\Migration\069_quality_proper.cs" />
<Compile Include="Datastore\Migration\071_unknown_quality_in_profile.cs" /> <Compile Include="Datastore\Migration\071_unknown_quality_in_profile.cs" />
<Compile Include="Datastore\Migration\072_history_grabid.cs" /> <Compile Include="Datastore\Migration\072_history_grabid.cs" />
<Compile Include="Datastore\Migration\076_add_users_table.cs" />
<Compile Include="Datastore\Migration\075_force_lib_update.cs" /> <Compile Include="Datastore\Migration\075_force_lib_update.cs" />
<Compile Include="Datastore\Migration\074_disable_eztv.cs" /> <Compile Include="Datastore\Migration\074_disable_eztv.cs" />
<Compile Include="Datastore\Migration\073_clear_ratings.cs" /> <Compile Include="Datastore\Migration\073_clear_ratings.cs" />

View File

@ -284,3 +284,11 @@ dl.info {
background-color : #17B1D9; background-color : #17B1D9;
} }
} }
.login {
color : #ececec;
h2 {
vertical-align : bottom;
}
}

View File

@ -35,7 +35,7 @@ define(
}, },
onRender: function(){ onRender: function(){
if(!this.ui.authToggle.prop('checked')){ if(this.ui.authToggle.val() === 'none'){
this.ui.authOptions.hide(); this.ui.authOptions.hide();
} }
@ -61,7 +61,7 @@ define(
_setAuthOptionsVisibility: function () { _setAuthOptionsVisibility: function () {
var showAuthOptions = this.ui.authToggle.prop('checked'); var showAuthOptions = this.ui.authToggle.val() !== 'none';
if (showAuthOptions) { if (showAuthOptions) {
this.ui.authOptions.slideDown(); this.ui.authOptions.slideDown();

View File

@ -112,21 +112,17 @@
<div class="form-group"> <div class="form-group">
<label class="col-sm-3 control-label">Authentication</label> <label class="col-sm-3 control-label">Authentication</label>
<div class="col-sm-8"> <div class="col-sm-1 col-sm-push-4 help-inline">
<div class="input-group"> <i class="icon-nd-form-warning" title="Requires restart to take effect"/>
<label class="checkbox toggle well">
<input type="checkbox" class="x-auth" name="authenticationEnabled"/>
<p>
<span>On</span>
<span>Off</span>
</p>
<div class="btn btn-primary slide-button"/>
</label>
<span class="help-inline-checkbox">
<i class="icon-nd-form-info" title="Require Username and Password to access Sonarr"/> <i class="icon-nd-form-info" title="Require Username and Password to access Sonarr"/>
</span>
</div> </div>
<div class="col-sm-4 col-sm-pull-1">
<select name="authenticationMethod" class="form-control x-auth">
<option value="none">None</option>
<option value="basic">Basic (Browser popup)</option>
<option value="forms">Forms (Login page)</option>
</select>
</div> </div>
</div> </div>

View File

@ -296,13 +296,14 @@ define(
app.addInitializer(function () { app.addInitializer(function () {
var footerText = serverStatusModel.get('version'); var version = serverStatusModel.get('version');
var branch = serverStatusModel.get('branch');
if (serverStatusModel.get('branch') !== 'master') { $('#footer-region .version').html(version);
footerText += '</br>' + serverStatusModel.get('branch');
if (branch !== 'master') {
$('#footer-region .branch').html(branch);
} }
$('#footer-region .version').html(footerText);
}); });
return app; return app;

View File

@ -66,6 +66,7 @@
<div id="footer-region"> <div id="footer-region">
Sonarr Ver. Sonarr Ver.
<span class="version"></span> <span class="version"></span>
<div class="branch"></div>
</div> </div>
</div> </div>
</div> </div>

59
src/UI/login.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title>Sonarr - Login</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<link href="/Content/bootstrap.css" rel='stylesheet' type='text/css'/>
<link href="/Content/theme.css" rel='stylesheet' type='text/css'/>
<link rel="apple-touch-icon" href="/Content/Images/touch/57.png"/>
<link rel="apple-touch-icon" sizes="72x72" href="/Content/Images/touch/72.png"/>
<link rel="apple-touch-icon" sizes="114x114" href="/Content/Images/touch/114.png"/>
<link rel="apple-touch-icon" sizes="144x144" href="/Content/Images/touch/144.png"/>
<link rel="icon" type="image/ico" href="/Content/Images/favicon.ico"/>
</head>
<body>
<div class="container">
<div id="nav-region"></div>
</div>
<div id="page">
<div class="page-container">
<div class="container-fluid">
<div class="container-fluid main-region" id="main-region">
<div class="col-md-2 col-md-offset-5">
<form name="login" id="login" class="login" method="POST">
<h2><img src="/Content/Images/logos/32.png" alt=""/> Sonarr</h2>
<div class="form-group">
<label for="username" class="sr-only">Email address</label>
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
</div>
<div class="form-group">
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="rememberMe" checked="checked"> Remember me
</label>
</div>
<input class="btn btn-lg btn-primary btn-block" type="submit" value="Log in" />
</form>
</div>
</div>
<div id="modal-region"></div>
<div id="file-browser-modal-region"></div>
</div>
</div>
<a id="scroll-up" title="Back to the top!">
<i class="icon-circle-arrow-up"></i>
</a>
</div>
</body>
</html>