From 0d0c6c716737c922aab3a515c43fc5f0d56f4159 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 16 Jul 2020 08:01:39 -0400 Subject: [PATCH] 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 --- src/Admin/appsettings.Production.json | 4 +- src/Admin/appsettings.json | 4 +- src/Api/Controllers/AccountsController.cs | 23 +- src/Api/Startup.cs | 4 +- src/Api/appsettings.Production.json | 4 +- src/Api/appsettings.json | 4 +- src/Billing/appsettings.Production.json | 4 +- src/Billing/appsettings.json | 4 +- src/Core/GlobalSettings.cs | 3 + .../IdentityServer/AuthorizationCodeStore.cs | 44 ++ .../IdentityServer/BaseRequestValidator.cs | 416 ++++++++++++++++++ .../CustomTokenRequestValidator.cs | 89 ++++ .../ResourceOwnerPasswordValidator.cs | 372 +--------------- src/Core/IdentityServer/StaticClients.cs | 59 ++- .../Accounts/VerifyPasswordRequestModel.cs | 12 + .../Utilities/ServiceCollectionExtensions.cs | 101 ++--- src/Events/appsettings.Production.json | 4 +- src/Events/appsettings.json | 4 +- src/Identity/Controllers/AccountController.cs | 227 ++++++++++ src/Identity/Models/RedirectViewModel.cs | 7 + src/Identity/Startup.cs | 84 +++- src/Identity/Views/Shared/Redirect.cshtml | 12 + src/Identity/appsettings.Production.json | 4 +- src/Identity/appsettings.json | 5 +- src/Notifications/appsettings.Production.json | 4 +- src/Notifications/appsettings.json | 4 +- util/Setup/EnvironmentFileBuilder.cs | 5 + util/Setup/Templates/DockerCompose.hbs | 17 + util/Setup/Templates/NginxConfig.hbs | 4 + 29 files changed, 1093 insertions(+), 435 deletions(-) create mode 100644 src/Core/IdentityServer/AuthorizationCodeStore.cs create mode 100644 src/Core/IdentityServer/BaseRequestValidator.cs create mode 100644 src/Core/IdentityServer/CustomTokenRequestValidator.cs create mode 100644 src/Core/Models/Api/Request/Accounts/VerifyPasswordRequestModel.cs create mode 100644 src/Identity/Controllers/AccountController.cs create mode 100644 src/Identity/Models/RedirectViewModel.cs create mode 100644 src/Identity/Views/Shared/Redirect.cshtml diff --git a/src/Admin/appsettings.Production.json b/src/Admin/appsettings.Production.json index 5ea6892d0..6c4313dcd 100644 --- a/src/Admin/appsettings.Production.json +++ b/src/Admin/appsettings.Production.json @@ -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 diff --git a/src/Admin/appsettings.json b/src/Admin/appsettings.json index 44dc6524e..e5e7f4609 100644 --- a/src/Admin/appsettings.json +++ b/src/Admin/appsettings.json @@ -10,11 +10,13 @@ "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" diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index cb4630d00..bfab73a31 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -195,6 +195,25 @@ namespace Bit.Api.Controllers throw new BadRequestException(ModelState); } + [HttpPost("verify-password")] + public async Task PostVerifyPassword([FromBody]VerifyPasswordRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + if (await _userService.CheckPasswordAsync(user, model.MasterPasswordHash)) + { + return; + } + + ModelState.AddModelError(nameof(model.MasterPasswordHash), "Invalid password."); + await Task.Delay(2000); + throw new BadRequestException(ModelState); + } + [HttpPost("kdf")] public async Task PostKdf([FromBody]KdfRequestModel model) { @@ -633,7 +652,7 @@ namespace Bit.Api.Controllers return token; } - + [HttpGet("tax")] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetTaxInfo() @@ -647,7 +666,7 @@ namespace Bit.Api.Controllers var taxInfo = await _paymentService.GetTaxInfoAsync(user); return new TaxInfoResponseModel(taxInfo); } - + [HttpPut("tax")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PutTaxInfo([FromBody]TaxInfoUpdateRequestModel model) diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index e682759c3..b1978c292 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -78,13 +78,13 @@ namespace Bit.Api config.AddPolicy("Application", policy => { policy.RequireAuthenticatedUser(); - policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application"); + policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, "api"); }); config.AddPolicy("Web", policy => { policy.RequireAuthenticatedUser(); - policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application"); + policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, "api"); policy.RequireClaim(JwtClaimTypes.ClientId, "web"); }); diff --git a/src/Api/appsettings.Production.json b/src/Api/appsettings.Production.json index c4be398fc..3d1330713 100644 --- a/src/Api/appsettings.Production.json +++ b/src/Api/appsettings.Production.json @@ -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 diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index 176ce060e..c90737301 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -10,11 +10,13 @@ "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" diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 27fc64746..9c444bb66 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -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 diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 7685801b5..31904b911 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -10,11 +10,13 @@ "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" diff --git a/src/Core/GlobalSettings.cs b/src/Core/GlobalSettings.cs index 2293e468d..1a0ec4517 100644 --- a/src/Core/GlobalSettings.cs +++ b/src/Core/GlobalSettings.cs @@ -15,6 +15,7 @@ namespace Bit.Core public string LicenseCertificatePassword { get; set; } public virtual string PushRelayBaseUri { get; set; } public virtual string InternalIdentityKey { get; set; } + public virtual string OidcIdentityClientKey { get; set; } public virtual string HibpApiKey { get; set; } public virtual bool DisableUserRegistration { get; set; } public virtual bool DisableEmailNewDevice { get; set; } @@ -50,11 +51,13 @@ namespace Bit.Core public string Identity { get; set; } public string Admin { get; set; } public string Notifications { get; set; } + public string Sso { get; set; } public string InternalNotifications { get; set; } public string InternalAdmin { get; set; } public string InternalIdentity { get; set; } public string InternalApi { get; set; } public string InternalVault { get; set; } + public string InternalSso { get; set; } } public class SqlSettings diff --git a/src/Core/IdentityServer/AuthorizationCodeStore.cs b/src/Core/IdentityServer/AuthorizationCodeStore.cs new file mode 100644 index 000000000..194c676e1 --- /dev/null +++ b/src/Core/IdentityServer/AuthorizationCodeStore.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using IdentityServer4; +using IdentityServer4.Extensions; +using IdentityServer4.Models; +using IdentityServer4.Services; +using IdentityServer4.Stores; +using IdentityServer4.Stores.Serialization; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.IdentityServer +{ + // ref: https://raw.githubusercontent.com/IdentityServer/IdentityServer4/3.1.3/src/IdentityServer4/src/Stores/Default/DefaultAuthorizationCodeStore.cs + public class AuthorizationCodeStore : DefaultGrantStore, IAuthorizationCodeStore + { + public AuthorizationCodeStore( + IPersistedGrantStore store, + IPersistentGrantSerializer serializer, + IHandleGenerationService handleGenerationService, + ILogger logger) + : base(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode, store, serializer, + handleGenerationService, logger) + { } + + public Task StoreAuthorizationCodeAsync(AuthorizationCode code) + { + return CreateItemAsync(code, code.ClientId, code.Subject.GetSubjectId(), code.CreationTime, code.Lifetime); + } + + public Task GetAuthorizationCodeAsync(string code) + { + return GetItemAsync(code); + } + + public Task RemoveAuthorizationCodeAsync(string code) + { + // return RemoveItemAsync(code); + + // We don't want to delete authorization codes during validation. + // We'll rely on the authorization code lifecycle for short term validation and the + // DatabaseExpiredGrantsJob to clean up old authorization codes. + return Task.FromResult(0); + } + } +} diff --git a/src/Core/IdentityServer/BaseRequestValidator.cs b/src/Core/IdentityServer/BaseRequestValidator.cs new file mode 100644 index 000000000..806fd5e5a --- /dev/null +++ b/src/Core/IdentityServer/BaseRequestValidator.cs @@ -0,0 +1,416 @@ +using Bit.Core.Models.Table; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Bit.Core.Services; +using System.Linq; +using Bit.Core.Models; +using Bit.Core.Identity; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Bit.Core.Models.Api; + +namespace Bit.Core.IdentityServer +{ + public abstract class BaseRequestValidator where T : class + { + private UserManager _userManager; + private readonly IDeviceRepository _deviceRepository; + private readonly IDeviceService _deviceService; + private readonly IUserService _userService; + private readonly IEventService _eventService; + private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IMailService _mailService; + private readonly ILogger _logger; + private readonly CurrentContext _currentContext; + private readonly GlobalSettings _globalSettings; + + public BaseRequestValidator( + UserManager userManager, + IDeviceRepository deviceRepository, + IDeviceService deviceService, + IUserService userService, + IEventService eventService, + IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IApplicationCacheService applicationCacheService, + IMailService mailService, + ILogger logger, + CurrentContext currentContext, + GlobalSettings globalSettings) + { + _userManager = userManager; + _deviceRepository = deviceRepository; + _deviceService = deviceService; + _userService = userService; + _eventService = eventService; + _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider; + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _applicationCacheService = applicationCacheService; + _mailService = mailService; + _logger = logger; + _currentContext = currentContext; + _globalSettings = globalSettings; + } + + protected async Task ValidateAsync(T context, ValidatedTokenRequest request) + { + var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString(); + var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString(); + var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1"; + var twoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && + !string.IsNullOrWhiteSpace(twoFactorProvider); + + var (user, valid) = await ValidateContextAsync(context); + if (!valid) + { + await BuildErrorResultAsync(false, context, user); + return; + } + + var twoFactorRequirement = await RequiresTwoFactorAsync(user); + if (twoFactorRequirement.Item1) + { + var twoFactorProviderType = TwoFactorProviderType.Authenticator; // Just defaulting it + if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out twoFactorProviderType)) + { + await BuildTwoFactorResultAsync(user, twoFactorRequirement.Item2, context); + return; + } + + var verified = await VerifyTwoFactor(user, twoFactorRequirement.Item2, + twoFactorProviderType, twoFactorToken); + if (!verified && twoFactorProviderType != TwoFactorProviderType.Remember) + { + await BuildErrorResultAsync(true, context, user); + return; + } + else if (!verified && twoFactorProviderType == TwoFactorProviderType.Remember) + { + await Task.Delay(2000); // Delay for brute force. + await BuildTwoFactorResultAsync(user, twoFactorRequirement.Item2, context); + return; + } + } + else + { + twoFactorRequest = false; + twoFactorRemember = false; + twoFactorToken = null; + } + + var device = await SaveDeviceAsync(user, request); + await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember); + return; + } + + protected abstract Task<(User, bool)> ValidateContextAsync(T context); + + protected async Task BuildSuccessResultAsync(User user, T context, Device device, bool sendRememberToken) + { + await _eventService.LogUserEventAsync(user.Id, EventType.User_LoggedIn); + + var claims = new List(); + + if (device != null) + { + claims.Add(new Claim("device", device.Identifier)); + } + + var customResponse = new Dictionary(); + if (!string.IsNullOrWhiteSpace(user.PrivateKey)) + { + customResponse.Add("PrivateKey", user.PrivateKey); + } + + if (!string.IsNullOrWhiteSpace(user.Key)) + { + customResponse.Add("Key", user.Key); + } + + customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); + customResponse.Add("Kdf", (byte)user.Kdf); + customResponse.Add("KdfIterations", user.KdfIterations); + + if (sendRememberToken) + { + var token = await _userManager.GenerateTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); + customResponse.Add("TwoFactorToken", token); + } + + SetSuccessResult(context, user, claims, customResponse); + } + + protected async Task BuildTwoFactorResultAsync(User user, Organization organization, T context) + { + var providerKeys = new List(); + var providers = new Dictionary>(); + + var enabledProviders = new List>(); + if (organization?.GetTwoFactorProviders() != null) + { + enabledProviders.AddRange(organization.GetTwoFactorProviders().Where( + p => organization.TwoFactorProviderIsEnabled(p.Key))); + } + + if (user.GetTwoFactorProviders() != null) + { + foreach (var p in user.GetTwoFactorProviders()) + { + if (await _userService.TwoFactorProviderIsEnabledAsync(p.Key, user)) + { + enabledProviders.Add(p); + } + } + } + + if (!enabledProviders.Any()) + { + await BuildErrorResultAsync(false, context, user); + return; + } + + foreach (var provider in enabledProviders) + { + providerKeys.Add((byte)provider.Key); + var infoDict = await BuildTwoFactorParams(organization, user, provider.Key, provider.Value); + providers.Add((byte)provider.Key, infoDict); + } + + SetTwoFactorResult(context, + new Dictionary + { + { "TwoFactorProviders", providers.Keys }, + { "TwoFactorProviders2", providers } + }); + + if (enabledProviders.Count() == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email) + { + // Send email now if this is their only 2FA method + await _userService.SendTwoFactorEmailAsync(user); + } + } + + protected async Task BuildErrorResultAsync(bool twoFactorRequest, T context, User user) + { + if (user != null) + { + await _eventService.LogUserEventAsync(user.Id, + twoFactorRequest ? EventType.User_FailedLogIn2fa : EventType.User_FailedLogIn); + } + + if (_globalSettings.SelfHosted) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".", + $" {_currentContext.IpAddress}")); + } + await Task.Delay(2000); // Delay for brute force. + SetErrorResult(context, + new Dictionary + {{ + "ErrorModel", new ErrorResponseModel(twoFactorRequest ? + "Two-step token is invalid. Try again." : "Username or password is incorrect. Try again.") + }}); + } + + protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); + + protected abstract void SetSuccessResult(T context, User user, List claims, + Dictionary customResponse); + + protected abstract void SetErrorResult(T context, Dictionary customResponse); + + private async Task> RequiresTwoFactorAsync(User user) + { + var individualRequired = _userManager.SupportsUserTwoFactor && + await _userManager.GetTwoFactorEnabledAsync(user) && + (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; + + Organization firstEnabledOrg = null; + var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) + .ToList(); + if (orgs.Any()) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var twoFactorOrgs = orgs.Where(o => OrgUsing2fa(orgAbilities, o.Id)); + if (twoFactorOrgs.Any()) + { + var userOrgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); + firstEnabledOrg = userOrgs.FirstOrDefault( + o => orgs.Any(om => om.Id == o.Id) && o.TwoFactorIsEnabled()); + } + } + + return new Tuple(individualRequired || firstEnabledOrg != null, firstEnabledOrg); + } + + private bool OrgUsing2fa(IDictionary orgAbilities, Guid orgId) + { + return orgAbilities != null && orgAbilities.ContainsKey(orgId) && + orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; + } + + private Device GetDeviceFromRequest(ValidatedRequest request) + { + var deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString(); + var deviceType = request.Raw["DeviceType"]?.ToString(); + var deviceName = request.Raw["DeviceName"]?.ToString(); + var devicePushToken = request.Raw["DevicePushToken"]?.ToString(); + + if (string.IsNullOrWhiteSpace(deviceIdentifier) || string.IsNullOrWhiteSpace(deviceType) || + string.IsNullOrWhiteSpace(deviceName) || !Enum.TryParse(deviceType, out DeviceType type)) + { + return null; + } + + return new Device + { + Identifier = deviceIdentifier, + Name = deviceName, + Type = type, + PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken + }; + } + + private async Task VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType type, + string token) + { + switch (type) + { + case TwoFactorProviderType.Authenticator: + case TwoFactorProviderType.Email: + case TwoFactorProviderType.Duo: + case TwoFactorProviderType.YubiKey: + case TwoFactorProviderType.U2f: + case TwoFactorProviderType.Remember: + if (type != TwoFactorProviderType.Remember && + !(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) + { + return false; + } + return await _userManager.VerifyTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(type), token); + case TwoFactorProviderType.OrganizationDuo: + if (!organization?.TwoFactorProviderIsEnabled(type) ?? true) + { + return false; + } + + return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user); + default: + return false; + } + } + + private async Task> BuildTwoFactorParams(Organization organization, User user, + TwoFactorProviderType type, TwoFactorProvider provider) + { + switch (type) + { + case TwoFactorProviderType.Duo: + case TwoFactorProviderType.U2f: + case TwoFactorProviderType.Email: + case TwoFactorProviderType.YubiKey: + if (!(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) + { + return null; + } + + var token = await _userManager.GenerateTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(type)); + if (type == TwoFactorProviderType.Duo) + { + return new Dictionary + { + ["Host"] = provider.MetaData["Host"], + ["Signature"] = token + }; + } + else if (type == TwoFactorProviderType.U2f) + { + // TODO: Remove "Challenges" in a future update. Deprecated. + var tokens = token?.Split('|'); + return new Dictionary + { + ["Challenge"] = tokens != null && tokens.Length > 0 ? tokens[0] : null, + ["Challenges"] = tokens != null && tokens.Length > 1 ? tokens[1] : null + }; + } + else if (type == TwoFactorProviderType.Email) + { + return new Dictionary + { + ["Email"] = token + }; + } + else if (type == TwoFactorProviderType.YubiKey) + { + return new Dictionary + { + ["Nfc"] = (bool)provider.MetaData["Nfc"] + }; + } + return null; + case TwoFactorProviderType.OrganizationDuo: + if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization)) + { + return new Dictionary + { + ["Host"] = provider.MetaData["Host"], + ["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user) + }; + } + return null; + default: + return null; + } + } + + private async Task SaveDeviceAsync(User user, ValidatedTokenRequest request) + { + var device = GetDeviceFromRequest(request); + if (device != null) + { + var existingDevice = await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id); + if (existingDevice == null) + { + device.UserId = user.Id; + await _deviceService.SaveAsync(device); + + var now = DateTime.UtcNow; + if (now - user.CreationDate > TimeSpan.FromMinutes(10)) + { + var deviceType = device.Type.GetType().GetMember(device.Type.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName(); + if (!_globalSettings.DisableEmailNewDevice) + { + await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, + _currentContext.IpAddress); + } + } + + return device; + } + + return existingDevice; + } + + return null; + } + } +} diff --git a/src/Core/IdentityServer/CustomTokenRequestValidator.cs b/src/Core/IdentityServer/CustomTokenRequestValidator.cs new file mode 100644 index 000000000..d12d220e0 --- /dev/null +++ b/src/Core/IdentityServer/CustomTokenRequestValidator.cs @@ -0,0 +1,89 @@ +using Bit.Core.Models.Table; +using Bit.Core.Repositories; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Bit.Core.Services; +using System.Linq; +using Bit.Core.Identity; +using Microsoft.Extensions.Logging; +using IdentityServer4.Extensions; + +namespace Bit.Core.IdentityServer +{ + public class CustomTokenRequestValidator : BaseRequestValidator, + ICustomTokenRequestValidator + { + private UserManager _userManager; + + public CustomTokenRequestValidator( + UserManager userManager, + IDeviceRepository deviceRepository, + IDeviceService deviceService, + IUserService userService, + IEventService eventService, + IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IApplicationCacheService applicationCacheService, + IMailService mailService, + ILogger logger, + CurrentContext currentContext, + GlobalSettings globalSettings) + : base(userManager, deviceRepository, deviceService, userService, eventService, + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings) + { + _userManager = userManager; + } + + public async Task ValidateAsync(CustomTokenRequestValidationContext context) + { + if (context.Result.ValidatedRequest.GrantType != "authorization_code") + { + return; + } + await ValidateAsync(context, context.Result.ValidatedRequest); + } + + protected async override Task<(User, bool)> ValidateContextAsync(CustomTokenRequestValidationContext context) + { + var user = await _userManager.FindByEmailAsync(context.Result.ValidatedRequest.Subject.GetDisplayName()); + return (user, user != null); + } + + protected override void SetSuccessResult(CustomTokenRequestValidationContext context, User user, + List claims, Dictionary customResponse) + { + context.Result.CustomResponse = customResponse; + if (claims?.Any() ?? false) + { + context.Result.ValidatedRequest.Client.AlwaysSendClientClaims = true; + context.Result.ValidatedRequest.Client.ClientClaimsPrefix = string.Empty; + foreach (var claim in claims) + { + context.Result.ValidatedRequest.ClientClaims.Add(claim); + } + } + } + + protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context, + Dictionary customResponse) + { + context.Result.Error = "invalid_grant"; + context.Result.ErrorDescription = "Two factor required."; + context.Result.IsError = true; + context.Result.CustomResponse = customResponse; + } + + protected override void SetErrorResult(CustomTokenRequestValidationContext context, + Dictionary customResponse) + { + context.Result.Error = "invalid_grant"; + context.Result.IsError = true; + context.Result.CustomResponse = customResponse; + } + } +} diff --git a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs index 53aa2cb75..1422d0d88 100644 --- a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -1,41 +1,22 @@ -using Bit.Core.Models.Api; -using Bit.Core.Models.Table; -using Bit.Core.Enums; +using Bit.Core.Models.Table; using Bit.Core.Repositories; using IdentityServer4.Models; using IdentityServer4.Validation; using Microsoft.AspNetCore.Identity; -using System; using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; using Bit.Core.Services; -using System.Linq; -using Bit.Core.Models; using Bit.Core.Identity; -using Bit.Core.Models.Data; -using Bit.Core.Utilities; -using System.ComponentModel.DataAnnotations; -using System.Reflection; using Microsoft.Extensions.Logging; namespace Bit.Core.IdentityServer { - public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator + public class ResourceOwnerPasswordValidator : BaseRequestValidator, + IResourceOwnerPasswordValidator { private UserManager _userManager; - private readonly IDeviceRepository _deviceRepository; - private readonly IDeviceService _deviceService; private readonly IUserService _userService; - private readonly IEventService _eventService; - private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IApplicationCacheService _applicationCacheService; - private readonly IMailService _mailService; - private readonly ILogger _logger; - private readonly CurrentContext _currentContext; - private readonly GlobalSettings _globalSettings; public ResourceOwnerPasswordValidator( UserManager userManager, @@ -51,366 +32,55 @@ namespace Bit.Core.IdentityServer ILogger logger, CurrentContext currentContext, GlobalSettings globalSettings) + : base(userManager, deviceRepository, deviceService, userService, eventService, + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings) { _userManager = userManager; - _deviceRepository = deviceRepository; - _deviceService = deviceService; _userService = userService; - _eventService = eventService; - _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider; - _organizationRepository = organizationRepository; - _organizationUserRepository = organizationUserRepository; - _applicationCacheService = applicationCacheService; - _mailService = mailService; - _logger = logger; - _currentContext = currentContext; - _globalSettings = globalSettings; } public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { - var twoFactorToken = context.Request.Raw["TwoFactorToken"]?.ToString(); - var twoFactorProvider = context.Request.Raw["TwoFactorProvider"]?.ToString(); - var twoFactorRemember = context.Request.Raw["TwoFactorRemember"]?.ToString() == "1"; - var twoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && - !string.IsNullOrWhiteSpace(twoFactorProvider); + await ValidateAsync(context, context.Request); + } + protected async override Task<(User, bool)> ValidateContextAsync(ResourceOwnerPasswordValidationContext context) + { if (string.IsNullOrWhiteSpace(context.UserName)) { - await BuildErrorResultAsync(false, context, null); - return; + return (null, false); } var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant()); if (user == null || !await _userService.CheckPasswordAsync(user, context.Password)) { - await BuildErrorResultAsync(false, context, user); - return; + return (user, false); } - var twoFactorRequirement = await RequiresTwoFactorAsync(user); - if (twoFactorRequirement.Item1) - { - var twoFactorProviderType = TwoFactorProviderType.Authenticator; // Just defaulting it - if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out twoFactorProviderType)) - { - await BuildTwoFactorResultAsync(user, twoFactorRequirement.Item2, context); - return; - } - - var verified = await VerifyTwoFactor(user, twoFactorRequirement.Item2, - twoFactorProviderType, twoFactorToken); - if (!verified && twoFactorProviderType != TwoFactorProviderType.Remember) - { - await BuildErrorResultAsync(true, context, user); - return; - } - else if (!verified && twoFactorProviderType == TwoFactorProviderType.Remember) - { - await Task.Delay(2000); // Delay for brute force. - await BuildTwoFactorResultAsync(user, twoFactorRequirement.Item2, context); - return; - } - } - else - { - twoFactorRequest = false; - twoFactorRemember = false; - twoFactorToken = null; - } - - var device = await SaveDeviceAsync(user, context); - await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember); - return; + return (user, true); } - private async Task BuildSuccessResultAsync(User user, ResourceOwnerPasswordValidationContext context, - Device device, bool sendRememberToken) + protected override void SetSuccessResult(ResourceOwnerPasswordValidationContext context, User user, + List claims, Dictionary customResponse) { - await _eventService.LogUserEventAsync(user.Id, EventType.User_LoggedIn); - - var claims = new List(); - - if (device != null) - { - claims.Add(new Claim("device", device.Identifier)); - } - - var customResponse = new Dictionary(); - if (!string.IsNullOrWhiteSpace(user.PrivateKey)) - { - customResponse.Add("PrivateKey", user.PrivateKey); - } - - if (!string.IsNullOrWhiteSpace(user.Key)) - { - customResponse.Add("Key", user.Key); - } - - if (sendRememberToken) - { - var token = await _userManager.GenerateTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); - customResponse.Add("TwoFactorToken", token); - } - context.Result = new GrantValidationResult(user.Id.ToString(), "Application", identityProvider: "bitwarden", claims: claims.Count > 0 ? claims : null, customResponse: customResponse); } - private async Task BuildTwoFactorResultAsync(User user, Organization organization, - ResourceOwnerPasswordValidationContext context) + protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContext context, + Dictionary customResponse) { - var providerKeys = new List(); - var providers = new Dictionary>(); - - var enabledProviders = new List>(); - if (organization?.GetTwoFactorProviders() != null) - { - enabledProviders.AddRange(organization.GetTwoFactorProviders().Where( - p => organization.TwoFactorProviderIsEnabled(p.Key))); - } - - if (user.GetTwoFactorProviders() != null) - { - foreach (var p in user.GetTwoFactorProviders()) - { - if (await _userService.TwoFactorProviderIsEnabledAsync(p.Key, user)) - { - enabledProviders.Add(p); - } - } - } - - if (!enabledProviders.Any()) - { - await BuildErrorResultAsync(false, context, user); - return; - } - - foreach (var provider in enabledProviders) - { - providerKeys.Add((byte)provider.Key); - var infoDict = await BuildTwoFactorParams(organization, user, provider.Key, provider.Value); - providers.Add((byte)provider.Key, infoDict); - } - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.", - new Dictionary - { - { "TwoFactorProviders", providers.Keys }, - { "TwoFactorProviders2", providers } - }); - - if (enabledProviders.Count() == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email) - { - // Send email now if this is their only 2FA method - await _userService.SendTwoFactorEmailAsync(user); - } + customResponse); } - private async Task BuildErrorResultAsync(bool twoFactorRequest, - ResourceOwnerPasswordValidationContext context, User user) + protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context, + Dictionary customResponse) { - if (user != null) - { - await _eventService.LogUserEventAsync(user.Id, - twoFactorRequest ? EventType.User_FailedLogIn2fa : EventType.User_FailedLogIn); - } - - if (_globalSettings.SelfHosted) - { - _logger.LogWarning(Constants.BypassFiltersEventId, - string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".", - $" {_currentContext.IpAddress}")); - } - await Task.Delay(2000); // Delay for brute force. - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, - customResponse: new Dictionary - {{ - "ErrorModel", new ErrorResponseModel(twoFactorRequest ? - "Two-step token is invalid. Try again." : "Username or password is incorrect. Try again.") - }}); - } - - public async Task> RequiresTwoFactorAsync(User user) - { - var individualRequired = _userManager.SupportsUserTwoFactor && - await _userManager.GetTwoFactorEnabledAsync(user) && - (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; - - Organization firstEnabledOrg = null; - var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) - .ToList(); - if (orgs.Any()) - { - var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); - var twoFactorOrgs = orgs.Where(o => OrgUsing2fa(orgAbilities, o.Id)); - if (twoFactorOrgs.Any()) - { - var userOrgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); - firstEnabledOrg = userOrgs.FirstOrDefault( - o => orgs.Any(om => om.Id == o.Id) && o.TwoFactorIsEnabled()); - } - } - - return new Tuple(individualRequired || firstEnabledOrg != null, firstEnabledOrg); - } - - private bool OrgUsing2fa(IDictionary orgAbilities, Guid orgId) - { - return orgAbilities != null && orgAbilities.ContainsKey(orgId) && - orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; - } - - private Device GetDeviceFromRequest(ResourceOwnerPasswordValidationContext context) - { - var deviceIdentifier = context.Request.Raw["DeviceIdentifier"]?.ToString(); - var deviceType = context.Request.Raw["DeviceType"]?.ToString(); - var deviceName = context.Request.Raw["DeviceName"]?.ToString(); - var devicePushToken = context.Request.Raw["DevicePushToken"]?.ToString(); - - if (string.IsNullOrWhiteSpace(deviceIdentifier) || string.IsNullOrWhiteSpace(deviceType) || - string.IsNullOrWhiteSpace(deviceName) || !Enum.TryParse(deviceType, out DeviceType type)) - { - return null; - } - - return new Device - { - Identifier = deviceIdentifier, - Name = deviceName, - Type = type, - PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken - }; - } - - private async Task VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType type, - string token) - { - switch (type) - { - case TwoFactorProviderType.Authenticator: - case TwoFactorProviderType.Email: - case TwoFactorProviderType.Duo: - case TwoFactorProviderType.YubiKey: - case TwoFactorProviderType.U2f: - case TwoFactorProviderType.Remember: - if (type != TwoFactorProviderType.Remember && - !(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) - { - return false; - } - return await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(type), token); - case TwoFactorProviderType.OrganizationDuo: - if (!organization?.TwoFactorProviderIsEnabled(type) ?? true) - { - return false; - } - - return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user); - default: - return false; - } - } - - private async Task> BuildTwoFactorParams(Organization organization, User user, - TwoFactorProviderType type, TwoFactorProvider provider) - { - switch (type) - { - case TwoFactorProviderType.Duo: - case TwoFactorProviderType.U2f: - case TwoFactorProviderType.Email: - case TwoFactorProviderType.YubiKey: - if (!(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) - { - return null; - } - - var token = await _userManager.GenerateTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(type)); - if (type == TwoFactorProviderType.Duo) - { - return new Dictionary - { - ["Host"] = provider.MetaData["Host"], - ["Signature"] = token - }; - } - else if (type == TwoFactorProviderType.U2f) - { - // TODO: Remove "Challenges" in a future update. Deprecated. - var tokens = token?.Split('|'); - return new Dictionary - { - ["Challenge"] = tokens != null && tokens.Length > 0 ? tokens[0] : null, - ["Challenges"] = tokens != null && tokens.Length > 1 ? tokens[1] : null - }; - } - else if (type == TwoFactorProviderType.Email) - { - return new Dictionary - { - ["Email"] = token - }; - } - else if (type == TwoFactorProviderType.YubiKey) - { - return new Dictionary - { - ["Nfc"] = (bool)provider.MetaData["Nfc"] - }; - } - return null; - case TwoFactorProviderType.OrganizationDuo: - if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization)) - { - return new Dictionary - { - ["Host"] = provider.MetaData["Host"], - ["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user) - }; - } - return null; - default: - return null; - } - } - - private async Task SaveDeviceAsync(User user, ResourceOwnerPasswordValidationContext context) - { - var device = GetDeviceFromRequest(context); - if (device != null) - { - var existingDevice = await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id); - if (existingDevice == null) - { - device.UserId = user.Id; - await _deviceService.SaveAsync(device); - - var now = DateTime.UtcNow; - if (now - user.CreationDate > TimeSpan.FromMinutes(10)) - { - var deviceType = device.Type.GetType().GetMember(device.Type.ToString()) - .FirstOrDefault()?.GetCustomAttribute()?.GetName(); - if (!_globalSettings.DisableEmailNewDevice) - { - await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, - _currentContext.IpAddress); - } - } - - return device; - } - - return existingDevice; - } - - return null; + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse); } } } diff --git a/src/Core/IdentityServer/StaticClients.cs b/src/Core/IdentityServer/StaticClients.cs index 81f7e895a..34f4fd37a 100644 --- a/src/Core/IdentityServer/StaticClients.cs +++ b/src/Core/IdentityServer/StaticClients.cs @@ -1,4 +1,5 @@ -using IdentityServer4.Models; +using IdentityServer4; +using IdentityServer4.Models; using System.Collections.Generic; using System.Linq; @@ -28,8 +29,7 @@ namespace Bit.Core.IdentityServer string[] scopes = null) { ClientId = id; - RequireClientSecret = false; - AllowedGrantTypes = GrantTypes.ResourceOwnerPassword; + AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode }; RefreshTokenExpiration = TokenExpiration.Sliding; RefreshTokenUsage = TokenUsage.ReUse; SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; @@ -38,6 +38,39 @@ namespace Bit.Core.IdentityServer AccessTokenLifetime = 3600 * accessTokenLifetimeHours; AllowOfflineAccess = true; + RequireConsent = false; + RequirePkce = true; + RequireClientSecret = false; + if (id == "web") + { + RedirectUris = new[] { "https://localhost:8080/sso-connector.html" }; + PostLogoutRedirectUris = new[] { "https://localhost:8080" }; + AllowedCorsOrigins = new[] { "https://localhost:8080" }; + } + else if (id == "desktop") + { + RedirectUris = new[] { "bitwarden://sso-callback" }; + PostLogoutRedirectUris = new[] { "bitwarden-desktop://logged-out" }; + } + else if (id == "connector") + { + RedirectUris = new[] { "bwdc://sso-callback" }; + PostLogoutRedirectUris = new[] { "bwdc://logged-out" }; + } + else if (id == "browser") + { + // TODO + } + else if (id == "cli") + { + // TODO + } + else if (id == "mobile") + { + RedirectUris = new[] { "bitwarden://sso-callback" }; + PostLogoutRedirectUris = new[] { "bitwarden://logged-out" }; + } + if (scopes == null) { scopes = new string[] { "api" }; @@ -45,5 +78,25 @@ namespace Bit.Core.IdentityServer AllowedScopes = scopes; } } + + public class OidcIdentityClient : Client + { + public OidcIdentityClient(GlobalSettings globalSettings) + { + ClientId = "oidc-identity"; + RequireClientSecret = true; + RequirePkce = true; + ClientSecrets = new List { new Secret(globalSettings.OidcIdentityClientKey.Sha256()) }; + AllowedScopes = new string[] + { + IdentityServerConstants.StandardScopes.OpenId, + IdentityServerConstants.StandardScopes.Profile + }; + AllowedGrantTypes = GrantTypes.Code; + Enabled = true; + RedirectUris = new List { $"{globalSettings.BaseServiceUri.Identity}/signin-oidc" }; + RequireConsent = false; + } + } } } diff --git a/src/Core/Models/Api/Request/Accounts/VerifyPasswordRequestModel.cs b/src/Core/Models/Api/Request/Accounts/VerifyPasswordRequestModel.cs new file mode 100644 index 000000000..fe03ef1c7 --- /dev/null +++ b/src/Core/Models/Api/Request/Accounts/VerifyPasswordRequestModel.cs @@ -0,0 +1,12 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class VerifyPasswordRequestModel + { + [Required] + [StringLength(300)] + public string MasterPasswordHash { get; set; } + } +} diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index ba6f8c2f8..bb3485a15 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -5,9 +5,6 @@ using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Services; using IdentityModel; -using IdentityServer4.Services; -using IdentityServer4.Stores; -using IdentityServer4.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; @@ -339,67 +336,6 @@ namespace Bit.Core.Utilities } } - public static IIdentityServerBuilder AddCustomIdentityServerServices( - this IServiceCollection services, IWebHostEnvironment env, GlobalSettings globalSettings) - { - var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); - var identityServerBuilder = services - .AddIdentityServer(options => - { - options.Endpoints.EnableAuthorizeEndpoint = false; - 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(); - - if (env.IsDevelopment()) - { - identityServerBuilder.AddDeveloperSigningCredential(false); - } - else if (globalSettings.SelfHosted && - CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CertificatePassword) - && File.Exists("identity.pfx")) - { - var identityServerCert = CoreHelpers.GetCertificate("identity.pfx", - globalSettings.IdentityServer.CertificatePassword); - identityServerBuilder.AddSigningCredential(identityServerCert); - } - else if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CertificateThumbprint)) - { - var identityServerCert = CoreHelpers.GetCertificate( - globalSettings.IdentityServer.CertificateThumbprint); - identityServerBuilder.AddSigningCredential(identityServerCert); - } - else if (!globalSettings.SelfHosted && - CoreHelpers.SettingHasValue(globalSettings.Storage?.ConnectionString) && - CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CertificatePassword)) - { - var storageAccount = CloudStorageAccount.Parse(globalSettings.Storage.ConnectionString); - var identityServerCert = CoreHelpers.GetBlobCertificateAsync(storageAccount, "certificates", - "identity.pfx", globalSettings.IdentityServer.CertificatePassword).GetAwaiter().GetResult(); - identityServerBuilder.AddSigningCredential(identityServerCert); - } - else - { - throw new Exception("No identity certificate to use."); - } - - services.AddTransient(); - services.AddTransient(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - - return identityServerBuilder; - } - public static void AddCustomDataProtectionServices( this IServiceCollection services, IWebHostEnvironment env, GlobalSettings globalSettings) { @@ -435,6 +371,43 @@ namespace Bit.Core.Utilities } } + public static IIdentityServerBuilder AddIdentityServerCertificate( + this IIdentityServerBuilder identityServerBuilder, IWebHostEnvironment env, GlobalSettings globalSettings) + { + if (env.IsDevelopment()) + { + identityServerBuilder.AddDeveloperSigningCredential(false); + } + else if (globalSettings.SelfHosted && + CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CertificatePassword) + && File.Exists("identity.pfx")) + { + var identityServerCert = CoreHelpers.GetCertificate("identity.pfx", + globalSettings.IdentityServer.CertificatePassword); + identityServerBuilder.AddSigningCredential(identityServerCert); + } + else if (CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CertificateThumbprint)) + { + var identityServerCert = CoreHelpers.GetCertificate( + globalSettings.IdentityServer.CertificateThumbprint); + identityServerBuilder.AddSigningCredential(identityServerCert); + } + else if (!globalSettings.SelfHosted && + CoreHelpers.SettingHasValue(globalSettings.Storage?.ConnectionString) && + CoreHelpers.SettingHasValue(globalSettings.IdentityServer.CertificatePassword)) + { + var storageAccount = CloudStorageAccount.Parse(globalSettings.Storage.ConnectionString); + var identityServerCert = CoreHelpers.GetBlobCertificateAsync(storageAccount, "certificates", + "identity.pfx", globalSettings.IdentityServer.CertificatePassword).GetAwaiter().GetResult(); + identityServerBuilder.AddSigningCredential(identityServerCert); + } + else + { + throw new Exception("No identity certificate to use."); + } + return identityServerBuilder; + } + public static GlobalSettings AddGlobalSettingsServices(this IServiceCollection services, IConfiguration configuration) { diff --git a/src/Events/appsettings.Production.json b/src/Events/appsettings.Production.json index c0717e38e..1f85555af 100644 --- a/src/Events/appsettings.Production.json +++ b/src/Events/appsettings.Production.json @@ -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" } } } diff --git a/src/Events/appsettings.json b/src/Events/appsettings.json index b1298a4f5..a3d2fcf88 100644 --- a/src/Events/appsettings.json +++ b/src/Events/appsettings.json @@ -8,11 +8,13 @@ "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" diff --git a/src/Identity/Controllers/AccountController.cs b/src/Identity/Controllers/AccountController.cs new file mode 100644 index 000000000..2b81b3c4d --- /dev/null +++ b/src/Identity/Controllers/AccountController.cs @@ -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 _logger; + + public AccountController( + IIdentityServerInteractionService interaction, + IUserRepository userRepository, + IClientStore clientStore, + ILogger logger) + { + _interaction = interaction; + _userRepository = userRepository; + _clientStore = clientStore; + _logger = logger; + } + + [HttpGet] + public async Task 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 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(); + 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 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 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 IsPkceClientAsync(string client_id) + { + if (!string.IsNullOrWhiteSpace(client_id)) + { + var client = await _clientStore.FindEnabledClientByIdAsync(client_id); + return client?.RequirePkce == true; + } + return false; + } + } +} diff --git a/src/Identity/Models/RedirectViewModel.cs b/src/Identity/Models/RedirectViewModel.cs new file mode 100644 index 000000000..848fdf871 --- /dev/null +++ b/src/Identity/Models/RedirectViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Identity.Models +{ + public class RedirectViewModel + { + public string RedirectUrl { get; set; } + } +} diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 0603cd4bb..d4ac92be3 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -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(); } + 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 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(); // 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(); + + 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() + .AddCustomTokenRequestValidator() + .AddProfileService() + .AddResourceOwnerValidator() + .AddPersistedGrantStore() + .AddClientStore() + .AddIdentityServerCertificate(env, globalSettings); + + services.AddTransient(); + return identityServerBuilder; + } } } diff --git a/src/Identity/Views/Shared/Redirect.cshtml b/src/Identity/Views/Shared/Redirect.cshtml new file mode 100644 index 000000000..ad7c2a36f --- /dev/null +++ b/src/Identity/Views/Shared/Redirect.cshtml @@ -0,0 +1,12 @@ +@model Bit.Identity.Models.RedirectViewModel + + + + + + +

You are now being returned to the application. Once complete, you may close this tab.

+ + diff --git a/src/Identity/appsettings.Production.json b/src/Identity/appsettings.Production.json index 5ea6892d0..6c4313dcd 100644 --- a/src/Identity/appsettings.Production.json +++ b/src/Identity/appsettings.Production.json @@ -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 diff --git a/src/Identity/appsettings.json b/src/Identity/appsettings.json index 79bcb615f..4f5e2082f 100644 --- a/src/Identity/appsettings.json +++ b/src/Identity/appsettings.json @@ -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" diff --git a/src/Notifications/appsettings.Production.json b/src/Notifications/appsettings.Production.json index c0717e38e..1f85555af 100644 --- a/src/Notifications/appsettings.Production.json +++ b/src/Notifications/appsettings.Production.json @@ -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" } } } diff --git a/src/Notifications/appsettings.json b/src/Notifications/appsettings.json index d8448c69a..029280a38 100644 --- a/src/Notifications/appsettings.json +++ b/src/Notifications/appsettings.json @@ -8,11 +8,13 @@ "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" diff --git a/util/Setup/EnvironmentFileBuilder.cs b/util/Setup/EnvironmentFileBuilder.cs index 94cd78b62..9086f78f2 100644 --- a/util/Setup/EnvironmentFileBuilder.cs +++ b/util/Setup/EnvironmentFileBuilder.cs @@ -26,12 +26,14 @@ namespace Bit.Setup ["globalSettings__baseServiceUri__api"] = "http://localhost/api", ["globalSettings__baseServiceUri__identity"] = "http://localhost/identity", ["globalSettings__baseServiceUri__admin"] = "http://localhost/admin", + ["globalSettings__baseServiceUri__sso"] = "http://localhost/sso", ["globalSettings__baseServiceUri__notifications"] = "http://localhost/notifications", ["globalSettings__baseServiceUri__internalNotifications"] = "http://notifications:5000", ["globalSettings__baseServiceUri__internalAdmin"] = "http://admin:5000", ["globalSettings__baseServiceUri__internalIdentity"] = "http://identity:5000", ["globalSettings__baseServiceUri__internalApi"] = "http://api:5000", ["globalSettings__baseServiceUri__internalVault"] = "http://web:5000", + ["globalSettings__baseServiceUri__internalSso"] = "http://sso:5000", ["globalSettings__pushRelayBaseUri"] = "https://push.bitwarden.com", ["globalSettings__installation__identityUri"] = "https://identity.bitwarden.com", }; @@ -89,6 +91,7 @@ namespace Bit.Setup ["globalSettings__baseServiceUri__identity"] = $"{_context.Config.Url}/identity", ["globalSettings__baseServiceUri__admin"] = $"{_context.Config.Url}/admin", ["globalSettings__baseServiceUri__notifications"] = $"{_context.Config.Url}/notifications", + ["globalSettings__baseServiceUri__sso"] = $"{_context.Config.Url}/sso", ["globalSettings__sqlServer__connectionString"] = $"\"{dbConnectionString}\"", ["globalSettings__identityServer__certificatePassword"] = _context.Install?.IdentityCertPassword, ["globalSettings__attachment__baseDirectory"] = $"{_context.OutputDir}/core/attachments", @@ -100,6 +103,8 @@ namespace Bit.Setup ["globalSettings__licenseDirectory"] = $"{_context.OutputDir}/core/licenses", ["globalSettings__internalIdentityKey"] = _context.Stub ? "RANDOM_IDENTITY_KEY" : Helpers.SecureRandomString(64, alpha: true, numeric: true), + ["globalSettings__oidcIdentityClientKey"] = _context.Stub ? "RANDOM_IDENTITY_KEY" : + Helpers.SecureRandomString(64, alpha: true, numeric: true), ["globalSettings__duo__aKey"] = _context.Stub ? "RANDOM_DUO_AKEY" : Helpers.SecureRandomString(64, alpha: true, numeric: true), ["globalSettings__installation__id"] = _context.Install?.InstallationId.ToString(), diff --git a/util/Setup/Templates/DockerCompose.hbs b/util/Setup/Templates/DockerCompose.hbs index 7cb2c44dc..0e5cbb6fc 100644 --- a/util/Setup/Templates/DockerCompose.hbs +++ b/util/Setup/Templates/DockerCompose.hbs @@ -87,6 +87,23 @@ services: - default - public + sso: + image: bitwarden/sso:{{{CoreVersion}}} + container_name: bitwarden-sso + restart: always + volumes: + - ../identity:/etc/bitwarden/identity + - ../core:/etc/bitwarden/core + - ../ca-certificates:/etc/bitwarden/ca-certificates + - ../logs/sso:/etc/bitwarden/logs + env_file: + - global.env + - ../env/uid.env + - ../env/global.override.env + networks: + - default + - public + admin: image: bitwarden/admin:{{{CoreVersion}}} container_name: bitwarden-admin diff --git a/util/Setup/Templates/NginxConfig.hbs b/util/Setup/Templates/NginxConfig.hbs index 28b9af906..b43db73a6 100644 --- a/util/Setup/Templates/NginxConfig.hbs +++ b/util/Setup/Templates/NginxConfig.hbs @@ -104,6 +104,10 @@ server { proxy_pass http://identity:5000/; } + location /sso/ { + proxy_pass http://sso:5000/; + } + location /icons/ { proxy_pass http://icons:5000/; }