From 3c756348ebd0ec5f01fda0291f9ce1c039e7fcd0 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 25 Jan 2015 18:03:21 -0800 Subject: [PATCH] New: Forms authentication --- gulp/build.js | 4 +- gulp/copy.js | 8 +- gulp/paths.js | 2 +- gulp/watch.js | 9 +- src/NzbDrone.Api/Authentication/1tews5g3.gd1~ | 62 +++++++++ .../Authentication/AuthenticationService.cs | 49 ++++--- .../Authentication/EnableAuthInNancy.cs | 59 ++++++++- .../Authentication/LoginModule.cs | 39 ++++++ .../Authentication/LoginResource.cs | 9 ++ src/NzbDrone.Api/Config/HostConfigModule.cs | 23 +++- src/NzbDrone.Api/Config/HostConfigResource.cs | 3 +- .../Extensions/RequestExtensions.cs | 10 ++ .../Frontend/Mappers/IndexHtmlMapper.cs | 2 +- .../Frontend/Mappers/LoginHtmlMapper.cs | 88 +++++++++++++ src/NzbDrone.Api/NzbDrone.Api.csproj | 6 + src/NzbDrone.Api/System/SystemModule.cs | 2 +- src/NzbDrone.Api/packages.config | 1 + .../ConfigFileProviderTest.cs | 5 +- .../Authentication/AuthenticationType.cs | 9 ++ src/NzbDrone.Core/Authentication/User.cs | 12 ++ .../Authentication/UserRepository.cs | 31 +++++ .../Authentication/UserService.cs | 121 ++++++++++++++++++ .../Configuration/ConfigFileProvider.cs | 34 ++--- .../Configuration/ConfigService.cs | 20 +++ .../Configuration/IConfigService.cs | 9 +- .../Datastore/Converters/GuidConverter.cs | 38 ++++++ .../Migration/076_add_users_table.cs | 17 +++ src/NzbDrone.Core/Datastore/TableMapping.cs | 3 + src/NzbDrone.Core/NzbDrone.Core.csproj | 6 + src/UI/Content/theme.less | 8 ++ src/UI/Settings/General/GeneralView.js | 4 +- .../Settings/General/GeneralViewTemplate.hbs | 24 ++-- src/UI/app.js | 11 +- src/UI/index.html | 1 + src/UI/login.html | 59 +++++++++ 35 files changed, 707 insertions(+), 81 deletions(-) create mode 100644 src/NzbDrone.Api/Authentication/1tews5g3.gd1~ create mode 100644 src/NzbDrone.Api/Authentication/LoginModule.cs create mode 100644 src/NzbDrone.Api/Authentication/LoginResource.cs create mode 100644 src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs create mode 100644 src/NzbDrone.Core/Authentication/AuthenticationType.cs create mode 100644 src/NzbDrone.Core/Authentication/User.cs create mode 100644 src/NzbDrone.Core/Authentication/UserRepository.cs create mode 100644 src/NzbDrone.Core/Authentication/UserService.cs create mode 100644 src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs create mode 100644 src/UI/login.html diff --git a/gulp/build.js b/gulp/build.js index aa519f453..c48e00a24 100644 --- a/gulp/build.js +++ b/gulp/build.js @@ -9,5 +9,5 @@ require('./copy'); gulp.task('build', function () { return runSequence('clean', - ['requireJs', 'less', 'handlebars', 'copyIndex', 'copyContent']); -}); \ No newline at end of file + ['requireJs', 'less', 'handlebars', 'copyHtml', 'copyContent']); +}); diff --git a/gulp/copy.js b/gulp/copy.js index e5210bfc5..a83bc8227 100644 --- a/gulp/copy.js +++ b/gulp/copy.js @@ -11,13 +11,13 @@ gulp.task('copyJs', function () { .pipe(gulp.dest(paths.dest.root)); }); -gulp.task('copyIndex', function () { - return gulp.src(paths.src.index) - .pipe(cache('copyIndex')) +gulp.task('copyHtml', function () { + return gulp.src(paths.src.html) + .pipe(cache('copyHtml')) .pipe(gulp.dest(paths.dest.root)); }); gulp.task('copyContent', function () { return gulp.src([paths.src.content + '**/*.*', '!**/*.less']) .pipe(gulp.dest(paths.dest.content)); -}); \ No newline at end of file +}); diff --git a/gulp/paths.js b/gulp/paths.js index bcb443c76..dac3feabd 100644 --- a/gulp/paths.js +++ b/gulp/paths.js @@ -2,7 +2,7 @@ module.exports = { src: { root: './src/UI/', templates: './src/UI/**/*.hbs', - index: './src/UI/index.html', + html: './src/UI/*.html', partials: './src/UI/**/*Partial.hbs', scripts: './src/UI/**/*.js', less: ['./src/UI/**/*.less'], diff --git a/gulp/watch.js b/gulp/watch.js index eff7f023c..69a489316 100644 --- a/gulp/watch.js +++ b/gulp/watch.js @@ -10,11 +10,11 @@ require('./less.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.templates, ['handlebars']); 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']); }); @@ -23,8 +23,9 @@ gulp.task('liveReload', ['jshint', 'handlebars', 'less', 'copyJs'], function () gulp.watch([ 'app/**/*.js', 'app/**/*.css', - 'app/index.html' + 'app/index.html', + 'app/login.html' ]).on('change', function (file) { server.changed(file.path); }); -}); \ No newline at end of file +}); diff --git a/src/NzbDrone.Api/Authentication/1tews5g3.gd1~ b/src/NzbDrone.Api/Authentication/1tews5g3.gd1~ new file mode 100644 index 000000000..7eeeec032 --- /dev/null +++ b/src/NzbDrone.Api/Authentication/1tews5g3.gd1~ @@ -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 + }); + } + } +} diff --git a/src/NzbDrone.Api/Authentication/AuthenticationService.cs b/src/NzbDrone.Api/Authentication/AuthenticationService.cs index 9ca63f603..e23bb89ae 100644 --- a/src/NzbDrone.Api/Authentication/AuthenticationService.cs +++ b/src/NzbDrone.Api/Authentication/AuthenticationService.cs @@ -2,14 +2,16 @@ using System.Linq; using Nancy; using Nancy.Authentication.Basic; +using Nancy.Authentication.Forms; using Nancy.Security; using NzbDrone.Api.Extensions; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; namespace NzbDrone.Api.Authentication { - public interface IAuthenticationService : IUserValidator + public interface IAuthenticationService : IUserValidator, IUserMapper { bool IsAuthenticated(NancyContext context); } @@ -17,37 +19,49 @@ namespace NzbDrone.Api.Authentication public class AuthenticationService : IAuthenticationService { private readonly IConfigFileProvider _configFileProvider; + private readonly IUserService _userService; private static readonly NzbDroneUser AnonymousUser = new NzbDroneUser { UserName = "Anonymous" }; private static String API_KEY; - public AuthenticationService(IConfigFileProvider configFileProvider) + public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService) { _configFileProvider = configFileProvider; + _userService = userService; API_KEY = configFileProvider.ApiKey; } public IUserIdentity Validate(string username, string password) { - if (!Enabled) + if (_configFileProvider.AuthenticationMethod == AuthenticationType.None) { return AnonymousUser; } - if (_configFileProvider.Username.Equals(username) && - _configFileProvider.Password.Equals(password)) + var user = _userService.FindUser(username, password); + + if (user != null) { - return new NzbDroneUser { UserName = username }; + return new NzbDroneUser { UserName = user.Username }; } return null; } - private bool Enabled + public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context) { - get + if (_configFileProvider.AuthenticationMethod == AuthenticationType.None) + { + return AnonymousUser; + } + + var user = _userService.FindUser(identifier); + + if (user != null) { - return _configFileProvider.AuthenticationEnabled; + return new NzbDroneUser { UserName = user.Username }; } + + return null; } public bool IsAuthenticated(NancyContext context) @@ -59,13 +73,13 @@ namespace NzbDrone.Api.Authentication 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)) { return true; @@ -74,7 +88,12 @@ namespace NzbDrone.Api.Authentication return false; } - if (!Enabled) + if (context.Request.IsLoginRequest()) + { + return true; + } + + if (context.Request.IsContentRequest()) { return true; } diff --git a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs b/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs index fbccb3a2b..6c982119c 100644 --- a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs +++ b/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs @@ -1,23 +1,46 @@ -using Nancy; +using System; +using System.Text; +using Nancy; using Nancy.Authentication.Basic; +using Nancy.Authentication.Forms; using Nancy.Bootstrapper; +using Nancy.Cryptography; +using NzbDrone.Api.Extensions; using NzbDrone.Api.Extensions.Pipelines; +using NzbDrone.Core.Authentication; +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) + public EnableAuthInNancy(IAuthenticationService authenticationService, + IConfigService configService, + IConfigFileProvider configFileProvider) { _authenticationService = authenticationService; + _configService = configService; + _configFileProvider = configFileProvider; } public void Register(IPipelines pipelines) { - pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr")); + if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms) + { + RegisterFormsAuth(pipelines); + } + + else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic) + { + pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr")); + } + pipelines.BeforeRequest.AddItemToEndOfPipeline(RequiresAuthentication); + pipelines.AfterRequest.AddItemToEndOfPipeline(RemoveLoginHooksForApiCalls); } private Response RequiresAuthentication(NancyContext context) @@ -31,5 +54,33 @@ namespace NzbDrone.Api.Authentication 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); + } + } + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Authentication/LoginModule.cs b/src/NzbDrone.Api/Authentication/LoginModule.cs new file mode 100644 index 000000000..e1de88026 --- /dev/null +++ b/src/NzbDrone.Api/Authentication/LoginModule.cs @@ -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()); + } + + 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); + } + } +} diff --git a/src/NzbDrone.Api/Authentication/LoginResource.cs b/src/NzbDrone.Api/Authentication/LoginResource.cs new file mode 100644 index 000000000..5d6a5c9f5 --- /dev/null +++ b/src/NzbDrone.Api/Authentication/LoginResource.cs @@ -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; } + } +} diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/NzbDrone.Api/Config/HostConfigModule.cs index ee523087c..c0e99d265 100644 --- a/src/NzbDrone.Api/Config/HostConfigModule.cs +++ b/src/NzbDrone.Api/Config/HostConfigModule.cs @@ -2,6 +2,8 @@ using System.Reflection; using FluentValidation; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; using NzbDrone.Core.Validation; @@ -13,11 +15,13 @@ namespace NzbDrone.Api.Config public class HostConfigModule : NzbDroneRestModule { private readonly IConfigFileProvider _configFileProvider; + private readonly IUserService _userService; - public HostConfigModule(IConfigFileProvider configFileProvider) + public HostConfigModule(IConfigFileProvider configFileProvider, IUserService userService) : base("/config/host") { _configFileProvider = configFileProvider; + _userService = userService; GetResourceSingle = GetHostConfig; GetResourceById = GetHostConfig; @@ -26,8 +30,8 @@ namespace NzbDrone.Api.Config 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.Username).NotEmpty().When(c => c.AuthenticationEnabled); - SharedValidator.RuleFor(c => c.Password).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.AuthenticationMethod != AuthenticationType.None); SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl); SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl && OsInfo.IsWindows); @@ -46,6 +50,14 @@ namespace NzbDrone.Api.Config resource.InjectFrom(_configFileProvider); resource.Id = 1; + var user = _userService.FindUser(); + + if (user != null) + { + resource.Username = user.Username; + resource.Password = user.Password; + } + return resource; } @@ -61,6 +73,11 @@ namespace NzbDrone.Api.Config .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); _configFileProvider.SaveConfigDictionary(dictionary); + + if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace()) + { + _userService.Upsert(resource.Username, resource.Password); + } } } } diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/NzbDrone.Api/Config/HostConfigResource.cs index 7cdf741ae..3420e2d4c 100644 --- a/src/NzbDrone.Api/Config/HostConfigResource.cs +++ b/src/NzbDrone.Api/Config/HostConfigResource.cs @@ -1,5 +1,6 @@ using System; using NzbDrone.Api.REST; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Update; namespace NzbDrone.Api.Config @@ -11,7 +12,7 @@ namespace NzbDrone.Api.Config public Int32 SslPort { get; set; } public Boolean EnableSsl { get; set; } public Boolean LaunchBrowser { get; set; } - public bool AuthenticationEnabled { get; set; } + public AuthenticationType AuthenticationMethod { get; set; } public Boolean AnalyticsEnabled { get; set; } public String Username { get; set; } public String Password { get; set; } diff --git a/src/NzbDrone.Api/Extensions/RequestExtensions.cs b/src/NzbDrone.Api/Extensions/RequestExtensions.cs index a07ab687d..6c112c900 100644 --- a/src/NzbDrone.Api/Extensions/RequestExtensions.cs +++ b/src/NzbDrone.Api/Extensions/RequestExtensions.cs @@ -26,5 +26,15 @@ namespace NzbDrone.Api.Extensions request.UserHostAddress.Equals("127.0.0.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); + } } } diff --git a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs index b355fce5c..3b09cfdf9 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs +++ b/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Api.Frontend.Mappers public override bool CanHandle(string resourceUrl) { - return !resourceUrl.Contains("."); + return !resourceUrl.Contains(".") && !resourceUrl.StartsWith("/login"); } public override Response GetResponse(string resourceUrl) diff --git a/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs new file mode 100644 index 000000000..33bb60257 --- /dev/null +++ b/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs @@ -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 _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 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; + } + } +} diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index ce03f5f22..5a98e275c 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -52,6 +52,9 @@ False ..\packages\Nancy.Authentication.Basic.0.23.2\lib\net40\Nancy.Authentication.Basic.dll + + ..\packages\Nancy.Authentication.Forms.0.23.2\lib\net40\Nancy.Authentication.Forms.dll + False ..\packages\Newtonsoft.Json.6.0.6\lib\net40\Newtonsoft.Json.dll @@ -80,6 +83,8 @@ + + @@ -94,6 +99,7 @@ + diff --git a/src/NzbDrone.Api/System/SystemModule.cs b/src/NzbDrone.Api/System/SystemModule.cs index 2fe13c530..d2ae34d89 100644 --- a/src/NzbDrone.Api/System/SystemModule.cs +++ b/src/NzbDrone.Api/System/SystemModule.cs @@ -57,7 +57,7 @@ namespace NzbDrone.Api.System IsOsx = OsInfo.IsOsx, IsWindows = OsInfo.IsWindows, Branch = _configFileProvider.Branch, - Authentication = _configFileProvider.AuthenticationEnabled, + Authentication = _configFileProvider.AuthenticationMethod, SqliteVersion = _database.Version, UrlBase = _configFileProvider.UrlBase, RuntimeVersion = _runtimeInfo.RuntimeVersion diff --git a/src/NzbDrone.Api/packages.config b/src/NzbDrone.Api/packages.config index 10f28ba95..071109658 100644 --- a/src/NzbDrone.Api/packages.config +++ b/src/NzbDrone.Api/packages.config @@ -4,6 +4,7 @@ + diff --git a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs index 43bfc00f9..e3a937f4a 100644 --- a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs +++ b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs @@ -3,6 +3,7 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Test.Common; @@ -126,9 +127,9 @@ namespace NzbDrone.Common.Test [Test] public void GetAuthenticationType_No_Existing_Value() { - var result = Subject.AuthenticationEnabled; + var result = Subject.AuthenticationMethod; - result.Should().Be(false); + result.Should().Be(AuthenticationType.None); } [Test] diff --git a/src/NzbDrone.Core/Authentication/AuthenticationType.cs b/src/NzbDrone.Core/Authentication/AuthenticationType.cs new file mode 100644 index 000000000..9f21b07a7 --- /dev/null +++ b/src/NzbDrone.Core/Authentication/AuthenticationType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Authentication +{ + public enum AuthenticationType + { + None = 0, + Basic = 1, + Forms = 2 + } +} diff --git a/src/NzbDrone.Core/Authentication/User.cs b/src/NzbDrone.Core/Authentication/User.cs new file mode 100644 index 000000000..794d4824a --- /dev/null +++ b/src/NzbDrone.Core/Authentication/User.cs @@ -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; } + } +} diff --git a/src/NzbDrone.Core/Authentication/UserRepository.cs b/src/NzbDrone.Core/Authentication/UserRepository.cs new file mode 100644 index 000000000..d9e2ed992 --- /dev/null +++ b/src/NzbDrone.Core/Authentication/UserRepository.cs @@ -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 FindUser(string username); + User FindUser(Guid identifier); + } + + public class UserRepository : BasicRepository, 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(); + } + } +} diff --git a/src/NzbDrone.Core/Authentication/UserService.cs b/src/NzbDrone.Core/Authentication/UserService.cs new file mode 100644 index 000000000..274bab7d5 --- /dev/null +++ b/src/NzbDrone.Core/Authentication/UserService.cs @@ -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 + { + 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); + } + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 9f368b232..6c10e7d0c 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Commands; @@ -29,14 +30,11 @@ namespace NzbDrone.Core.Configuration int SslPort { get; } bool EnableSsl { get; } bool LaunchBrowser { get; } - bool AuthenticationEnabled { get; } + AuthenticationType AuthenticationMethod { get; } bool AnalyticsEnabled { get; } - string Username { get; } - string Password { get; } string LogLevel { get; } string Branch { get; } string ApiKey { get; } - bool Torrent { get; } string SslCertHash { get; } string UrlBase { get; } Boolean UpdateAutomatically { get; } @@ -163,14 +161,20 @@ namespace NzbDrone.Core.Configuration } } - public bool Torrent + public AuthenticationType AuthenticationMethod { - get { return GetValueBoolean("Torrent", false, persist: false); } - } + get + { + var enabled = GetValueBoolean("AuthenticationEnabled", false, false); - public bool AuthenticationEnabled - { - get { return GetValueBoolean("AuthenticationEnabled", false); } + if (enabled) + { + SetValue("AuthenticationMethod", AuthenticationType.Basic); + return AuthenticationType.Basic; + } + + return GetValueEnum("AuthenticationMethod", AuthenticationType.None); + } } public bool AnalyticsEnabled @@ -186,16 +190,6 @@ namespace NzbDrone.Core.Configuration get { return GetValue("Branch", "master").ToLowerInvariant(); } } - public string Username - { - get { return GetValue("Username", ""); } - } - - public string Password - { - get { return GetValue("Password", ""); } - } - public string LogLevel { get { return GetValue("LogLevel", "Info"); } diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 1b715aec3..1cd6c9d43 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -289,6 +289,26 @@ namespace NzbDrone.Core.Configuration 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) { return GetValue(key, String.Empty); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index c84805647..5f991ba99 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -56,9 +56,14 @@ namespace NzbDrone.Core.Configuration String TimeFormat { get; set; } Boolean ShowRelativeDates { get; set; } - - //Internal Boolean CleanupMetadataImages { get; set; } + + + //Forms Auth + string RijndaelPassphrase { get; } + string HmacPassphrase { get; } + string RijndaelSalt { get; } + string HmacSalt { get; } } } diff --git a/src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs b/src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs new file mode 100644 index 000000000..bbd459a42 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/GuidConverter.cs @@ -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); } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs b/src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs new file mode 100644 index 000000000..7933d90d4 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs @@ -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(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index ae628352c..9cdb04778 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -30,6 +30,7 @@ using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Tv; using NzbDrone.Common.Disk; +using NzbDrone.Core.Authentication; namespace NzbDrone.Core.Datastore { @@ -101,6 +102,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("Restrictions"); Mapper.Entity().RegisterModel("DelayProfiles"); + Mapper.Entity().RegisterModel("Users"); } private static void RegisterMappers() @@ -122,6 +124,7 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(HashSet), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(OsPath), new OsPathConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(Guid), new GuidConverter()); } private static void RegisterProviderSettingConverter() diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 5e7455b53..afea7a702 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -115,6 +115,10 @@ + + + + @@ -153,6 +157,7 @@ + @@ -236,6 +241,7 @@ + diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less index 10c2d5505..8aa743b66 100644 --- a/src/UI/Content/theme.less +++ b/src/UI/Content/theme.less @@ -284,3 +284,11 @@ dl.info { background-color : #17B1D9; } } + +.login { + color : #ececec; + + h2 { + vertical-align : bottom; + } +} diff --git a/src/UI/Settings/General/GeneralView.js b/src/UI/Settings/General/GeneralView.js index 38a3f8aae..157949ad1 100644 --- a/src/UI/Settings/General/GeneralView.js +++ b/src/UI/Settings/General/GeneralView.js @@ -35,7 +35,7 @@ define( }, onRender: function(){ - if(!this.ui.authToggle.prop('checked')){ + if(this.ui.authToggle.val() === 'none'){ this.ui.authOptions.hide(); } @@ -61,7 +61,7 @@ define( _setAuthOptionsVisibility: function () { - var showAuthOptions = this.ui.authToggle.prop('checked'); + var showAuthOptions = this.ui.authToggle.val() !== 'none'; if (showAuthOptions) { this.ui.authOptions.slideDown(); diff --git a/src/UI/Settings/General/GeneralViewTemplate.hbs b/src/UI/Settings/General/GeneralViewTemplate.hbs index 3e9a215c0..b82be83cd 100644 --- a/src/UI/Settings/General/GeneralViewTemplate.hbs +++ b/src/UI/Settings/General/GeneralViewTemplate.hbs @@ -112,21 +112,17 @@
-
-
-
diff --git a/src/UI/app.js b/src/UI/app.js index dc15767c7..912d2eade 100644 --- a/src/UI/app.js +++ b/src/UI/app.js @@ -296,13 +296,14 @@ define( app.addInitializer(function () { - var footerText = serverStatusModel.get('version'); + var version = serverStatusModel.get('version'); + var branch = serverStatusModel.get('branch'); - if (serverStatusModel.get('branch') !== 'master') { - footerText += '
' + serverStatusModel.get('branch'); - } + $('#footer-region .version').html(version); - $('#footer-region .version').html(footerText); + if (branch !== 'master') { + $('#footer-region .branch').html(branch); + } }); return app; diff --git a/src/UI/index.html b/src/UI/index.html index bb3186557..76d3813f0 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -66,6 +66,7 @@
diff --git a/src/UI/login.html b/src/UI/login.html new file mode 100644 index 000000000..1cce229db --- /dev/null +++ b/src/UI/login.html @@ -0,0 +1,59 @@ + + + + Sonarr - Login + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ +