1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-28 17:57:37 +01: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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1093 additions and 435 deletions

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

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

View File

@ -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)
{

View File

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

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

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

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

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

View File

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

View File

@ -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<AuthorizationCode>, IAuthorizationCodeStore
{
public AuthorizationCodeStore(
IPersistedGrantStore store,
IPersistentGrantSerializer serializer,
IHandleGenerationService handleGenerationService,
ILogger<DefaultAuthorizationCodeStore> logger)
: base(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode, store, serializer,
handleGenerationService, logger)
{ }
public Task<string> StoreAuthorizationCodeAsync(AuthorizationCode code)
{
return CreateItemAsync(code, code.ClientId, code.Subject.GetSubjectId(), code.CreationTime, code.Lifetime);
}
public Task<AuthorizationCode> 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);
}
}
}

View File

@ -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<T> where T : class
{
private UserManager<User> _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<ResourceOwnerPasswordValidator> _logger;
private readonly CurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
public BaseRequestValidator(
UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService,
IEventService eventService,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<ResourceOwnerPasswordValidator> 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<Claim>();
if (device != null)
{
claims.Add(new Claim("device", device.Identifier));
}
var customResponse = new Dictionary<string, object>();
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<byte>();
var providers = new Dictionary<byte, Dictionary<string, object>>();
var enabledProviders = new List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>();
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<string, object>
{
{ "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<string, object>
{{
"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<string, object> customResponse);
protected abstract void SetSuccessResult(T context, User user, List<Claim> claims,
Dictionary<string, object> customResponse);
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
private async Task<Tuple<bool, Organization>> 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<bool, Organization>(individualRequired || firstEnabledOrg != null, firstEnabledOrg);
}
private bool OrgUsing2fa(IDictionary<Guid, OrganizationAbility> 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<bool> 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<Dictionary<string, object>> 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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["Email"] = token
};
}
else if (type == TwoFactorProviderType.YubiKey)
{
return new Dictionary<string, object>
{
["Nfc"] = (bool)provider.MetaData["Nfc"]
};
}
return null;
case TwoFactorProviderType.OrganizationDuo:
if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
{
return new Dictionary<string, object>
{
["Host"] = provider.MetaData["Host"],
["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user)
};
}
return null;
default:
return null;
}
}
private async Task<Device> 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<DisplayAttribute>()?.GetName();
if (!_globalSettings.DisableEmailNewDevice)
{
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
_currentContext.IpAddress);
}
}
return device;
}
return existingDevice;
}
return null;
}
}
}

View File

@ -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<CustomTokenRequestValidationContext>,
ICustomTokenRequestValidator
{
private UserManager<User> _userManager;
public CustomTokenRequestValidator(
UserManager<User> userManager,
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService,
IEventService eventService,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<ResourceOwnerPasswordValidator> 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<Claim> claims, Dictionary<string, object> 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<string, object> 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<string, object> customResponse)
{
context.Result.Error = "invalid_grant";
context.Result.IsError = true;
context.Result.CustomResponse = customResponse;
}
}
}

View File

@ -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<ResourceOwnerPasswordValidationContext>,
IResourceOwnerPasswordValidator
{
private UserManager<User> _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<ResourceOwnerPasswordValidator> _logger;
private readonly CurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
public ResourceOwnerPasswordValidator(
UserManager<User> userManager,
@ -51,366 +32,55 @@ namespace Bit.Core.IdentityServer
ILogger<ResourceOwnerPasswordValidator> 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<Claim> claims, Dictionary<string, object> customResponse)
{
await _eventService.LogUserEventAsync(user.Id, EventType.User_LoggedIn);
var claims = new List<Claim>();
if (device != null)
{
claims.Add(new Claim("device", device.Identifier));
}
var customResponse = new Dictionary<string, object>();
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<string, object> customResponse)
{
var providerKeys = new List<byte>();
var providers = new Dictionary<byte, Dictionary<string, object>>();
var enabledProviders = new List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>();
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<string, object>
{
{ "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<string, object> 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<string, object>
{{
"ErrorModel", new ErrorResponseModel(twoFactorRequest ?
"Two-step token is invalid. Try again." : "Username or password is incorrect. Try again.")
}});
}
public async Task<Tuple<bool, Organization>> 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<bool, Organization>(individualRequired || firstEnabledOrg != null, firstEnabledOrg);
}
private bool OrgUsing2fa(IDictionary<Guid, OrganizationAbility> 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<bool> 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<Dictionary<string, object>> 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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["Email"] = token
};
}
else if (type == TwoFactorProviderType.YubiKey)
{
return new Dictionary<string, object>
{
["Nfc"] = (bool)provider.MetaData["Nfc"]
};
}
return null;
case TwoFactorProviderType.OrganizationDuo:
if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
{
return new Dictionary<string, object>
{
["Host"] = provider.MetaData["Host"],
["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user)
};
}
return null;
default:
return null;
}
}
private async Task<Device> 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<DisplayAttribute>()?.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);
}
}
}

View File

@ -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<Secret> { new Secret(globalSettings.OidcIdentityClientKey.Sha256()) };
AllowedScopes = new string[]
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
};
AllowedGrantTypes = GrantTypes.Code;
Enabled = true;
RedirectUris = new List<string> { $"{globalSettings.BaseServiceUri.Identity}/signin-oidc" };
RequireConsent = false;
}
}
}
}

View File

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

View File

@ -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<ClientStore>();
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<ClientStore>();
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
services.AddScoped<IResourceOwnerPasswordValidator, ResourceOwnerPasswordValidator>();
services.AddScoped<IProfileService, ProfileService>();
services.AddSingleton<IPersistedGrantStore, PersistedGrantStore>();
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)
{

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

View File

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

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"

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

View File

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

View File

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

View File

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

View File

@ -104,6 +104,10 @@ server {
proxy_pass http://identity:5000/;
}
location /sso/ {
proxy_pass http://sso:5000/;
}
location /icons/ {
proxy_pass http://icons:5000/;
}