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:
227
src/Identity/Controllers/AccountController.cs
Normal file
227
src/Identity/Controllers/AccountController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
7
src/Identity/Models/RedirectViewModel.cs
Normal file
7
src/Identity/Models/RedirectViewModel.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Identity.Models
|
||||
{
|
||||
public class RedirectViewModel
|
||||
{
|
||||
public string RedirectUrl { get; set; }
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
src/Identity/Views/Shared/Redirect.cshtml
Normal file
12
src/Identity/Views/Shared/Redirect.cshtml
Normal 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>
|
@ -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
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user