mirror of
https://github.com/bitwarden/server.git
synced 2024-12-27 17:47: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:
parent
2742b414fd
commit
0d0c6c7167
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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<TaxInfoResponseModel> 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)
|
||||
|
@ -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");
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
44
src/Core/IdentityServer/AuthorizationCodeStore.cs
Normal file
44
src/Core/IdentityServer/AuthorizationCodeStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
416
src/Core/IdentityServer/BaseRequestValidator.cs
Normal file
416
src/Core/IdentityServer/BaseRequestValidator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
89
src/Core/IdentityServer/CustomTokenRequestValidator.cs
Normal file
89
src/Core/IdentityServer/CustomTokenRequestValidator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
227
src/Identity/Controllers/AccountController.cs
Normal file
227
src/Identity/Controllers/AccountController.cs
Normal file
@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Identity.Models;
|
||||
using IdentityModel;
|
||||
using IdentityServer4.Services;
|
||||
using IdentityServer4.Stores;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Identity.Controllers
|
||||
{
|
||||
public class AccountController : Controller
|
||||
{
|
||||
private readonly IIdentityServerInteractionService _interaction;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IClientStore _clientStore;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
IIdentityServerInteractionService interaction,
|
||||
IUserRepository userRepository,
|
||||
IClientStore clientStore,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_interaction = interaction;
|
||||
_userRepository = userRepository;
|
||||
_clientStore = clientStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Login(string returnUrl)
|
||||
{
|
||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||
if (context.Parameters.AllKeys.Contains("domain_hint") &&
|
||||
!string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
|
||||
{
|
||||
return RedirectToAction(nameof(ExternalChallenge),
|
||||
new { organizationIdentifier = context.Parameters["domain_hint"], returnUrl = returnUrl });
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("No domain_hint provided.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult ExternalChallenge(string organizationIdentifier, string returnUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(organizationIdentifier))
|
||||
{
|
||||
throw new Exception("Invalid organization reference id.");
|
||||
}
|
||||
|
||||
// TODO: Lookup sso config and create a domain hint
|
||||
var domainHint = "oidc_okta";
|
||||
// Temp hardcoded orgs
|
||||
if (organizationIdentifier == "org_oidc_okta")
|
||||
{
|
||||
domainHint = "oidc_okta";
|
||||
}
|
||||
else if (organizationIdentifier == "org_oidc_onelogin")
|
||||
{
|
||||
domainHint = "oidc_onelogin";
|
||||
}
|
||||
else if (organizationIdentifier == "org_saml2_onelogin")
|
||||
{
|
||||
domainHint = "saml2_onelogin";
|
||||
}
|
||||
else if (organizationIdentifier == "org_saml2_sustainsys")
|
||||
{
|
||||
domainHint = "saml2_sustainsys";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Organization not found.");
|
||||
}
|
||||
|
||||
var provider = "sso";
|
||||
var props = new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = Url.Action(nameof(ExternalCallback)),
|
||||
Items =
|
||||
{
|
||||
{ "return_url", returnUrl },
|
||||
{ "domain_hint", domainHint },
|
||||
{ "scheme", provider },
|
||||
},
|
||||
};
|
||||
|
||||
return Challenge(props, provider);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> ExternalCallback()
|
||||
{
|
||||
// Read external identity from the temporary cookie
|
||||
var result = await HttpContext.AuthenticateAsync(
|
||||
IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
|
||||
if (result?.Succeeded != true)
|
||||
{
|
||||
throw new Exception("External authentication error");
|
||||
}
|
||||
|
||||
// Debugging
|
||||
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
|
||||
_logger.LogDebug("External claims: {@claims}", externalClaims);
|
||||
|
||||
var (user, provider, providerUserId, claims) = await FindUserFromExternalProviderAsync(result);
|
||||
if (user == null)
|
||||
{
|
||||
// Should never happen
|
||||
throw new Exception("Cannot find user.");
|
||||
}
|
||||
|
||||
// this allows us to collect any additonal claims or properties
|
||||
// for the specific prtotocols used and store them in the local auth cookie.
|
||||
// this is typically used to store data needed for signout from those protocols.
|
||||
var additionalLocalClaims = new List<Claim>();
|
||||
var localSignInProps = new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
|
||||
};
|
||||
ProcessLoginCallbackForOidc(result, additionalLocalClaims, localSignInProps);
|
||||
|
||||
// issue authentication cookie for user
|
||||
await HttpContext.SignInAsync(user.Id.ToString(), user.Email, provider,
|
||||
localSignInProps, additionalLocalClaims.ToArray());
|
||||
|
||||
// delete temporary cookie used during external authentication
|
||||
await HttpContext.SignOutAsync(IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
|
||||
|
||||
// retrieve return URL
|
||||
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
|
||||
|
||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||
if (context != null)
|
||||
{
|
||||
if (await IsPkceClientAsync(context.ClientId))
|
||||
{
|
||||
// if the client is PKCE then we assume it's native, so this change in how to
|
||||
// return the response is for better UX for the end user.
|
||||
return View("Redirect", new RedirectViewModel { RedirectUrl = returnUrl });
|
||||
}
|
||||
|
||||
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
|
||||
// request for a local page
|
||||
if (Url.IsLocalUrl(returnUrl))
|
||||
{
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
else if (string.IsNullOrEmpty(returnUrl))
|
||||
{
|
||||
return Redirect("~/");
|
||||
}
|
||||
else
|
||||
{
|
||||
// user might have clicked on a malicious link - should be logged
|
||||
throw new Exception("invalid return URL");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims)>
|
||||
FindUserFromExternalProviderAsync(AuthenticateResult result)
|
||||
{
|
||||
var externalUser = result.Principal;
|
||||
|
||||
// try to determine the unique id of the external user (issued by the provider)
|
||||
// the most common claim type for that are the sub claim and the NameIdentifier
|
||||
// depending on the external provider, some other claim type might be used
|
||||
var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ??
|
||||
externalUser.FindFirst(ClaimTypes.NameIdentifier) ??
|
||||
throw new Exception("Unknown userid");
|
||||
|
||||
// remove the user id claim so we don't include it as an extra claim if/when we provision the user
|
||||
var claims = externalUser.Claims.ToList();
|
||||
claims.Remove(userIdClaim);
|
||||
|
||||
var provider = result.Properties.Items["scheme"];
|
||||
var providerUserId = userIdClaim.Value;
|
||||
var user = await _userRepository.GetByIdAsync(new Guid(providerUserId));
|
||||
|
||||
return (user, provider, providerUserId, claims);
|
||||
}
|
||||
|
||||
private void ProcessLoginCallbackForOidc(AuthenticateResult externalResult,
|
||||
List<Claim> localClaims, AuthenticationProperties localSignInProps)
|
||||
{
|
||||
// if the external system sent a session id claim, copy it over
|
||||
// so we can use it for single sign-out
|
||||
var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
|
||||
if (sid != null)
|
||||
{
|
||||
localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
|
||||
}
|
||||
|
||||
// if the external provider issued an id_token, we'll keep it for signout
|
||||
var id_token = externalResult.Properties.GetTokenValue("id_token");
|
||||
if (id_token != null)
|
||||
{
|
||||
localSignInProps.StoreTokens(
|
||||
new[] { new AuthenticationToken { Name = "id_token", Value = id_token } });
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsPkceClientAsync(string client_id)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(client_id))
|
||||
{
|
||||
var client = await _clientStore.FindEnabledClientByIdAsync(client_id);
|
||||
return client?.RequirePkce == true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
7
src/Identity/Models/RedirectViewModel.cs
Normal file
7
src/Identity/Models/RedirectViewModel.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Identity.Models
|
||||
{
|
||||
public class RedirectViewModel
|
||||
{
|
||||
public string RedirectUrl { get; set; }
|
||||
}
|
||||
}
|
@ -9,6 +9,12 @@ using AspNetCoreRateLimit;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Threading.Tasks;
|
||||
using IdentityServer4.Stores;
|
||||
using Bit.Core.IdentityServer;
|
||||
using IdentityServer4.Services;
|
||||
|
||||
namespace Bit.Identity
|
||||
{
|
||||
@ -49,6 +55,9 @@ namespace Bit.Identity
|
||||
// Caching
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Mvc
|
||||
services.AddMvc();
|
||||
|
||||
if (!globalSettings.SelfHosted)
|
||||
{
|
||||
// Rate limiting
|
||||
@ -56,8 +65,35 @@ namespace Bit.Identity
|
||||
services.AddSingleton<IRateLimitCounterStore, MemoryCacheRateLimitCounterStore>();
|
||||
}
|
||||
|
||||
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
|
||||
|
||||
// Authentication
|
||||
services
|
||||
.AddAuthentication()
|
||||
.AddOpenIdConnect("sso", "Single Sign On", options =>
|
||||
{
|
||||
options.Authority = globalSettings.BaseServiceUri.InternalSso;
|
||||
options.RequireHttpsMetadata = !Environment.IsDevelopment() &&
|
||||
globalSettings.BaseServiceUri.InternalIdentity.StartsWith("https");
|
||||
options.ClientId = "oidc-identity";
|
||||
options.ClientSecret = globalSettings.OidcIdentityClientKey;
|
||||
|
||||
options.SignInScheme = IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme;
|
||||
options.ResponseType = "code";
|
||||
|
||||
options.Events = new Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents
|
||||
{
|
||||
OnRedirectToIdentityProvider = context =>
|
||||
{
|
||||
// Pass domain_hint onto the sso idp
|
||||
context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"];
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// IdentityServer
|
||||
services.AddCustomIdentityServerServices(Environment, globalSettings);
|
||||
AddCustomIdentityServerServices(services, Environment, globalSettings);
|
||||
|
||||
// Identity
|
||||
services.AddCustomIdentityServices(globalSettings);
|
||||
@ -80,6 +116,8 @@ namespace Bit.Identity
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Default Middleware
|
||||
@ -95,14 +133,58 @@ namespace Bit.Identity
|
||||
app.UseForwardedHeaders(globalSettings);
|
||||
}
|
||||
|
||||
// Add static files to the request pipeline.
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Add routing
|
||||
app.UseRouting();
|
||||
|
||||
// Add Cors
|
||||
app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings))
|
||||
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
|
||||
|
||||
// Add current context
|
||||
app.UseMiddleware<CurrentContextMiddleware>();
|
||||
|
||||
// Add IdentityServer to the request pipeline.
|
||||
app.UseIdentityServer();
|
||||
|
||||
// Add Mvc stuff
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
|
||||
// Log startup
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
|
||||
}
|
||||
|
||||
public static IIdentityServerBuilder AddCustomIdentityServerServices(IServiceCollection services,
|
||||
IWebHostEnvironment env, GlobalSettings globalSettings)
|
||||
{
|
||||
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
|
||||
|
||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
||||
var identityServerBuilder = services
|
||||
.AddIdentityServer(options =>
|
||||
{
|
||||
options.Endpoints.EnableIntrospectionEndpoint = false;
|
||||
options.Endpoints.EnableEndSessionEndpoint = false;
|
||||
options.Endpoints.EnableUserInfoEndpoint = false;
|
||||
options.Endpoints.EnableCheckSessionEndpoint = false;
|
||||
options.Endpoints.EnableTokenRevocationEndpoint = false;
|
||||
options.IssuerUri = $"{issuerUri.Scheme}://{issuerUri.Host}";
|
||||
options.Caching.ClientStoreExpiration = new TimeSpan(0, 5, 0);
|
||||
})
|
||||
.AddInMemoryCaching()
|
||||
.AddInMemoryApiResources(ApiResources.GetApiResources())
|
||||
.AddClientStoreCache<ClientStore>()
|
||||
.AddCustomTokenRequestValidator<CustomTokenRequestValidator>()
|
||||
.AddProfileService<ProfileService>()
|
||||
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
|
||||
.AddPersistedGrantStore<PersistedGrantStore>()
|
||||
.AddClientStore<ClientStore>()
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
|
||||
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
|
||||
return identityServerBuilder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
src/Identity/Views/Shared/Redirect.cshtml
Normal file
12
src/Identity/Views/Shared/Redirect.cshtml
Normal file
@ -0,0 +1,12 @@
|
||||
@model Bit.Identity.Models.RedirectViewModel
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;url=@Model.RedirectUrl" data-url="@Model.RedirectUrl">
|
||||
<script>
|
||||
window.location.href = document.querySelector("meta[http-equiv=refresh]").getAttribute("data-url");
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>You are now being returned to the application. Once complete, you may close this tab.</p>
|
||||
</body>
|
||||
</html>
|
@ -6,11 +6,13 @@
|
||||
"identity": "https://identity.bitwarden.com",
|
||||
"admin": "https://admin.bitwarden.com",
|
||||
"notifications": "https://notifications.bitwarden.com",
|
||||
"sso": "https://sso.bitwarden.com",
|
||||
"internalNotifications": "https://notifications.bitwarden.com",
|
||||
"internalAdmin": "https://admin.bitwarden.com",
|
||||
"internalIdentity": "https://identity.bitwarden.com",
|
||||
"internalApi": "https://api.bitwarden.com",
|
||||
"internalVault": "https://vault.bitwarden.com"
|
||||
"internalVault": "https://vault.bitwarden.com",
|
||||
"internalSso": "https://sso.bitwarden.com"
|
||||
},
|
||||
"braintree": {
|
||||
"production": true
|
||||
|
@ -4,17 +4,20 @@
|
||||
"siteName": "Bitwarden",
|
||||
"projectName": "Identity",
|
||||
"stripeApiKey": "SECRET",
|
||||
"oidcIdentityClientKey": "SECRET",
|
||||
"baseServiceUri": {
|
||||
"vault": "https://localhost:8080",
|
||||
"api": "http://localhost:4000",
|
||||
"identity": "http://localhost:33656",
|
||||
"admin": "http://localhost:62911",
|
||||
"notifications": "http://localhost:61840",
|
||||
"sso": "http://localhost:51822",
|
||||
"internalNotifications": "http://localhost:61840",
|
||||
"internalAdmin": "http://localhost:62911",
|
||||
"internalIdentity": "http://localhost:33656",
|
||||
"internalApi": "http://localhost:4000",
|
||||
"internalVault": "http://localhost:4001"
|
||||
"internalVault": "http://localhost:4001",
|
||||
"internalSso": "http://localhost:51822"
|
||||
},
|
||||
"sqlServer": {
|
||||
"connectionString": "SECRET"
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -104,6 +104,10 @@ server {
|
||||
proxy_pass http://identity:5000/;
|
||||
}
|
||||
|
||||
location /sso/ {
|
||||
proxy_pass http://sso:5000/;
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
proxy_pass http://icons:5000/;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user