1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-16 15:17:33 -05:00

sso integrations (#822)

* stub out hybrid sso

* support for PKCE authorization_code clients

* sso service urls

* sso client key

* abstract request validator

* support for verifying password

* custom AuthorizationCodeStore that does not remove codes

* cleanup

* comment

* created master password

* ResetMasterPassword

* rename Sso client to OidcIdentity

* update env builder

* bitwarden sso project in docker-compose

* sso path in nginx config
This commit is contained in:
Kyle Spearrin
2020-07-16 08:01:39 -04:00
committed by GitHub
parent 2742b414fd
commit 0d0c6c7167
29 changed files with 1093 additions and 435 deletions

View File

@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Bit.Core.Models.Table;
using Bit.Core.Repositories;
using Bit.Identity.Models;
using IdentityModel;
using IdentityServer4.Services;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Bit.Identity.Controllers
{
public class AccountController : Controller
{
private readonly IIdentityServerInteractionService _interaction;
private readonly IUserRepository _userRepository;
private readonly IClientStore _clientStore;
private readonly ILogger<AccountController> _logger;
public AccountController(
IIdentityServerInteractionService interaction,
IUserRepository userRepository,
IClientStore clientStore,
ILogger<AccountController> logger)
{
_interaction = interaction;
_userRepository = userRepository;
_clientStore = clientStore;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> Login(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context.Parameters.AllKeys.Contains("domain_hint") &&
!string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
{
return RedirectToAction(nameof(ExternalChallenge),
new { organizationIdentifier = context.Parameters["domain_hint"], returnUrl = returnUrl });
}
else
{
throw new Exception("No domain_hint provided.");
}
}
[HttpGet]
public IActionResult ExternalChallenge(string organizationIdentifier, string returnUrl)
{
if (string.IsNullOrWhiteSpace(organizationIdentifier))
{
throw new Exception("Invalid organization reference id.");
}
// TODO: Lookup sso config and create a domain hint
var domainHint = "oidc_okta";
// Temp hardcoded orgs
if (organizationIdentifier == "org_oidc_okta")
{
domainHint = "oidc_okta";
}
else if (organizationIdentifier == "org_oidc_onelogin")
{
domainHint = "oidc_onelogin";
}
else if (organizationIdentifier == "org_saml2_onelogin")
{
domainHint = "saml2_onelogin";
}
else if (organizationIdentifier == "org_saml2_sustainsys")
{
domainHint = "saml2_sustainsys";
}
else
{
throw new Exception("Organization not found.");
}
var provider = "sso";
var props = new AuthenticationProperties
{
RedirectUri = Url.Action(nameof(ExternalCallback)),
Items =
{
{ "return_url", returnUrl },
{ "domain_hint", domainHint },
{ "scheme", provider },
},
};
return Challenge(props, provider);
}
[HttpGet]
public async Task<ActionResult> ExternalCallback()
{
// Read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync(
IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
if (result?.Succeeded != true)
{
throw new Exception("External authentication error");
}
// Debugging
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims);
var (user, provider, providerUserId, claims) = await FindUserFromExternalProviderAsync(result);
if (user == null)
{
// Should never happen
throw new Exception("Cannot find user.");
}
// this allows us to collect any additonal claims or properties
// for the specific prtotocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
};
ProcessLoginCallbackForOidc(result, additionalLocalClaims, localSignInProps);
// issue authentication cookie for user
await HttpContext.SignInAsync(user.Id.ToString(), user.Email, provider,
localSignInProps, additionalLocalClaims.ToArray());
// delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
// retrieve return URL
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context != null)
{
if (await IsPkceClientAsync(context.ClientId))
{
// if the client is PKCE then we assume it's native, so this change in how to
// return the response is for better UX for the end user.
return View("Redirect", new RedirectViewModel { RedirectUrl = returnUrl });
}
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
return Redirect(returnUrl);
}
// request for a local page
if (Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
else if (string.IsNullOrEmpty(returnUrl))
{
return Redirect("~/");
}
else
{
// user might have clicked on a malicious link - should be logged
throw new Exception("invalid return URL");
}
}
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims)>
FindUserFromExternalProviderAsync(AuthenticateResult result)
{
var externalUser = result.Principal;
// try to determine the unique id of the external user (issued by the provider)
// the most common claim type for that are the sub claim and the NameIdentifier
// depending on the external provider, some other claim type might be used
var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ??
externalUser.FindFirst(ClaimTypes.NameIdentifier) ??
throw new Exception("Unknown userid");
// remove the user id claim so we don't include it as an extra claim if/when we provision the user
var claims = externalUser.Claims.ToList();
claims.Remove(userIdClaim);
var provider = result.Properties.Items["scheme"];
var providerUserId = userIdClaim.Value;
var user = await _userRepository.GetByIdAsync(new Guid(providerUserId));
return (user, provider, providerUserId, claims);
}
private void ProcessLoginCallbackForOidc(AuthenticateResult externalResult,
List<Claim> localClaims, AuthenticationProperties localSignInProps)
{
// if the external system sent a session id claim, copy it over
// so we can use it for single sign-out
var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
if (sid != null)
{
localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
}
// if the external provider issued an id_token, we'll keep it for signout
var id_token = externalResult.Properties.GetTokenValue("id_token");
if (id_token != null)
{
localSignInProps.StoreTokens(
new[] { new AuthenticationToken { Name = "id_token", Value = id_token } });
}
}
public async Task<bool> IsPkceClientAsync(string client_id)
{
if (!string.IsNullOrWhiteSpace(client_id))
{
var client = await _clientStore.FindEnabledClientByIdAsync(client_id);
return client?.RequirePkce == true;
}
return false;
}
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Identity.Models
{
public class RedirectViewModel
{
public string RedirectUrl { get; set; }
}
}

View File

@ -9,6 +9,12 @@ using AspNetCoreRateLimit;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;
using IdentityServer4.Stores;
using Bit.Core.IdentityServer;
using IdentityServer4.Services;
namespace Bit.Identity
{
@ -49,6 +55,9 @@ namespace Bit.Identity
// Caching
services.AddMemoryCache();
// Mvc
services.AddMvc();
if (!globalSettings.SelfHosted)
{
// Rate limiting
@ -56,8 +65,35 @@ namespace Bit.Identity
services.AddSingleton<IRateLimitCounterStore, MemoryCacheRateLimitCounterStore>();
}
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
// Authentication
services
.AddAuthentication()
.AddOpenIdConnect("sso", "Single Sign On", options =>
{
options.Authority = globalSettings.BaseServiceUri.InternalSso;
options.RequireHttpsMetadata = !Environment.IsDevelopment() &&
globalSettings.BaseServiceUri.InternalIdentity.StartsWith("https");
options.ClientId = "oidc-identity";
options.ClientSecret = globalSettings.OidcIdentityClientKey;
options.SignInScheme = IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.ResponseType = "code";
options.Events = new Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
// Pass domain_hint onto the sso idp
context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"];
return Task.FromResult(0);
}
};
});
// IdentityServer
services.AddCustomIdentityServerServices(Environment, globalSettings);
AddCustomIdentityServerServices(services, Environment, globalSettings);
// Identity
services.AddCustomIdentityServices(globalSettings);
@ -80,6 +116,8 @@ namespace Bit.Identity
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
app.UseSerilog(env, appLifetime, globalSettings);
// Default Middleware
@ -95,14 +133,58 @@ namespace Bit.Identity
app.UseForwardedHeaders(globalSettings);
}
// Add static files to the request pipeline.
app.UseStaticFiles();
// Add routing
app.UseRouting();
// Add Cors
app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings))
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
// Add current context
app.UseMiddleware<CurrentContextMiddleware>();
// Add IdentityServer to the request pipeline.
app.UseIdentityServer();
// Add Mvc stuff
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
// Log startup
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
}
public static IIdentityServerBuilder AddCustomIdentityServerServices(IServiceCollection services,
IWebHostEnvironment env, GlobalSettings globalSettings)
{
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services
.AddIdentityServer(options =>
{
options.Endpoints.EnableIntrospectionEndpoint = false;
options.Endpoints.EnableEndSessionEndpoint = false;
options.Endpoints.EnableUserInfoEndpoint = false;
options.Endpoints.EnableCheckSessionEndpoint = false;
options.Endpoints.EnableTokenRevocationEndpoint = false;
options.IssuerUri = $"{issuerUri.Scheme}://{issuerUri.Host}";
options.Caching.ClientStoreExpiration = new TimeSpan(0, 5, 0);
})
.AddInMemoryCaching()
.AddInMemoryApiResources(ApiResources.GetApiResources())
.AddClientStoreCache<ClientStore>()
.AddCustomTokenRequestValidator<CustomTokenRequestValidator>()
.AddProfileService<ProfileService>()
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
.AddPersistedGrantStore<PersistedGrantStore>()
.AddClientStore<ClientStore>()
.AddIdentityServerCertificate(env, globalSettings);
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
return identityServerBuilder;
}
}
}

View File

@ -0,0 +1,12 @@
@model Bit.Identity.Models.RedirectViewModel
<html>
<head>
<meta http-equiv="refresh" content="0;url=@Model.RedirectUrl" data-url="@Model.RedirectUrl">
<script>
window.location.href = document.querySelector("meta[http-equiv=refresh]").getAttribute("data-url");
</script>
</head>
<body>
<p>You are now being returned to the application. Once complete, you may close this tab.</p>
</body>
</html>

View File

@ -6,11 +6,13 @@
"identity": "https://identity.bitwarden.com",
"admin": "https://admin.bitwarden.com",
"notifications": "https://notifications.bitwarden.com",
"sso": "https://sso.bitwarden.com",
"internalNotifications": "https://notifications.bitwarden.com",
"internalAdmin": "https://admin.bitwarden.com",
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com"
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
},
"braintree": {
"production": true

View File

@ -4,17 +4,20 @@
"siteName": "Bitwarden",
"projectName": "Identity",
"stripeApiKey": "SECRET",
"oidcIdentityClientKey": "SECRET",
"baseServiceUri": {
"vault": "https://localhost:8080",
"api": "http://localhost:4000",
"identity": "http://localhost:33656",
"admin": "http://localhost:62911",
"notifications": "http://localhost:61840",
"sso": "http://localhost:51822",
"internalNotifications": "http://localhost:61840",
"internalAdmin": "http://localhost:62911",
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "http://localhost:4001"
"internalVault": "http://localhost:4001",
"internalSso": "http://localhost:51822"
},
"sqlServer": {
"connectionString": "SECRET"