1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-26 12:55:17 +01:00

passwordless sign in for admin

This commit is contained in:
Kyle Spearrin 2018-03-21 14:26:49 -04:00
parent 1be7701da0
commit d35d8185ed
16 changed files with 333 additions and 77 deletions

View File

@ -27,4 +27,10 @@
<DotNetCliToolReference Include="Microsoft.Extensions.SecretManager.Tools" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings.json">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,7 @@
namespace Bit.Admin
{
public class AdminSettings
{
public virtual string Admins { get; set; }
}
}

View File

@ -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();

View File

@ -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<IdentityUser> _signInManager;
public LoginController(
PasswordlessSignInManager<IdentityUser> signInManager)
{
_signInManager = signInManager;
}
public IActionResult Index()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(LoginModel model)
{
if(ModelState.IsValid)
{
await _signInManager.PasswordlessSignInAsync(model.Email);
return RedirectToAction("Index", "Home");
}
return View(model);
}
public async Task<IActionResult> Confirm(string email, string token)
{
var result = await _signInManager.PasswordlessSignInAsync(email, token, false);
if(!result.Succeeded)
{
return View("Error");
}
return RedirectToAction("Index", "Home");
}
}
}

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Models
{
public class LoginModel
{
[Required]
[EmailAddress]
public string Email { get; set; }
}
}

View File

@ -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<AdminSettings>(Configuration.GetSection("AdminSettings"));
// Stripe Billing
StripeConfiguration.SetApiKey(globalSettings.StripeApiKey);
// Repositories
services.AddSqlServerRepositories(globalSettings);
// Context
services.AddScoped<CurrentContext>();
// Identity
services.AddPasswordlessIdentityServices<ReadOnlyEnvIdentityUserStore>(globalSettings);
// Services
services.AddBaseServices();
services.AddDefaultServices(globalSettings);
// Mvc
services.AddMvc(config =>
{
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
});
services.Configure<RouteOptions>(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();
}

View File

@ -0,0 +1,21 @@
@model LoginModel
@{
ViewData["Title"] = "Login";
}
<div class="row justify-content-md-center">
<div class="col-4">
<p>Please enter your email address below to log in.</p>
<form asp-action="" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Email" class="sr-only">Email Address</label>
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
required autofocus>
<span asp-validation-for="Email" class="invalid-feedback"></span>
<small class="form-text text-muted">We'll email you a secure login link.</small>
</div>
<button class="btn btn-primary btn-block" type="submit">Continue</button>
</form>
</div>
</div>

View File

@ -1,10 +0,0 @@
{
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}

View File

@ -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
}
}
}

View File

@ -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"
}
}

View File

@ -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<CurrentContext>();
// Identity
services.AddTransient<ILookupNormalizer, LowerInvariantLookupNormalizer>();
services.AddIdentity<IdentityUser, Core.Models.Table.Role>()
.AddUserStore<ReadOnlyIdentityUserStore>()
.AddRoleStore<RoleStore>()
.AddDefaultTokenProviders();
services.TryAddScoped<PasswordlessSignInManager<IdentityUser>, PasswordlessSignInManager<IdentityUser>>();
services.Configure<DataProtectionTokenProviderOptions>(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<ReadOnlyDatabaseIdentityUserStore>(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<RouteOptions>(options => options.LowercaseUrls = true);
}

View File

@ -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<IdentityUser> FindByEmailAsync(string normalizedEmail,
CancellationToken cancellationToken = default(CancellationToken))
{
var user = await _userRepository.GetByEmailAsync(normalizedEmail);
return user?.ToIdentityUser();
}
public override async Task<IdentityUser> 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();
}
}
}

View File

@ -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<IdentityUser> FindByEmailAsync(string normalizedEmail,
CancellationToken cancellationToken = default(CancellationToken))
{
var usersCsv = _configuration["adminSettings:admins"];
if(string.IsNullOrWhiteSpace(usersCsv))
{
return Task.FromResult<IdentityUser>(null);
}
var users = usersCsv.ToLowerInvariant().Split(',');
var user = users.Where(a => a.Trim() == normalizedEmail).FirstOrDefault();
if(user == null || !user.Contains("@"))
{
return Task.FromResult<IdentityUser>(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<IdentityUser> FindByIdAsync(string userId,
CancellationToken cancellationToken = default(CancellationToken))
{
return FindByEmailAsync(userId, cancellationToken);
}
}
}

View File

@ -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<IdentityUser>,
IUserEmailStore<IdentityUser>,
IUserSecurityStampStore<IdentityUser>
{
private readonly IUserRepository _userRepository;
public ReadOnlyIdentityUserStore(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public void Dispose() { }
public Task<IdentityResult> CreateAsync(IdentityUser user,
@ -32,24 +24,11 @@ namespace Bit.Core.Identity
throw new NotImplementedException();
}
public async Task<IdentityUser> FindByEmailAsync(string normalizedEmail,
CancellationToken cancellationToken = default(CancellationToken))
{
var user = await _userRepository.GetByEmailAsync(normalizedEmail);
return user?.ToIdentityUser();
}
public abstract Task<IdentityUser> FindByEmailAsync(string normalizedEmail,
CancellationToken cancellationToken = default(CancellationToken));
public async Task<IdentityUser> 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<IdentityUser> FindByIdAsync(string userId,
CancellationToken cancellationToken = default(CancellationToken));
public async Task<IdentityUser> FindByNameAsync(string normalizedUserName,
CancellationToken cancellationToken = default(CancellationToken))

View File

@ -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<ILogger<ExceptionHandlerFilterAttribute>>();
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<LoggingExceptionHandlerFilterAttribute>>();
logger.LogError(0, exception, exception.Message);
}
}

View File

@ -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<TUserStore>(
this IServiceCollection services, GlobalSettings globalSettings) where TUserStore : class
{
services.AddTransient<ILookupNormalizer, LowerInvariantLookupNormalizer>();
services.Configure<DataProtectionTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromMinutes(15);
});
var identityBuilder = services.AddIdentity<IdentityUser, Role>()
.AddUserStore<TUserStore>()
.AddRoleStore<RoleStore>()
.AddDefaultTokenProviders();
services.TryAddScoped<PasswordlessSignInManager<IdentityUser>, PasswordlessSignInManager<IdentityUser>>();
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)
{