diff --git a/Ombi/Ombi.DependencyInjection/IocExtensions.cs b/Ombi/Ombi.DependencyInjection/IocExtensions.cs index 189c58b3b..639d26f0e 100644 --- a/Ombi/Ombi.DependencyInjection/IocExtensions.cs +++ b/Ombi/Ombi.DependencyInjection/IocExtensions.cs @@ -1,5 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Ombi.Core; using Ombi.Core.Engine; @@ -53,5 +56,16 @@ namespace Ombi.DependencyInjection services.AddTransient(); return services; } + + public static IServiceCollection RegisterIdentity(this IServiceCollection services) + { + services.AddAuthorization(auth => + { + auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser().Build()); + }); + return services; + } } } diff --git a/Ombi/Ombi.DependencyInjection/Ombi.DependencyInjection.csproj b/Ombi/Ombi.DependencyInjection/Ombi.DependencyInjection.csproj index 0be698b01..a98f5c6aa 100644 --- a/Ombi/Ombi.DependencyInjection/Ombi.DependencyInjection.csproj +++ b/Ombi/Ombi.DependencyInjection/Ombi.DependencyInjection.csproj @@ -4,11 +4,20 @@ netstandard1.4 + + + + + + + + ..\..\..\..\..\.nuget\packages\microsoft.aspnetcore.authorization\1.1.1\lib\netstandard1.3\Microsoft.AspNetCore.Authorization.dll + ..\..\..\..\..\.nuget\packages\microsoft.extensions.dependencyinjection.abstractions\1.1.0\lib\netstandard1.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll diff --git a/Ombi/Ombi.Store/Ombi.Store.csproj b/Ombi/Ombi.Store/Ombi.Store.csproj index 7d9da619e..d8eb4db16 100644 --- a/Ombi/Ombi.Store/Ombi.Store.csproj +++ b/Ombi/Ombi.Store/Ombi.Store.csproj @@ -6,6 +6,8 @@ + + diff --git a/Ombi/Ombi/.gitignore b/Ombi/Ombi/.gitignore index 577f921f9..ef724f488 100644 --- a/Ombi/Ombi/.gitignore +++ b/Ombi/Ombi/.gitignore @@ -17,5 +17,5 @@ /libpeerconnection.log npm-debug.log testem.log -/typings +#/typings /systemjs.config.js* diff --git a/Ombi/Ombi/Auth/CustomJwtDataFormat.cs b/Ombi/Ombi/Auth/CustomJwtDataFormat.cs new file mode 100644 index 000000000..de8eccaae --- /dev/null +++ b/Ombi/Ombi/Auth/CustomJwtDataFormat.cs @@ -0,0 +1,72 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Authentication; +using Microsoft.IdentityModel.Tokens; + +namespace Ombi.Auth +{ + public class CustomJwtDataFormat : ISecureDataFormat + { + private readonly string algorithm; + private readonly TokenValidationParameters validationParameters; + + public CustomJwtDataFormat(string algorithm, TokenValidationParameters validationParameters) + { + this.algorithm = algorithm; + this.validationParameters = validationParameters; + } + + public AuthenticationTicket Unprotect(string protectedText) + => Unprotect(protectedText, null); + + public AuthenticationTicket Unprotect(string protectedText, string purpose) + { + var handler = new JwtSecurityTokenHandler(); + ClaimsPrincipal principal = null; + SecurityToken validToken = null; + + try + { + principal = handler.ValidateToken(protectedText, this.validationParameters, out validToken); + + var validJwt = validToken as JwtSecurityToken; + + if (validJwt == null) + { + throw new ArgumentException("Invalid JWT"); + } + + if (!validJwt.Header.Alg.Equals(algorithm, StringComparison.Ordinal)) + { + throw new ArgumentException($"Algorithm must be '{algorithm}'"); + } + + // Additional custom validation of JWT claims here (if any) + } + catch (SecurityTokenValidationException) + { + return null; + } + catch (ArgumentException) + { + return null; + } + + // Validation passed. Return a valid AuthenticationTicket: + return new AuthenticationTicket(principal, new AuthenticationProperties(), "Cookie"); + } + + // This ISecureDataFormat implementation is decode-only + public string Protect(AuthenticationTicket data) + { + throw new NotImplementedException(); + } + + public string Protect(AuthenticationTicket data, string purpose) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/Ombi/Ombi/Auth/TokenProviderMiddleware.cs b/Ombi/Ombi/Auth/TokenProviderMiddleware.cs new file mode 100644 index 000000000..069cc7b56 --- /dev/null +++ b/Ombi/Ombi/Auth/TokenProviderMiddleware.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Ombi.Models; + +namespace Ombi.Auth +{ + public class TokenProviderMiddleware + { + private readonly RequestDelegate _next; + private readonly TokenProviderOptions _options; + private readonly JsonSerializerSettings _serializerSettings; + + public TokenProviderMiddleware( + RequestDelegate next, + IOptions options) + { + _next = next; + + _options = options.Value; + ThrowIfInvalidOptions(_options); + + _serializerSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented + }; + } + + public Task Invoke(HttpContext context) + { + // If the request path doesn't match, skip + if (!context.Request.Path.Equals(_options.Path, StringComparison.Ordinal)) + { + return _next(context); + } + + // Request must be POST with Content-Type: application/json + if (!context.Request.Method.Equals("POST") + ) + { + context.Response.StatusCode = 400; + return context.Response.WriteAsync("Bad request."); + } + + + return GenerateToken(context); + } + + private async Task GenerateToken(HttpContext context) + { + var request = context.Request; + UserAuthModel userInfo; // TODO use a stong type + + using (var bodyReader = new StreamReader(request.Body)) + { + string body = await bodyReader.ReadToEndAsync(); + userInfo = JsonConvert.DeserializeObject(body); + } + + var identity = await _options.IdentityResolver(userInfo.Username, userInfo.Password); + if (identity == null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid username or password."); + return; + } + + var now = DateTime.UtcNow; + + // Specifically add the jti (nonce), iat (issued timestamp), and sub (subject/user) claims. + // You can add other claims here, if you want: + var jwtClaims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, userInfo.Username), + new Claim(JwtRegisteredClaimNames.Jti, await _options.NonceGenerator()), + new Claim(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUniversalTime().ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) + }; + + identity.Claims.ToList().AddRange(jwtClaims); + + // Create the JWT and write it to a string + var jwt = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: identity.Claims, + notBefore: now, + expires: now.Add(_options.Expiration), + signingCredentials: _options.SigningCredentials); + var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt); + + var response = new + { + access_token = encodedJwt, + expires_in = (int)_options.Expiration.TotalSeconds + }; + + // Serialize and return the response + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonConvert.SerializeObject(response, _serializerSettings)); + } + + private static void ThrowIfInvalidOptions(TokenProviderOptions options) + { + if (string.IsNullOrEmpty(options.Path)) + { + throw new ArgumentNullException(nameof(TokenProviderOptions.Path)); + } + + if (string.IsNullOrEmpty(options.Issuer)) + { + throw new ArgumentNullException(nameof(TokenProviderOptions.Issuer)); + } + + if (string.IsNullOrEmpty(options.Audience)) + { + throw new ArgumentNullException(nameof(TokenProviderOptions.Audience)); + } + + if (options.Expiration == TimeSpan.Zero) + { + throw new ArgumentException("Must be a non-zero TimeSpan.", nameof(TokenProviderOptions.Expiration)); + } + + if (options.IdentityResolver == null) + { + throw new ArgumentNullException(nameof(TokenProviderOptions.IdentityResolver)); + } + + if (options.SigningCredentials == null) + { + throw new ArgumentNullException(nameof(TokenProviderOptions.SigningCredentials)); + } + + if (options.NonceGenerator == null) + { + throw new ArgumentNullException(nameof(TokenProviderOptions.NonceGenerator)); + } + } + + } +} \ No newline at end of file diff --git a/Ombi/Ombi/Auth/TokenProviderOptions.cs b/Ombi/Ombi/Auth/TokenProviderOptions.cs new file mode 100644 index 000000000..3cdb356c1 --- /dev/null +++ b/Ombi/Ombi/Auth/TokenProviderOptions.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; + +namespace Ombi.Auth +{ + public class TokenProviderOptions + { + /// + /// The relative request path to listen on. + /// + /// The default path is /token. + public string Path { get; set; } = "/token/"; + + /// + /// The Issuer (iss) claim for generated tokens. + /// + public string Issuer { get; set; } + + /// + /// The Audience (aud) claim for the generated tokens. + /// + public string Audience { get; set; } + + /// + /// The expiration time for the generated tokens. + /// + /// The default is five minutes (300 seconds). + public TimeSpan Expiration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// The signing key to use when generating tokens. + /// + public SigningCredentials SigningCredentials { get; set; } + + /// + /// Resolves a user identity given a username and password. + /// + public Func> IdentityResolver { get; set; } + + /// + /// Generates a random value (nonce) for each generated token. + /// + /// The default nonce is a random GUID. + public Func> NonceGenerator { get; set; } + = () => Task.FromResult(Guid.NewGuid().ToString()); + } +} diff --git a/Ombi/Ombi/Controllers/HomeController.cs b/Ombi/Ombi/Controllers/HomeController.cs index c03c9f475..07a23e7f5 100644 --- a/Ombi/Ombi/Controllers/HomeController.cs +++ b/Ombi/Ombi/Controllers/HomeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Ombi.Controllers { @@ -12,24 +8,5 @@ namespace Ombi.Controllers { return View(); } - - public IActionResult About() - { - ViewData["Message"] = "Your application description page."; - - return View(); - } - - public IActionResult Contact() - { - ViewData["Message"] = "Your contact page."; - - return View(); - } - - public IActionResult Error() - { - return View(); - } } } diff --git a/Ombi/Ombi/Controllers/RequestController.cs b/Ombi/Ombi/Controllers/RequestController.cs index d90268845..71e436122 100644 --- a/Ombi/Ombi/Controllers/RequestController.cs +++ b/Ombi/Ombi/Controllers/RequestController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Ombi.Core.Engine; using Ombi.Core.Models.Requests; @@ -7,6 +8,7 @@ using Ombi.Core.Models.Search; namespace Ombi.Controllers { + [Authorize] public class RequestController : BaseV1ApiController { public RequestController(IRequestEngine engine) diff --git a/Ombi/Ombi/Models/RequestResult.cs b/Ombi/Ombi/Models/RequestResult.cs new file mode 100644 index 000000000..51c01d0d5 --- /dev/null +++ b/Ombi/Ombi/Models/RequestResult.cs @@ -0,0 +1,18 @@ +using System; + +namespace Ombi.Models +{ + public class RequestResult + { + public RequestState State { get; set; } + public string Msg { get; set; } + public Object Data { get; set; } + } + + public enum RequestState + { + Failed = -1, + NotAuth = 0, + Success = 1 + } +} \ No newline at end of file diff --git a/Ombi/Ombi/Models/UserAuthModel.cs b/Ombi/Ombi/Models/UserAuthModel.cs new file mode 100644 index 000000000..4e16af0b8 --- /dev/null +++ b/Ombi/Ombi/Models/UserAuthModel.cs @@ -0,0 +1,8 @@ +namespace Ombi.Models +{ + public class UserAuthModel + { + public string Username { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/Ombi/Ombi/Ombi.csproj b/Ombi/Ombi/Ombi.csproj index 6c2e094de..efeae93f4 100644 --- a/Ombi/Ombi/Ombi.csproj +++ b/Ombi/Ombi/Ombi.csproj @@ -3,24 +3,48 @@ netcoreapp1.1 win10-x64;osx.10.12-x64;ubuntu.16.10-x64;debian.8-x64; + portable-net45+win8 + + auth - Copy.module.js + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + @@ -30,4 +54,13 @@ + + + + ..\..\..\..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.1\System.Configuration.dll + + + ..\..\..\..\..\.nuget\packages\system.security.cryptography.csp\4.3.0\ref\netstandard1.3\System.Security.Cryptography.Csp.dll + + diff --git a/Ombi/Ombi/Program.cs b/Ombi/Ombi/Program.cs index 1949a296a..d4867e9cb 100644 --- a/Ombi/Ombi/Program.cs +++ b/Ombi/Ombi/Program.cs @@ -11,6 +11,7 @@ namespace Ombi { public static void Main(string[] args) { + Console.Title = "Ombi"; var host = new WebHostBuilder() .UseKestrel() .UseContentRoot(Directory.GetCurrentDirectory()) diff --git a/Ombi/Ombi/Startup.Auth.cs b/Ombi/Ombi/Startup.Auth.cs new file mode 100644 index 000000000..00ad36eec --- /dev/null +++ b/Ombi/Ombi/Startup.Auth.cs @@ -0,0 +1,97 @@ +using System; +using System.Security.Claims; +using System.Security.Principal; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Ombi.Auth; + +namespace Ombi +{ + public partial class Startup + { + public SymmetricSecurityKey signingKey; + private void ConfigureAuth(IApplicationBuilder app) + { + + var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Configuration.GetSection("TokenAuthentication:SecretKey").Value)); + + var tokenProviderOptions = new TokenProviderOptions + { + Path = Configuration.GetSection("TokenAuthentication:TokenPath").Value, + Audience = Configuration.GetSection("TokenAuthentication:Audience").Value, + Issuer = Configuration.GetSection("TokenAuthentication:Issuer").Value, + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256), + IdentityResolver = GetIdentity + }; + + var tokenValidationParameters = new TokenValidationParameters + { + // The signing key must match! + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + // Validate the JWT Issuer (iss) claim + ValidateIssuer = true, + ValidIssuer = Configuration.GetSection("TokenAuthentication:Issuer").Value, + // Validate the JWT Audience (aud) claim + ValidateAudience = true, + ValidAudience = Configuration.GetSection("TokenAuthentication:Audience").Value, + // Validate the token expiry + ValidateLifetime = true, + // If you want to allow a certain amount of clock drift, set that here: + ClockSkew = TimeSpan.Zero + }; + + app.UseJwtBearerAuthentication(new JwtBearerOptions + { + AutomaticAuthenticate = true, + AutomaticChallenge = true, + TokenValidationParameters = tokenValidationParameters + }); + + app.UseCookieAuthentication(new CookieAuthenticationOptions + { + AutomaticAuthenticate = true, + AutomaticChallenge = true, + AuthenticationScheme = "Cookie", + CookieName = Configuration.GetSection("TokenAuthentication:CookieName").Value, + TicketDataFormat = new CustomJwtDataFormat( + SecurityAlgorithms.HmacSha256, + tokenValidationParameters) + }); + + app.UseMiddleware(Options.Create(tokenProviderOptions)); + } + + + private Task GetIdentity(string username, string password) + { + // DEMO CODE, DON NOT USE IN PRODUCTION!!! + if (username == "TEST" && password == "TEST123") + { + var claim = new ClaimsIdentity(new GenericIdentity(username, "Token"), + new[] + { + //new Claim(ClaimTypes.Role, "Admin"), + new Claim(ClaimTypes.Name, "Test"), + + }); + + claim.AddClaim(new Claim(ClaimsIdentity.DefaultRoleClaimType, "Admin", ClaimValueTypes.String)); + return Task.FromResult(claim); + } + if (username == "TEST2" && password == "TEST123") + { + return Task.FromResult(new ClaimsIdentity(new GenericIdentity(username, "Token"), new Claim[] { + new Claim(ClaimTypes.Role, "User"), + new Claim(ClaimTypes.Name, "Test2"), })); + } + + // Account doesn't exists + return Task.FromResult(null); + } + + } +} \ No newline at end of file diff --git a/Ombi/Ombi/Startup.cs b/Ombi/Ombi/Startup.cs index f418fcc0e..24ef16b31 100644 --- a/Ombi/Ombi/Startup.cs +++ b/Ombi/Ombi/Startup.cs @@ -1,5 +1,10 @@ -using System.IO; +using System; +using System.IO; +using System.Linq; +using System.Text; +using IdentityServer4.EntityFramework.DbContexts; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; @@ -8,12 +13,16 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using Ombi.Auth; using Ombi.DependencyInjection; +using Ombi.Models; using Ombi.Store.Context; namespace Ombi { - public class Startup + public partial class Startup { public Startup(IHostingEnvironment env) { @@ -51,6 +60,10 @@ namespace Ombi app.UseExceptionHandler("/Home/Error"); } + + + ConfigureAuth(app); + var provider = new FileExtensionContentTypeProvider(); provider.Mappings[".map"] = "application/octet-stream"; @@ -70,5 +83,8 @@ namespace Ombi defaults: new { controller = "Home", action = "Index" }); }); } + + + } } diff --git a/Ombi/Ombi/appsettings.json b/Ombi/Ombi/appsettings.json index 5fff67bac..b0f80f89d 100644 --- a/Ombi/Ombi/appsettings.json +++ b/Ombi/Ombi/appsettings.json @@ -4,5 +4,12 @@ "LogLevel": { "Default": "Warning" } + }, + "TokenAuthentication": { + "SecretKey": "secretkey_secretkey123!", + "Issuer": "DemoIssuer", + "Audience": "DemoAudience", + "TokenPath": "/api/token/", + "CookieName": "access_token" } } diff --git a/Ombi/Ombi/gulpfile.js b/Ombi/Ombi/gulpfile.js index c4697e4c7..fead077d4 100644 --- a/Ombi/Ombi/gulpfile.js +++ b/Ombi/Ombi/gulpfile.js @@ -57,6 +57,7 @@ var paths = { './bower_components/PACE/pace.js', './node_modules/bootstrap/dist/js/bootstrap.js', './node_modules/tether/dist/js/tether.js', + './node_modules/angular2-jwt/angular2-jwt.js', './systemjs.config.js', ], dest: './lib/' diff --git a/Ombi/Ombi/package.json b/Ombi/Ombi/package.json index f74d68e3e..643b975b2 100644 --- a/Ombi/Ombi/package.json +++ b/Ombi/Ombi/package.json @@ -18,6 +18,7 @@ "@types/systemjs": "^0.20.2", "angular2-infinite-scroll": "^0.3.4", "angular2-moment": "^1.3.3", + "angular2-jwt": "0.2.0", "bootstrap": "3.3.6", "core-js": "^2.4.1", "del": "^2.2.2", diff --git a/Ombi/Ombi/typings/globals/globals.d.ts b/Ombi/Ombi/typings/globals/globals.d.ts new file mode 100644 index 000000000..b9c873dfc --- /dev/null +++ b/Ombi/Ombi/typings/globals/globals.d.ts @@ -0,0 +1,5 @@ +// Globals + +declare var module: any; +declare var require: any; +declare var localStorage: any; \ No newline at end of file diff --git a/Ombi/Ombi/typings/index.d.ts b/Ombi/Ombi/typings/index.d.ts new file mode 100644 index 000000000..d3394b68b --- /dev/null +++ b/Ombi/Ombi/typings/index.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/app.component.ts b/Ombi/Ombi/wwwroot/app/app.component.ts index 36b29087e..de5f83076 100644 --- a/Ombi/Ombi/wwwroot/app/app.component.ts +++ b/Ombi/Ombi/wwwroot/app/app.component.ts @@ -1,5 +1,6 @@ import { Component } from '@angular/core'; import { NotificationService } from './services/notification.service'; +import { AuthService } from './auth/auth.service'; @Component({ selector: 'ombi', @@ -8,5 +9,5 @@ import { NotificationService } from './services/notification.service'; }) export class AppComponent { - constructor(public notificationService: NotificationService) { }; + constructor(public notificationService: NotificationService, public authService : AuthService) { }; } \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/app.module.ts b/Ombi/Ombi/wwwroot/app/app.module.ts index 8c7d4584b..1e9835bda 100644 --- a/Ombi/Ombi/wwwroot/app/app.module.ts +++ b/Ombi/Ombi/wwwroot/app/app.module.ts @@ -12,12 +12,16 @@ import { InfiniteScrollModule } from 'angular2-infinite-scroll/angular2-infinite import { SearchComponent } from './search/search.component'; import { RequestComponent } from './requests/request.component'; +import { LoginComponent } from './login/login.component'; import { PageNotFoundComponent } from './errors/not-found.component'; // Services import { SearchService } from './services/search.service'; import { RequestService } from './services/request.service'; import { NotificationService } from './services/notification.service'; +import { AuthService } from './auth/auth.service'; +import { AuthGuard } from './auth/auth.guard'; +import { AuthModule } from './auth/auth.module'; // Modules import { SettingsModule } from './settings/settings.module'; @@ -28,8 +32,10 @@ import { DataTableModule, SharedModule } from 'primeng/primeng'; const routes: Routes = [ { path: '*', component: PageNotFoundComponent }, - { path: 'search', component: SearchComponent }, - { path: 'requests', component: RequestComponent }, + { path: '', redirectTo: '/search', pathMatch: 'full' }, + { path: 'search', component: SearchComponent, canActivate: [AuthGuard] }, + { path: 'requests', component: RequestComponent, canActivate: [AuthGuard] }, + { path: 'login', component: LoginComponent }, ]; @NgModule({ @@ -44,18 +50,22 @@ const routes: Routes = [ SettingsModule, DataTableModule, SharedModule, - InfiniteScrollModule + InfiniteScrollModule, + AuthModule ], declarations: [ AppComponent, PageNotFoundComponent, SearchComponent, - RequestComponent + RequestComponent, + LoginComponent ], providers: [ SearchService, RequestService, - NotificationService + NotificationService, + AuthService, + AuthGuard, ], bootstrap: [AppComponent] }) diff --git a/Ombi/Ombi/wwwroot/app/auth/IUserLogin.ts b/Ombi/Ombi/wwwroot/app/auth/IUserLogin.ts new file mode 100644 index 000000000..1d45bda19 --- /dev/null +++ b/Ombi/Ombi/wwwroot/app/auth/IUserLogin.ts @@ -0,0 +1,4 @@ +export interface IUserLogin { + username: string, + password:string +} \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/auth/auth.guard.ts b/Ombi/Ombi/wwwroot/app/auth/auth.guard.ts new file mode 100644 index 000000000..29d59fd6c --- /dev/null +++ b/Ombi/Ombi/wwwroot/app/auth/auth.guard.ts @@ -0,0 +1,20 @@ + +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { CanActivate } from '@angular/router'; +import { AuthService } from './auth.service'; + +@Injectable() +export class AuthGuard implements CanActivate { + + constructor(private auth: AuthService, private router: Router) { } + + canActivate() { + if (this.auth.loggedIn()) { + return true; + } else { + this.router.navigate(['login']); + return false; + } + } +} \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/auth/auth.module.ts b/Ombi/Ombi/wwwroot/app/auth/auth.module.ts new file mode 100644 index 000000000..db5e84ffd --- /dev/null +++ b/Ombi/Ombi/wwwroot/app/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { Http, RequestOptions } from '@angular/http'; +import { AuthHttp, AuthConfig } from 'angular2-jwt'; + +export function authHttpServiceFactory(http: Http, options: RequestOptions) { + return new AuthHttp(new AuthConfig(), http, options); +} + +@NgModule({ + providers: [ + { + provide: AuthHttp, + useFactory: authHttpServiceFactory, + deps: [Http, RequestOptions] + } + ] +}) +export class AuthModule {} \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/auth/auth.service.ts b/Ombi/Ombi/wwwroot/app/auth/auth.service.ts new file mode 100644 index 000000000..8bb2f69b3 --- /dev/null +++ b/Ombi/Ombi/wwwroot/app/auth/auth.service.ts @@ -0,0 +1,33 @@ + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Rx'; + +import { ServiceHelpers } from '../services/service.helpers'; + +import { IUserLogin } from './IUserLogin'; + +import { tokenNotExpired } from 'angular2-jwt'; + +import { Http } from '@angular/http'; + +@Injectable() +export class AuthService extends ServiceHelpers { + constructor(http: Http) { + super(http, '/api/token'); + } + + login(login:IUserLogin) : Observable { + return this.http.post(`${this.url}/`, JSON.stringify(login), { headers: this.headers }) + .map(this.extractData); + + } + + loggedIn() { + return tokenNotExpired(); + } + + logout() { + localStorage.removeItem('id_token'); + } +} + diff --git a/Ombi/Ombi/wwwroot/app/login/login.component.html b/Ombi/Ombi/wwwroot/app/login/login.component.html new file mode 100644 index 000000000..7a01eb2a0 --- /dev/null +++ b/Ombi/Ombi/wwwroot/app/login/login.component.html @@ -0,0 +1,32 @@ +
+

Login

+
+

+ @UI.UserLogin_Paragraph +

+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ + + +
+
\ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/login/login.component.ts b/Ombi/Ombi/wwwroot/app/login/login.component.ts new file mode 100644 index 000000000..696a8cea1 --- /dev/null +++ b/Ombi/Ombi/wwwroot/app/login/login.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + + +import { AuthService } from '../auth/auth.service'; +import { NotificationService } from '../services/notification.service'; + +@Component({ + selector: 'ombi', + moduleId: module.id, + templateUrl: './login.component.html', +}) +export class LoginComponent { + constructor(private authService: AuthService, private router: Router, private notify: NotificationService) { } + + + username: string; + password: string; + + + login(): void { + this.authService.login({ password: this.password, username: this.username }) + .subscribe(x => { + localStorage.setItem("id_token", x.access_token); + if (this.authService.loggedIn()) { + this.router.navigate(['search']); + } else { + this.notify.error("Could not log in", "Incorrect username or password"); + } + }, err => this.notify.error("Could not log in", "Incorrect username or password")); + + + } +} \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/services/request.service.ts b/Ombi/Ombi/wwwroot/app/services/request.service.ts index adcdd8667..76eb3232f 100644 --- a/Ombi/Ombi/wwwroot/app/services/request.service.ts +++ b/Ombi/Ombi/wwwroot/app/services/request.service.ts @@ -1,15 +1,15 @@ import { Injectable } from '@angular/core'; -import { Http } from '@angular/http'; +import { AuthHttp } from 'angular2-jwt'; import { Observable } from 'rxjs/Rx'; -import { ServiceHelpers } from './service.helpers'; +import { ServiceAuthHelpers } from './service.helpers'; import { IRequestEngineResult } from '../interfaces/IRequestEngineResult'; import { ISearchMovieResult } from '../interfaces/ISearchMovieResult'; import { IRequestModel } from '../interfaces/IRequestModel'; @Injectable() -export class RequestService extends ServiceHelpers { - constructor(http: Http) { +export class RequestService extends ServiceAuthHelpers { + constructor(http: AuthHttp) { super(http, '/api/v1/Request/'); } diff --git a/Ombi/Ombi/wwwroot/app/services/search.service.ts b/Ombi/Ombi/wwwroot/app/services/search.service.ts index f0abe17e0..8363a717f 100644 --- a/Ombi/Ombi/wwwroot/app/services/search.service.ts +++ b/Ombi/Ombi/wwwroot/app/services/search.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@angular/core'; -import { Http } from '@angular/http'; +import { AuthHttp } from 'angular2-jwt'; import { Observable } from 'rxjs/Rx'; -import { ServiceHelpers } from './service.helpers'; +import { ServiceAuthHelpers } from './service.helpers'; import { ISearchMovieResult } from '../interfaces/ISearchMovieResult'; @Injectable() -export class SearchService extends ServiceHelpers { - constructor(http: Http) { +export class SearchService extends ServiceAuthHelpers { + constructor(http: AuthHttp) { super(http, "/api/v1/search"); } diff --git a/Ombi/Ombi/wwwroot/app/services/service.helpers.ts b/Ombi/Ombi/wwwroot/app/services/service.helpers.ts index f1d111c49..4b5ce8c42 100644 --- a/Ombi/Ombi/wwwroot/app/services/service.helpers.ts +++ b/Ombi/Ombi/wwwroot/app/services/service.helpers.ts @@ -1,6 +1,9 @@ import { Headers, Response, Http } from '@angular/http'; import { Observable } from 'rxjs/Observable'; + +import { AuthHttp } from 'angular2-jwt'; + export class ServiceHelpers { constructor(protected http: Http, protected url: string) { @@ -26,4 +29,31 @@ export class ServiceHelpers { } +} + +export class ServiceAuthHelpers { + + constructor(protected http: AuthHttp, protected url: string) { + this.headers = new Headers(); + this.headers.append('Content-Type', 'application/json; charset=utf-8'); + } + + protected headers: Headers; + + protected extractData(res: Response) { + let body = res.json(); + //console.log('extractData', body || {}); + return body || {}; + } + + protected handleError(error: any) { + // In a real world app, we might use a remote logging infrastructure + // We'd also dig deeper into the error to get a better message + let errMsg = (error.message) ? error.message : + error.status ? `${error.status} - ${error.statusText}` : 'Server error'; + console.error(errMsg); // log to console instead + return Observable.throw(errMsg); + } + + } \ No newline at end of file diff --git a/Ombi/Ombi/wwwroot/app/settings/settings.module.ts b/Ombi/Ombi/wwwroot/app/settings/settings.module.ts index 79ca414c9..255b0cb89 100644 --- a/Ombi/Ombi/wwwroot/app/settings/settings.module.ts +++ b/Ombi/Ombi/wwwroot/app/settings/settings.module.ts @@ -3,6 +3,10 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthModule } from '../auth/auth.module'; + import { OmbiComponent } from './ombi/ombi.component' import { SettingsMenuComponent } from './settingsmenu.component'; @@ -10,7 +14,7 @@ import { SettingsMenuComponent } from './settingsmenu.component'; import { MenuModule, InputSwitchModule, InputTextModule } from 'primeng/primeng'; const routes: Routes = [ - { path: 'Settings/Ombi', component: OmbiComponent } + { path: 'Settings/Ombi', component: OmbiComponent, canActivate: [AuthGuard] } ]; @NgModule({ @@ -21,7 +25,7 @@ const routes: Routes = [ MenuModule, InputSwitchModule, InputTextModule, - + AuthModule ], declarations: [ SettingsMenuComponent, @@ -31,6 +35,9 @@ const routes: Routes = [ RouterModule ], providers: [ + + AuthService, + AuthGuard, ], })