diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index 954ef5be9..16ba9e4f0 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -27,4 +27,10 @@ + + + PreserveNewest + + + diff --git a/src/Admin/AdminSettings.cs b/src/Admin/AdminSettings.cs new file mode 100644 index 000000000..031c059b3 --- /dev/null +++ b/src/Admin/AdminSettings.cs @@ -0,0 +1,7 @@ +namespace Bit.Admin +{ + public class AdminSettings + { + public virtual string Admins { get; set; } + } +} diff --git a/src/Admin/Controllers/HomeController.cs b/src/Admin/Controllers/HomeController.cs index afa28d86b..bada49495 100644 --- a/src/Admin/Controllers/HomeController.cs +++ b/src/Admin/Controllers/HomeController.cs @@ -1,13 +1,14 @@ using System; -using System.Collections.Generic; using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using Bit.Admin.Models; +using Microsoft.AspNetCore.Authorization; namespace Bit.Admin.Controllers { public class HomeController : Controller { + [Authorize] public IActionResult Index() { return View(); diff --git a/src/Admin/Controllers/LoginController.cs b/src/Admin/Controllers/LoginController.cs new file mode 100644 index 000000000..ee568c67b --- /dev/null +++ b/src/Admin/Controllers/LoginController.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Bit.Admin.Models; +using Bit.Core.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Admin.Controllers +{ + public class LoginController : Controller + { + private readonly PasswordlessSignInManager _signInManager; + + public LoginController( + PasswordlessSignInManager signInManager) + { + _signInManager = signInManager; + } + + public IActionResult Index() + { + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Index(LoginModel model) + { + if(ModelState.IsValid) + { + await _signInManager.PasswordlessSignInAsync(model.Email); + return RedirectToAction("Index", "Home"); + } + + return View(model); + } + + public async Task Confirm(string email, string token) + { + var result = await _signInManager.PasswordlessSignInAsync(email, token, false); + if(!result.Succeeded) + { + return View("Error"); + } + + return RedirectToAction("Index", "Home"); + } + } +} diff --git a/src/Admin/Models/LoginModel.cs b/src/Admin/Models/LoginModel.cs new file mode 100644 index 000000000..08f6cd8d4 --- /dev/null +++ b/src/Admin/Models/LoginModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Admin.Models +{ + public class LoginModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + } +} diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index e37e5f585..57b3cd538 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -1,9 +1,17 @@ using System; +using Bit.Core; +using Bit.Core.Identity; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Serilog.Events; +using Stripe; namespace Bit.Admin { @@ -18,22 +26,52 @@ namespace Bit.Admin public void ConfigureServices(IServiceCollection services) { - services.AddMvc(); + // Options + services.AddOptions(); + + // Settings + var globalSettings = services.AddGlobalSettingsServices(Configuration); + services.Configure(Configuration.GetSection("AdminSettings")); + + // Stripe Billing + StripeConfiguration.SetApiKey(globalSettings.StripeApiKey); + + // Repositories + services.AddSqlServerRepositories(globalSettings); + + // Context + services.AddScoped(); + + // Identity + services.AddPasswordlessIdentityServices(globalSettings); + + // Services + services.AddBaseServices(); + services.AddDefaultServices(globalSettings); + + // Mvc + services.AddMvc(config => + { + config.Filters.Add(new LoggingExceptionHandlerFilterAttribute()); + }); services.Configure(options => options.LowercaseUrls = true); } - public void Configure(IApplicationBuilder app, IHostingEnvironment env) + public void Configure( + IApplicationBuilder app, + IHostingEnvironment env, + IApplicationLifetime appLifetime, + GlobalSettings globalSettings, + ILoggerFactory loggerFactory) { + loggerFactory.AddSerilog(env, appLifetime, globalSettings, (e) => e.Level >= LogEventLevel.Error); + if(env.IsDevelopment()) { - app.UseBrowserLink(); app.UseDeveloperExceptionPage(); } - else - { - app.UseExceptionHandler("/home/error"); - } + app.UseAuthentication(); app.UseStaticFiles(); app.UseMvcWithDefaultRoute(); } diff --git a/src/Admin/Views/Login/Index.cshtml b/src/Admin/Views/Login/Index.cshtml new file mode 100644 index 000000000..930f5613f --- /dev/null +++ b/src/Admin/Views/Login/Index.cshtml @@ -0,0 +1,21 @@ +@model LoginModel +@{ + ViewData["Title"] = "Login"; +} + +
+
+

Please enter your email address below to log in.

+
+
+
+ + + + We'll email you a secure login link. +
+ +
+
+
diff --git a/src/Admin/appsettings.Development.json b/src/Admin/appsettings.Development.json deleted file mode 100644 index fa8ce71a9..000000000 --- a/src/Admin/appsettings.Development.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - } -} diff --git a/src/Admin/appsettings.Production.json b/src/Admin/appsettings.Production.json new file mode 100644 index 000000000..e0dbff6b0 --- /dev/null +++ b/src/Admin/appsettings.Production.json @@ -0,0 +1,13 @@ +{ + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.bitwarden.com", + "api": "https://api.bitwarden.com", + "identity": "https://identity.bitwarden.com", + "internalIdentity": "https://identity.bitwarden.com" + }, + "braintree": { + "production": true + } + } +} diff --git a/src/Admin/appsettings.json b/src/Admin/appsettings.json index 5fff67bac..7927f980f 100644 --- a/src/Admin/appsettings.json +++ b/src/Admin/appsettings.json @@ -1,8 +1,50 @@ { - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Warning" + "globalSettings": { + "selfHosted": false, + "siteName": "Bitwarden", + "projectName": "Admin", + "stripeApiKey": "SECRET", + "baseServiceUri": { + "vault": "http://localhost:4001", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "internalIdentity": "http://localhost:33656" + }, + "sqlServer": { + "connectionString": "SECRET" + }, + "mail": { + "sendGridApiKey": "SECRET", + "replyToEmail": "hello@bitwarden.com" + }, + "identityServer": { + "certificateThumbprint": "SECRET" + }, + "dataProtection": { + "certificateThumbprint": "SECRET" + }, + "storage": { + "connectionString": "SECRET" + }, + "events": { + "connectionString": "SECRET" + }, + "documentDb": { + "uri": "SECRET", + "key": "SECRET" + }, + "notificationHub": { + "connectionString": "SECRET", + "hubName": "SECRET" } + }, + "adminSettings": { + "admins": "" + }, + "braintree": { + "production": false, + "merchantId": "SECRET", + "publicKey": "SECRET", + "privateKey": "SECRET" } } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 142787bd4..a0fbc3f07 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -10,9 +10,7 @@ using Bit.Core.Utilities; using Serilog.Events; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection.Extensions; -using Bit.Billing.Utilities; using Bit.Core.Identity; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; namespace Bit.Billing @@ -45,28 +43,7 @@ namespace Bit.Billing services.AddScoped(); // Identity - services.AddTransient(); - services.AddIdentity() - .AddUserStore() - .AddRoleStore() - .AddDefaultTokenProviders(); - services.TryAddScoped, PasswordlessSignInManager>(); - - services.Configure(options => - { - options.TokenLifespan = TimeSpan.FromMinutes(15); - }); - services.ConfigureApplicationCookie(options => - { - options.LoginPath = "/login"; - options.LogoutPath = "/"; - options.AccessDeniedPath = "/login"; - options.Cookie.Name = "BitwardenBilling"; - options.Cookie.HttpOnly = true; - options.ExpireTimeSpan = TimeSpan.FromMinutes(60); - options.ReturnUrlParameter = "returnUrl"; - options.SlidingExpiration = true; - }); + services.AddPasswordlessIdentityServices(globalSettings); // Services services.AddBaseServices(); @@ -77,7 +54,7 @@ namespace Bit.Billing // Mvc services.AddMvc(config => { - config.Filters.Add(new ExceptionHandlerFilterAttribute()); + config.Filters.Add(new LoggingExceptionHandlerFilterAttribute()); }); services.Configure(options => options.LowercaseUrls = true); } diff --git a/src/Core/Identity/ReadOnlyDatabaseIdentityUserStore.cs b/src/Core/Identity/ReadOnlyDatabaseIdentityUserStore.cs new file mode 100644 index 000000000..3a795a6e1 --- /dev/null +++ b/src/Core/Identity/ReadOnlyDatabaseIdentityUserStore.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Bit.Core.Repositories; + +namespace Bit.Core.Identity +{ + public class ReadOnlyDatabaseIdentityUserStore : ReadOnlyIdentityUserStore + { + private readonly IUserRepository _userRepository; + + public ReadOnlyDatabaseIdentityUserStore(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public override async Task FindByEmailAsync(string normalizedEmail, + CancellationToken cancellationToken = default(CancellationToken)) + { + var user = await _userRepository.GetByEmailAsync(normalizedEmail); + return user?.ToIdentityUser(); + } + + public override async Task FindByIdAsync(string userId, + CancellationToken cancellationToken = default(CancellationToken)) + { + if(!Guid.TryParse(userId, out var userIdGuid)) + { + return null; + } + + var user = await _userRepository.GetByIdAsync(userIdGuid); + return user?.ToIdentityUser(); + } + } +} diff --git a/src/Core/Identity/ReadOnlyEnvIdentityUserStore.cs b/src/Core/Identity/ReadOnlyEnvIdentityUserStore.cs new file mode 100644 index 000000000..324764e76 --- /dev/null +++ b/src/Core/Identity/ReadOnlyEnvIdentityUserStore.cs @@ -0,0 +1,53 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; + +namespace Bit.Core.Identity +{ + public class ReadOnlyEnvIdentityUserStore : ReadOnlyIdentityUserStore + { + private readonly IConfiguration _configuration; + + public ReadOnlyEnvIdentityUserStore(IConfiguration configuration) + { + _configuration = configuration; + } + + public override Task FindByEmailAsync(string normalizedEmail, + CancellationToken cancellationToken = default(CancellationToken)) + { + var usersCsv = _configuration["adminSettings:admins"]; + if(string.IsNullOrWhiteSpace(usersCsv)) + { + return Task.FromResult(null); + } + + var users = usersCsv.ToLowerInvariant().Split(','); + var user = users.Where(a => a.Trim() == normalizedEmail).FirstOrDefault(); + if(user == null || !user.Contains("@")) + { + return Task.FromResult(null); + } + + user = user.Trim(); + return Task.FromResult(new IdentityUser + { + Id = user, + Email = user, + NormalizedEmail = user, + EmailConfirmed = true, + UserName = user, + NormalizedUserName = user, + SecurityStamp = user + }); + } + + public override Task FindByIdAsync(string userId, + CancellationToken cancellationToken = default(CancellationToken)) + { + return FindByEmailAsync(userId, cancellationToken); + } + } +} diff --git a/src/Core/Identity/ReadOnlyIdentityUserStore.cs b/src/Core/Identity/ReadOnlyIdentityUserStore.cs index 144fa2a65..14b4244d0 100644 --- a/src/Core/Identity/ReadOnlyIdentityUserStore.cs +++ b/src/Core/Identity/ReadOnlyIdentityUserStore.cs @@ -2,22 +2,14 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; -using Bit.Core.Repositories; namespace Bit.Core.Identity { - public class ReadOnlyIdentityUserStore : + public abstract class ReadOnlyIdentityUserStore : IUserStore, IUserEmailStore, IUserSecurityStampStore { - private readonly IUserRepository _userRepository; - - public ReadOnlyIdentityUserStore(IUserRepository userRepository) - { - _userRepository = userRepository; - } - public void Dispose() { } public Task CreateAsync(IdentityUser user, @@ -32,24 +24,11 @@ namespace Bit.Core.Identity throw new NotImplementedException(); } - public async Task FindByEmailAsync(string normalizedEmail, - CancellationToken cancellationToken = default(CancellationToken)) - { - var user = await _userRepository.GetByEmailAsync(normalizedEmail); - return user?.ToIdentityUser(); - } + public abstract Task FindByEmailAsync(string normalizedEmail, + CancellationToken cancellationToken = default(CancellationToken)); - public async Task FindByIdAsync(string userId, - CancellationToken cancellationToken = default(CancellationToken)) - { - if(!Guid.TryParse(userId, out var userIdGuid)) - { - return null; - } - - var user = await _userRepository.GetByIdAsync(userIdGuid); - return user?.ToIdentityUser(); - } + public abstract Task FindByIdAsync(string userId, + CancellationToken cancellationToken = default(CancellationToken)); public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/src/Billing/Utilities/ExceptionHandlerFilterAttribute.cs b/src/Core/Utilities/LoggingExceptionHandlerFilterAttribute.cs similarity index 64% rename from src/Billing/Utilities/ExceptionHandlerFilterAttribute.cs rename to src/Core/Utilities/LoggingExceptionHandlerFilterAttribute.cs index 3a7d0073a..04e2fc256 100644 --- a/src/Billing/Utilities/ExceptionHandlerFilterAttribute.cs +++ b/src/Core/Utilities/LoggingExceptionHandlerFilterAttribute.cs @@ -2,9 +2,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Bit.Billing.Utilities +namespace Bit.Core.Utilities { - public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute + public class LoggingExceptionHandlerFilterAttribute : ExceptionFilterAttribute { public override void OnException(ExceptionContext context) { @@ -15,7 +15,8 @@ namespace Bit.Billing.Utilities return; } - var logger = context.HttpContext.RequestServices.GetRequiredService>(); + var logger = context.HttpContext.RequestServices + .GetRequiredService>(); logger.LogError(0, exception, exception.Message); } } diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 4e2776034..35ae31826 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ using IdentityServer4.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -22,6 +21,7 @@ using System.IO; using SqlServerRepos = Bit.Core.Repositories.SqlServer; using System.Threading.Tasks; using TableStorageRepos = Bit.Core.Repositories.TableStorage; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bit.Core.Utilities { @@ -199,6 +199,38 @@ namespace Bit.Core.Utilities return identityBuilder; } + public static IdentityBuilder AddPasswordlessIdentityServices( + this IServiceCollection services, GlobalSettings globalSettings) where TUserStore : class + { + services.AddTransient(); + + services.Configure(options => + { + options.TokenLifespan = TimeSpan.FromMinutes(15); + }); + + var identityBuilder = services.AddIdentity() + .AddUserStore() + .AddRoleStore() + .AddDefaultTokenProviders(); + + services.TryAddScoped, PasswordlessSignInManager>(); + + services.ConfigureApplicationCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/"; + options.AccessDeniedPath = "/login?accessDenied=1"; + options.Cookie.Name = $"Bitwarden_{globalSettings.ProjectName}"; + options.Cookie.HttpOnly = true; + options.ExpireTimeSpan = TimeSpan.FromMinutes(60); + options.ReturnUrlParameter = "returnUrl"; + options.SlidingExpiration = true; + }); + + return identityBuilder; + } + public static IIdentityServerBuilder AddCustomIdentityServerServices( this IServiceCollection services, IHostingEnvironment env, GlobalSettings globalSettings) {