diff --git a/src/Jackett.Console/Jackett.Console.csproj b/src/Jackett.Console/Jackett.Console.csproj index bee0ab1f5..bb4bd0e92 100644 --- a/src/Jackett.Console/Jackett.Console.csproj +++ b/src/Jackett.Console/Jackett.Console.csproj @@ -53,6 +53,9 @@ ..\packages\Autofac.WebApi2.Owin.3.2.0\lib\net45\Autofac.Integration.WebApi.Owin.dll + + ..\packages\Microsoft.AspNet.Identity.Core.2.2.1\lib\net45\Microsoft.AspNet.Identity.Core.dll + ..\packages\Microsoft.Owin.3.0.1\lib\net45\Microsoft.Owin.dll True @@ -65,10 +68,19 @@ ..\packages\Microsoft.Owin.Host.HttpListener.2.0.2\lib\net45\Microsoft.Owin.Host.HttpListener.dll True + + ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.1\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + ..\packages\Microsoft.Owin.Hosting.2.0.2\lib\net45\Microsoft.Owin.Hosting.dll True + + ..\packages\Microsoft.Owin.Security.3.0.1\lib\net45\Microsoft.Owin.Security.dll + + + ..\packages\Microsoft.Owin.Security.Cookies.3.0.1\lib\net45\Microsoft.Owin.Security.Cookies.dll + ..\packages\Microsoft.Owin.StaticFiles.3.0.1\lib\net45\Microsoft.Owin.StaticFiles.dll True diff --git a/src/Jackett.Console/packages.config b/src/Jackett.Console/packages.config index 66bccb817..268d933cc 100644 --- a/src/Jackett.Console/packages.config +++ b/src/Jackett.Console/packages.config @@ -4,6 +4,7 @@ + @@ -12,7 +13,10 @@ + + + diff --git a/src/Jackett/Content/custom.js b/src/Jackett/Content/custom.js index 7ecf926b6..940018b69 100644 --- a/src/Jackett/Content/custom.js +++ b/src/Jackett/Content/custom.js @@ -5,15 +5,22 @@ loadJackettSettings(); function loadJackettSettings() { getJackettConfig(function (data) { + + $("#api-key-input").val(data.config.api_key); + $("#app-version").html(data.app_version); $("#jackett-port").val(data.config.port); + var password = data.config.password; + $("#jackett-adminpwd").val(password); + if (password != null && password != '') { + $("#logoutBtn").show(); + } }); } $("#change-jackett-port").click(function () { var jackett_port = $("#jackett-port").val(); - var jsonObject = JSON.parse('{"port":"' + jackett_port + '"}'); - - var jqxhr = $.post("admin/apply_jackett_config", JSON.stringify(jsonObject), function (data) { + var jsonObject = { port: jackett_port}; + var jqxhr = $.post("/admin/apply_jackett_config", JSON.stringify(jsonObject), function (data) { if (data.result == "error") { doNotify("Error: " + data.error, "danger", "glyphicon glyphicon-alert"); @@ -34,8 +41,30 @@ $("#change-jackett-port").click(function () { }); }); +$("#change-jackett-password").click(function () { + var password = $("#jackett-adminpwd").val(); + var jsonObject = { password: password }; + + var jqxhr = $.post("/admin/set_admin_password", JSON.stringify(jsonObject), function (data) { + + if (data.result == "error") { + doNotify("Error: " + data.error, "danger", "glyphicon glyphicon-alert"); + return; + } else { + doNotify("Admin password has been set.", "success", "glyphicon glyphicon-ok"); + + window.setTimeout(function () { + window.location = window.location.pathname; + }, 1000); + + } + }).fail(function () { + doNotify("Request to Jackett server failed", "danger", "glyphicon glyphicon-alert"); + }); +}); + function getJackettConfig(callback) { - var jqxhr = $.get("admin/get_jackett_config", function (data) { + var jqxhr = $.get("/admin/get_jackett_config", function (data) { callback(data); }).fail(function () { @@ -47,9 +76,7 @@ function reloadIndexers() { $('#indexers').hide(); $('#indexers > .indexer').remove(); $('#unconfigured-indexers').empty(); - var jqxhr = $.get("admin/get_indexers", function (data) { - $("#api-key-input").val(data.api_key); - $("#app-version").html(data.app_version); + var jqxhr = $.get("/admin/get_indexers", function (data) { displayIndexers(data.items); }).fail(function () { doNotify("Error loading indexers, request to Jackett server failed", "danger", "glyphicon glyphicon-alert"); @@ -82,7 +109,7 @@ function prepareDeleteButtons() { var $btn = $(btn); var id = $btn.data("id"); $btn.click(function () { - var jqxhr = $.post("admin/delete_indexer", JSON.stringify({ indexer: id }), function (data) { + var jqxhr = $.post("/admin/delete_indexer", JSON.stringify({ indexer: id }), function (data) { if (data.result == "error") { doNotify("Delete error for " + id + "\n" + data.error, "danger", "glyphicon glyphicon-alert"); } @@ -114,7 +141,7 @@ function prepareTestButtons() { var id = $btn.data("id"); $btn.click(function () { doNotify("Test started for " + id, "info", "glyphicon glyphicon-transfer"); - var jqxhr = $.post("admin/test_indexer", JSON.stringify({ indexer: id }), function (data) { + var jqxhr = $.post("/admin/test_indexer", JSON.stringify({ indexer: id }), function (data) { if (data.result == "error") { doNotify("Test failed for " + data.name + "\n" + data.error, "danger", "glyphicon glyphicon-alert"); } @@ -130,7 +157,7 @@ function prepareTestButtons() { function displayIndexerSetup(id) { - var jqxhr = $.post("admin/get_config_form", JSON.stringify({ indexer: id }), function (data) { + var jqxhr = $.post("/admin/get_config_form", JSON.stringify({ indexer: id }), function (data) { if (data.result == "error") { doNotify("Error: " + data.error, "danger", "glyphicon glyphicon-alert"); return; @@ -200,7 +227,7 @@ function populateSetupForm(indexerId, name, config) { $goButton.prop('disabled', true); $goButton.html($('#templates > .spinner')[0].outerHTML); - var jqxhr = $.post("admin/configure_indexer", JSON.stringify(data), function (data) { + var jqxhr = $.post("/admin/configure_indexer", JSON.stringify(data), function (data) { if (data.result == "error") { if (data.config) { populateConfigItems(configForm, data.config); diff --git a/src/Jackett/Content/index.html b/src/Jackett/Content/index.html index e63a95ac6..d3b31d560 100644 --- a/src/Jackett/Content/index.html +++ b/src/Jackett/Content/index.html @@ -6,21 +6,21 @@ - - - - + + + + - - - + + + Jackett
- Jackett + Jackett
@@ -35,6 +35,16 @@ Jackett API Key:
+
+ Admin Password: + + + +

Jackett port: @@ -101,7 +111,7 @@
- +
- +
Visit @@ -153,7 +163,7 @@
- + \ No newline at end of file diff --git a/src/Jackett/Content/login.html b/src/Jackett/Content/login.html new file mode 100644 index 000000000..96b63710e --- /dev/null +++ b/src/Jackett/Content/login.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + Jackett + + +
+ + Jackett + +
+

Login

+
+
+ Admin password + +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/src/Jackett/Controllers/AdminController.cs b/src/Jackett/Controllers/AdminController.cs index b0097fbdf..0adffb411 100644 --- a/src/Jackett/Controllers/AdminController.cs +++ b/src/Jackett/Controllers/AdminController.cs @@ -1,30 +1,40 @@ using Autofac; using Jackett.Models; using Jackett.Services; +using Jackett.Utils; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; using System.Text; using System.Threading.Tasks; +using System.Web; using System.Web.Http; +using System.Web.Http.Results; +using System.Web.Security; namespace Jackett.Controllers { [RoutePrefix("admin")] + [JackettAuthorized] public class AdminController : ApiController { private IConfigurationService config; private IIndexerManagerService indexerService; private IServerService serverService; + private ISecuityService securityService; - public AdminController(IConfigurationService config, IIndexerManagerService i, IServerService ss) + public AdminController(IConfigurationService config, IIndexerManagerService i, IServerService ss, ISecuityService s) { this.config = config; indexerService = i; serverService = ss; + securityService = s; } private async Task ReadPostDataJson() @@ -33,6 +43,91 @@ namespace Jackett.Controllers return JObject.Parse(content); } + + private HttpResponseMessage GetFile(string path) + { + var result = new HttpResponseMessage(HttpStatusCode.OK); + var mappedPath = Path.Combine(config.GetContentFolder(), path); + var stream = new FileStream(mappedPath, FileMode.Open); + result.Content = new StreamContent(stream); + result.Content.Headers.ContentType = + new MediaTypeHeaderValue(MimeMapping.GetMimeMapping(mappedPath)); + + return result; + } + + [HttpGet] + [AllowAnonymous] + public RedirectResult Logout() + { + var ctx = Request.GetOwinContext(); + var authManager = ctx.Authentication; + authManager.SignOut("ApplicationCookie"); + return Redirect("/Admin/Dashboard"); + } + + [HttpGet] + [HttpPost] + [AllowAnonymous] + public async Task Dashboard() + { + if(Request.RequestUri.Query!=null && Request.RequestUri.Query.Contains("logout")) + { + var file = GetFile("login.html"); + securityService.Logout(file); + return file; + } + + + if (securityService.CheckAuthorised(Request)) + { + return GetFile("index.html"); + + } else + { + var formData = await Request.Content.ReadAsFormDataAsync(); + + if (formData!=null && securityService.HashPassword(formData["password"]) == serverService.Config.AdminPassword) + { + var file = GetFile("index.html"); + securityService.Login(file); + return file; + } else + { + return GetFile("login.html"); + } + } + } + + [Route("set_admin_password")] + [HttpPost] + public async Task SetAdminPassword() + { + var jsonReply = new JObject(); + try + { + var postData = await ReadPostDataJson(); + var password = (string)postData["password"]; + if (string.IsNullOrEmpty(password)) + { + serverService.Config.AdminPassword = string.Empty; + } + else + { + serverService.Config.AdminPassword = securityService.HashPassword(password); + } + + serverService.SaveConfig(); + jsonReply["result"] = "success"; + } + catch (Exception ex) + { + jsonReply["result"] = "error"; + jsonReply["error"] = ex.Message; + } + return Json(jsonReply); + } + [Route("get_config_form")] [HttpPost] public async Task GetConfigForm() @@ -92,8 +187,6 @@ namespace Jackett.Controllers try { jsonReply["result"] = "success"; - jsonReply["api_key"] = serverService.Config.APIKey; - jsonReply["app_version"] = config.GetVersion(); JArray items = new JArray(); foreach (var indexer in indexerService.GetAllIndexers()) @@ -163,7 +256,13 @@ namespace Jackett.Controllers var jsonReply = new JObject(); try { - jsonReply["config"] = config.ReadServerSettingsFile(); + var cfg = new JObject(); + cfg["port"] = serverService.Config.Port; + cfg["api_key"] = serverService.Config.APIKey; + cfg["password"] = string.IsNullOrEmpty(serverService.Config.AdminPassword )? string.Empty:serverService.Config.AdminPassword.Substring(0,10); + + jsonReply["config"] = cfg; + jsonReply["app_version"] = config.GetVersion(); jsonReply["result"] = "success"; } catch (CustomException ex) diff --git a/src/Jackett/Engine.cs b/src/Jackett/Engine.cs index d2b7172fa..fb4506567 100644 --- a/src/Jackett/Engine.cs +++ b/src/Jackett/Engine.cs @@ -94,6 +94,15 @@ namespace Jackett } } + public static ISecuityService SecurityService + { + get + { + return container.Resolve(); + } + } + + private static void SetupLogging(ContainerBuilder builder) { var logConfig = new LoggingConfiguration(); diff --git a/src/Jackett/Jackett.csproj b/src/Jackett/Jackett.csproj index 43fa6adbc..b6419b08b 100644 --- a/src/Jackett/Jackett.csproj +++ b/src/Jackett/Jackett.csproj @@ -73,6 +73,9 @@ ..\packages\CsQuery.1.3.4\lib\net40\CsQuery.dll + + ..\packages\Microsoft.AspNet.Identity.Core.2.2.1\lib\net45\Microsoft.AspNet.Identity.Core.dll + ..\packages\Microsoft.Owin.3.0.1\lib\net45\Microsoft.Owin.dll True @@ -85,10 +88,19 @@ ..\packages\Microsoft.Owin.Host.HttpListener.2.0.2\lib\net45\Microsoft.Owin.Host.HttpListener.dll True + + ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.1\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + ..\packages\Microsoft.Owin.Hosting.2.0.2\lib\net45\Microsoft.Owin.Hosting.dll True + + ..\packages\Microsoft.Owin.Security.3.0.1\lib\net45\Microsoft.Owin.Security.dll + + + ..\packages\Microsoft.Owin.Security.Cookies.3.0.1\lib\net45\Microsoft.Owin.Security.Cookies.dll + ..\packages\Microsoft.Owin.StaticFiles.3.0.1\lib\net45\Microsoft.Owin.StaticFiles.dll True @@ -146,6 +158,7 @@ + @@ -184,6 +197,7 @@ + @@ -202,6 +216,7 @@ + @@ -240,6 +255,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/Jackett/Models/Config/ServerConfig.cs b/src/Jackett/Models/Config/ServerConfig.cs index 932a7143f..235924a40 100644 --- a/src/Jackett/Models/Config/ServerConfig.cs +++ b/src/Jackett/Models/Config/ServerConfig.cs @@ -17,6 +17,7 @@ namespace Jackett.Models.Config public int Port { get; set; } public bool AllowExternal { get; set; } public string APIKey { get; set; } + public string AdminPassword { get; set; } public string GetListenAddress(bool? external = null) { diff --git a/src/Jackett/Services/ConfigurationService.cs b/src/Jackett/Services/ConfigurationService.cs index 9515ba41e..f9c59af48 100644 --- a/src/Jackett/Services/ConfigurationService.cs +++ b/src/Jackett/Services/ConfigurationService.cs @@ -18,7 +18,6 @@ namespace Jackett.Services string GetVersion(); string GetIndexerConfigDir(); string GetAppDataFolder(); - JObject ReadServerSettingsFile(); string GetSonarrConfigFile(); T GetConfig(); void SaveConfig(T config); @@ -168,24 +167,5 @@ namespace Jackett.Services { return Path.Combine(GetAppDataFolder(), "sonarr_api.json"); } - - - public JObject ReadServerSettingsFile() - { - var path = GetConfigFile(); - JObject jsonReply = new JObject(); - if (File.Exists(path)) - { - jsonReply = JObject.Parse(File.ReadAllText(path)); - // Port = (int)jsonReply["port"]; - // ListenPublic = (bool)jsonReply["public"]; - } - else - { - // jsonReply["port"] = Port; - // jsonReply["public"] = ListenPublic; - } - return jsonReply; - } } } diff --git a/src/Jackett/Services/SecuityService.cs b/src/Jackett/Services/SecuityService.cs new file mode 100644 index 000000000..3ec85acaf --- /dev/null +++ b/src/Jackett/Services/SecuityService.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using System.Web; + +namespace Jackett.Services +{ + public interface ISecuityService + { + bool CheckAuthorised(HttpRequestMessage request); + string HashPassword(string input); + void Login(HttpResponseMessage request); + void Logout(HttpResponseMessage request); + } + + class SecuityService : ISecuityService + { + private const string COOKIENAME = "JACKETT"; + private IServerService serverService; + + public SecuityService(IServerService ss) + { + serverService = ss; + } + + public string HashPassword(string input) + { + // Append key as salt + input += serverService.Config.APIKey; + + UnicodeEncoding UE = new UnicodeEncoding(); + byte[] hashValue; + byte[] message = UE.GetBytes(input); + + SHA512Managed hashString = new SHA512Managed(); + string hex = ""; + + hashValue = hashString.ComputeHash(message); + foreach (byte x in hashValue) + { + hex += String.Format("{0:x2}", x); + } + return hex; + } + + public void Login(HttpResponseMessage response) + { + // Login + response.Headers.Add("Set-Cookie", COOKIENAME + "=" + serverService.Config.AdminPassword + "; path=/"); + } + + public void Logout(HttpResponseMessage response) + { + // Logout + response.Headers.Add("Set-Cookie", COOKIENAME + "=; path=/"); + } + + public bool CheckAuthorised(HttpRequestMessage request) + { + if (string.IsNullOrEmpty(Engine.Server.Config.AdminPassword)) + return true; + + try + { + var cookie = request.Headers.GetCookies(COOKIENAME).FirstOrDefault(); + if (cookie != null) + { + return cookie[COOKIENAME].Value == serverService.Config.AdminPassword; + } + } + catch { } + + return false; + } + } +} diff --git a/src/Jackett/Services/ServerService.cs b/src/Jackett/Services/ServerService.cs index 22f7c6565..004d99e4e 100644 --- a/src/Jackett/Services/ServerService.cs +++ b/src/Jackett/Services/ServerService.cs @@ -26,6 +26,7 @@ namespace Jackett.Services void Stop(); void ReserveUrls(bool doInstall = true); ServerConfig Config { get; } + void SaveConfig(); } public class ServerService : IServerService @@ -94,6 +95,11 @@ namespace Jackett.Services } } + public void SaveConfig() + { + configService.SaveConfig(config); + } + public void Start() { CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); diff --git a/src/Jackett/Startup.cs b/src/Jackett/Startup.cs index 917f86d7b..56dbb8f59 100644 --- a/src/Jackett/Startup.cs +++ b/src/Jackett/Startup.cs @@ -15,6 +15,8 @@ using Autofac; using Jackett.Services; using System.Web.Http.Tracing; using Jackett.Utils; +using Microsoft.Owin.Security.Cookies; +using Microsoft.AspNet.Identity; [assembly: OwinStartup(typeof(Startup))] namespace Jackett @@ -25,6 +27,9 @@ namespace Jackett { // Configure Web API for self-host. var config = new HttpConfiguration(); + + appBuilder.Use(); + // Setup tracing if enabled if (Engine.TracingEnabled) { @@ -63,11 +68,18 @@ namespace Jackett defaults: new { controller = "Download", action = "Download" } ); + appBuilder.UseCookieAuthentication(new CookieAuthenticationOptions + { + AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, + LoginPath = new PathString("/Admin/Login") + }); + appBuilder.UseFileServer(new FileServerOptions { RequestPath = new PathString(string.Empty), FileSystem = new PhysicalFileSystem(Engine.ConfigService.GetContentFolder()), - EnableDirectoryBrowsing = true, + EnableDirectoryBrowsing = false, + }); appBuilder.UseWebApi(config); diff --git a/src/Jackett/Utils/JackettAuthorizedAttribute.cs b/src/Jackett/Utils/JackettAuthorizedAttribute.cs new file mode 100644 index 000000000..3a0e77a78 --- /dev/null +++ b/src/Jackett/Utils/JackettAuthorizedAttribute.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using System.Web.Http; +using System.Web.Http.Controllers; +using System.Web.Http.Filters; + +namespace Jackett.Utils +{ + public class JackettAuthorizedAttribute : AuthorizationFilterAttribute + { + public override void OnAuthorization(HttpActionContext actionContext) + { + // Skip authorisation on blank passwords + if (string.IsNullOrEmpty(Engine.Server.Config.AdminPassword)) + { + return; + } + + if (!Engine.SecurityService.CheckAuthorised(actionContext.Request)) + { + if(actionContext.ControllerContext.ControllerDescriptor.ControllerType.GetCustomAttributes(true).Where(a => a.GetType() == typeof(AllowAnonymousAttribute)).Any()) + { + return; + } + + if (actionContext.ControllerContext.ControllerDescriptor.ControllerType.GetMethod(actionContext.ActionDescriptor.ActionName).GetCustomAttributes(true).Where(a => a.GetType() == typeof(AllowAnonymousAttribute)).Any()) + { + return; + } + + + actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode + .Unauthorized); + } + } + } +} diff --git a/src/Jackett/Utils/WebAPIRequestLogger.cs b/src/Jackett/Utils/WebAPIRequestLogger.cs index 19c2cd124..9cda729c6 100644 --- a/src/Jackett/Utils/WebAPIRequestLogger.cs +++ b/src/Jackett/Utils/WebAPIRequestLogger.cs @@ -24,11 +24,13 @@ namespace Jackett.Utils return await base.SendAsync(request, cancellationToken) .ContinueWith(task => { - //once response is ready, log it - var responseBody = task.Result.Content.ReadAsStringAsync().Result; - Trace.WriteLine(responseBody); - Engine.Logger.Debug("Response: " + responseBody); - + if (null != task.Result.Content) + { + //once response is ready, log it + var responseBody = task.Result.Content.ReadAsStringAsync().Result; + Trace.WriteLine(responseBody); + Engine.Logger.Debug("Response: " + responseBody); + } return task.Result; }); } diff --git a/src/Jackett/Utils/WebApiRootRedirectMiddleware.cs b/src/Jackett/Utils/WebApiRootRedirectMiddleware.cs new file mode 100644 index 000000000..68959f76d --- /dev/null +++ b/src/Jackett/Utils/WebApiRootRedirectMiddleware.cs @@ -0,0 +1,32 @@ +using Microsoft.Owin; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Jackett.Utils +{ + public class WebApiRootRedirectMiddleware : OwinMiddleware + { + public WebApiRootRedirectMiddleware(OwinMiddleware next) + : base(next) + { + } + + public async override Task Invoke(IOwinContext context) + { + var url = context.Request.Uri; + if (string.IsNullOrWhiteSpace(url.AbsolutePath) || url.AbsolutePath == "/") + { + // 301 is the status code of permanent redirect + context.Response.StatusCode = 301; + context.Response.Headers.Set("Location", "/Admin/Dashboard"); + } + else + { + await Next.Invoke(context); + } + } + } +} \ No newline at end of file diff --git a/src/Jackett/packages.config b/src/Jackett/packages.config index 6447c9edc..805887fbf 100644 --- a/src/Jackett/packages.config +++ b/src/Jackett/packages.config @@ -6,6 +6,7 @@ + @@ -14,7 +15,10 @@ + + +