Fixing U4-10138 Cannot upgrade to 7.7 due to user groups and U4-7907 With non OAuth external login providers we should have an 'auto-link' / 'auto-create' callback option

This commit is contained in:
Shannon
2017-07-18 19:53:34 +10:00
parent 32dc9bd275
commit 73b107ee2a
13 changed files with 448 additions and 49 deletions

View File

@@ -3,15 +3,17 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Umbraco.Core.Models.EntityBase;
using Umbraco.Core.Models.Membership;
using Umbraco.Core.Security;
namespace Umbraco.Core.Models.Identity
{
public class BackOfficeIdentityUser : IdentityUser<int, IIdentityUserLogin, IdentityUserRole<string>, IdentityUserClaim<int>>
public class BackOfficeIdentityUser : IdentityUser<int, IIdentityUserLogin, IdentityUserRole<string>, IdentityUserClaim<int>>, IRememberBeingDirty
{
public BackOfficeIdentityUser()
@@ -33,6 +35,24 @@ namespace Umbraco.Core.Models.Identity
var userIdentity = await manager.CreateIdentityAsync(this, Constants.Security.BackOfficeAuthenticationType);
return userIdentity;
}
/// <summary>
/// Override Email so we can track changes to it
/// </summary>
public override string Email
{
get { return _email; }
set { _tracker.SetPropertyValueAndDetectChanges(value, ref _email, Ps.Value.EmailSelector); }
}
/// <summary>
/// Override UserName so we can track changes to it
/// </summary>
public override string UserName
{
get { return _userName; }
set { _tracker.SetPropertyValueAndDetectChanges(value, ref _userName, Ps.Value.UsernameSelector); }
}
/// <summary>
/// Gets/sets the user's real name
@@ -58,7 +78,7 @@ namespace Umbraco.Core.Models.Identity
public override bool LockoutEnabled
{
get { return true; }
set
set
{
//do nothing
}
@@ -132,5 +152,74 @@ namespace Umbraco.Core.Models.Identity
{
get { return _allStartMediaIds ?? (_allStartMediaIds = StartMediaIds.Concat(Groups.Where(x => x.StartMediaId.HasValue).Select(x => x.StartMediaId.Value)).Distinct().ToArray()); }
}
#region Change tracking
/// <summary>
/// Since this class only has change tracking turned on for Email/Username this will return true if either of those have changed
/// </summary>
/// <returns></returns>
bool ICanBeDirty.IsDirty()
{
return _tracker.IsDirty();
}
/// <summary>
/// Returns true if the specified property is dirty
/// </summary>
/// <param name="propName"></param>
/// <returns></returns>
bool ICanBeDirty.IsPropertyDirty(string propName)
{
return _tracker.IsPropertyDirty(propName);
}
/// <summary>
/// Resets dirty properties
/// </summary>
void ICanBeDirty.ResetDirtyProperties()
{
_tracker.ResetDirtyProperties();
}
bool IRememberBeingDirty.WasDirty()
{
return _tracker.WasDirty();
}
bool IRememberBeingDirty.WasPropertyDirty(string propertyName)
{
return _tracker.WasPropertyDirty(propertyName);
}
void IRememberBeingDirty.ForgetPreviouslyDirtyProperties()
{
_tracker.ForgetPreviouslyDirtyProperties();
}
void IRememberBeingDirty.ResetDirtyProperties(bool rememberPreviouslyChangedProperties)
{
_tracker.ResetDirtyProperties(rememberPreviouslyChangedProperties);
}
private static readonly Lazy<PropertySelectors> Ps = new Lazy<PropertySelectors>();
private class PropertySelectors
{
public readonly PropertyInfo EmailSelector = ExpressionHelper.GetPropertyInfo<BackOfficeIdentityUser, string>(x => x.Email);
public readonly PropertyInfo UsernameSelector = ExpressionHelper.GetPropertyInfo<BackOfficeIdentityUser, string>(x => x.UserName);
}
private readonly ChangeTracker _tracker = new ChangeTracker();
private string _email;
private string _userName;
/// <summary>
/// internal class used to track changes for properties that have it enabled
/// </summary>
private class ChangeTracker : TracksChangesEntityBase
{
}
#endregion
}
}

View File

@@ -55,8 +55,35 @@ namespace Umbraco.Core.Persistence.Repositories
/// <returns></returns>
IEnumerable<IUser> GetPagedResultsByQuery(IQuery<IUser> query, long pageIndex, int pageSize, out long totalRecords, Expression<Func<IUser, object>> orderBy, Direction orderDirection, string[] userGroups = null, UserState[] userState = null, IQuery<IUser> filter = null);
/// <summary>
/// Returns a user by username
/// </summary>
/// <param name="username"></param>
/// <param name="includeSecurityData">
/// This is really only used for a shim in order to upgrade to 7.6 but could be used
/// for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes)
/// </param>
/// <returns>
/// A non cached <see cref="IUser"/> instance
/// </returns>
IUser GetByUsername(string username, bool includeSecurityData);
/// <summary>
/// Returns a user by id
/// </summary>
/// <param name="id"></param>
/// <param name="includeSecurityData">
/// This is really only used for a shim in order to upgrade to 7.6 but could be used
/// for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes)
/// </param>
/// <returns>
/// A non cached <see cref="IUser"/> instance
/// </returns>
IUser Get(int id, bool includeSecurityData);
IProfile GetProfile(string username);
IProfile GetProfile(int id);
IDictionary<UserState, int> GetUserStates();
IDictionary<UserState, int> GetUserStates();
}
}

View File

@@ -52,6 +52,88 @@ namespace Umbraco.Core.Persistence.Repositories
return user;
}
/// <summary>
/// Returns a user by username
/// </summary>
/// <param name="username"></param>
/// <param name="includeSecurityData">
/// Can be used for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes).
/// This is really only used for a shim in order to upgrade to 7.6.
/// </param>
/// <returns>
/// A non cached <see cref="IUser"/> instance
/// </returns>
public IUser GetByUsername(string username, bool includeSecurityData)
{
UserDto dto;
if (includeSecurityData)
{
var sql = GetQueryWithGroups();
sql.Where<UserDto>(userDto => userDto.Login == username, SqlSyntax);
sql //must be included for relator to work
.OrderBy<UserDto>(d => d.Id, SqlSyntax)
.OrderBy<UserGroupDto>(d => d.Id, SqlSyntax)
.OrderBy<UserStartNodeDto>(d => d.Id, SqlSyntax);
dto = Database
.Fetch<UserDto, UserGroupDto, UserGroup2AppDto, UserStartNodeDto, UserDto>(
new UserGroupRelator().Map, sql)
.FirstOrDefault();
}
else
{
var sql = GetBaseQuery("umbracoUser.*");
sql.Where<UserDto>(userDto => userDto.Login == username, SqlSyntax);
dto = Database.FirstOrDefault<UserDto>(sql);
}
if (dto == null)
return null;
var user = UserFactory.BuildEntity(dto);
return user;
}
/// <summary>
/// Returns a user by id
/// </summary>
/// <param name="id"></param>
/// <param name="includeSecurityData">
/// This is really only used for a shim in order to upgrade to 7.6 but could be used
/// for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes)
/// </param>
/// <returns>
/// A non cached <see cref="IUser"/> instance
/// </returns>
public IUser Get(int id, bool includeSecurityData)
{
UserDto dto;
if (includeSecurityData)
{
var sql = GetQueryWithGroups();
sql.Where(GetBaseWhereClause(), new { Id = id });
sql //must be included for relator to work
.OrderBy<UserDto>(d => d.Id, SqlSyntax)
.OrderBy<UserGroupDto>(d => d.Id, SqlSyntax)
.OrderBy<UserStartNodeDto>(d => d.Id, SqlSyntax);
dto = Database
.Fetch<UserDto, UserGroupDto, UserGroup2AppDto, UserStartNodeDto, UserDto>(
new UserGroupRelator().Map, sql)
.FirstOrDefault();
}
else
{
var sql = GetBaseQuery("umbracoUser.*");
sql.Where(GetBaseWhereClause(), new { Id = id });
dto = Database.FirstOrDefault<UserDto>(sql);
}
if (dto == null)
return null;
var user = UserFactory.BuildEntity(dto);
return user;
}
public IProfile GetProfile(string username)
{
var sql = GetBaseQuery(false).Where<UserDto>(userDto => userDto.UserName == username, SqlSyntax);

View File

@@ -101,20 +101,40 @@ namespace Umbraco.Core.Security
{
return SignInStatus.Failure;
}
var user = await UserManager.FindByNameAsync(userName);
var backOfficeUserMgr = UserManager as BackOfficeUserManager<BackOfficeIdentityUser>;
var user = backOfficeUserMgr != null
//this will be a slightly faster lookup since we don't need the security data here (and works for upgrading to 7.6)
//it's worth mentioning here that getting a user by login name is never cached so we don't need to worry about that here
? await backOfficeUserMgr.FindByNameAsync(userName, includeSecurityData:false)
//load normally - this would only be the case if someone has totally replaced the user manager
: await UserManager.FindByNameAsync(userName);
//if the user is null, create an empty one which can be used for auto-linking
if (user == null)
{
return SignInStatus.Failure;
}
if (await UserManager.IsLockedOutAsync(user.Id))
{
return SignInStatus.LockedOut;
user = new BackOfficeIdentityUser
{
UserName = userName,
Culture = GlobalSettings.DefaultUILanguage
};
}
//check the password for the user, this will allow a developer to auto-link
//an account if they have specified an IBackOfficeUserPasswordChecker
if (await UserManager.CheckPasswordAsync(user, password))
{
//the underlying call to this will query the user by Id which IS cached!
if (await UserManager.IsLockedOutAsync(user.Id))
{
return SignInStatus.LockedOut;
}
await UserManager.ResetAccessFailedCountAsync(user.Id);
return await SignInOrTwoFactor(user, isPersistent);
}
if (shouldLockout)
{
// If lockout is requested, increment access failed count which might lock out the user
@@ -125,7 +145,7 @@ namespace Umbraco.Core.Security
}
}
return SignInStatus.Failure;
}
}
/// <summary>
/// Borrowed from Micorosoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type

View File

@@ -146,7 +146,7 @@ namespace Umbraco.Core.Security
IDataProtectionProvider dataProtectionProvider)
{
// Configure validation logic for usernames
manager.UserValidator = new UserValidator<T, int>(manager)
manager.UserValidator = new BackOfficeUserValidator<T>(manager)
{
AllowOnlyAlphanumericUserNames = false,
RequireUniqueEmail = true
@@ -192,6 +192,32 @@ namespace Umbraco.Core.Security
//manager.SmsService = new SmsService();
}
/// <summary>
/// Looks up a <see cref="BackOfficeIdentityUser"/> by username
/// </summary>
/// <param name="userName"></param>
/// <param name="includeSecurityData">
/// Can be used for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes).
/// This is really only used for a shim in order to upgrade to 7.6.
/// </param>
/// <returns></returns>
public async Task<T> FindByNameAsync(string userName, bool includeSecurityData)
{
T result;
if (includeSecurityData)
{
result = await Store.FindByNameAsync(userName);
return result;
}
var backOfficeUserStore = Store as BackOfficeUserStore;
if (backOfficeUserStore == null)
throw new InvalidOperationException("A custom IUserStore is in use which does not support querying users without security data");
result = (T)await backOfficeUserStore.FindByNameAsync(userName, false);
return result;
}
/// <summary>
/// Logic used to validate a username and password
@@ -266,5 +292,6 @@ namespace Umbraco.Core.Security
}
throw new NotSupportedException("Cannot generate a password since the type of the password validator (" + PasswordValidator.GetType() + ") is not known");
}
}
}

View File

@@ -8,9 +8,11 @@ using System.Web.Security;
using AutoMapper;
using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Umbraco.Core.Models.EntityBase;
using Umbraco.Core.Models.Identity;
using Umbraco.Core.Models.Membership;
using Umbraco.Core.Services;
using IUser = Umbraco.Core.Models.Membership.IUser;
namespace Umbraco.Core.Security
{
@@ -190,6 +192,29 @@ namespace Umbraco.Core.Security
return await Task.FromResult(result);
}
/// <summary>
/// Looks up a <see cref="BackOfficeIdentityUser"/> by username
/// </summary>
/// <param name="userName"></param>
/// <param name="includeSecurityData">
/// Can be used for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes).
/// This is really only used for a shim in order to upgrade to 7.6.
/// </param>
/// <returns></returns>
public virtual async Task<BackOfficeIdentityUser> FindByNameAsync(string userName, bool includeSecurityData)
{
ThrowIfDisposed();
var user = _userService.GetByUsername(userName, includeSecurityData);
if (user == null)
{
return null;
}
var result = AssignLoginsCallback(Mapper.Map<BackOfficeIdentityUser>(user));
return await Task.FromResult(result);
}
/// <summary>
/// Set the user password hash
/// </summary>
@@ -367,12 +392,17 @@ namespace Umbraco.Core.Security
var result = _externalLoginService.Find(login).ToArray();
if (result.Any())
{
//return the first member that matches the result
var output = (from l in result
select _userService.GetUserById(l.UserId)
into user
where user != null
select Mapper.Map<BackOfficeIdentityUser>(user)).FirstOrDefault();
//return the first user that matches the result
BackOfficeIdentityUser output = null;
foreach (var l in result)
{
var user = _userService.GetUserById(l.UserId);
if (user != null)
{
output = Mapper.Map<BackOfficeIdentityUser>(user);
break;
}
}
return Task.FromResult(AssignLoginsCallback(output));
}
@@ -717,6 +747,9 @@ namespace Umbraco.Core.Security
}
}
//reset all changes
((IRememberBeingDirty) identityUser).ResetDirtyProperties(false);
return anythingChanged;
}
@@ -726,7 +759,6 @@ namespace Umbraco.Core.Security
if (_disposed)
throw new ObjectDisposedException(GetType().Name);
}
}
}

View File

@@ -0,0 +1,29 @@
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Umbraco.Core.Models.EntityBase;
using Umbraco.Core.Models.Identity;
namespace Umbraco.Core.Security
{
/// <summary>
/// Custom validator to not validate a user's username or email if they haven't changed
/// </summary>
/// <typeparam name="T"></typeparam>
internal class BackOfficeUserValidator<T> : UserValidator<T, int>
where T : BackOfficeIdentityUser
{
public BackOfficeUserValidator(UserManager<T, int> manager) : base(manager)
{
}
public override async Task<IdentityResult> ValidateAsync(T item)
{
//Don't validate if the user's email or username hasn't changed otherwise it's just wasting SQL queries.
if (((ICanBeDirty)item).IsDirty())
{
return await base.ValidateAsync(item);
}
return IdentityResult.Success;
}
}
}

View File

@@ -9,6 +9,17 @@ namespace Umbraco.Core.Security
/// </summary>
public interface IBackOfficeUserPasswordChecker
{
/// <summary>
/// Checks a password for a user
/// </summary>
/// <param name="user"></param>
/// <param name="password"></param>
/// <returns></returns>
/// <remarks>
/// This will allow a developer to auto-link a local account which is required if the user queried doesn't exist locally.
/// The user parameter will always contain the username, if the user doesn't exist locally, the other properties will not be filled in.
/// A developer can then create a local account by filling in the properties and using UserManager.CreateAsync
/// </remarks>
Task<BackOfficeUserPasswordCheckerResult> CheckPasswordAsync(BackOfficeIdentityUser user, string password);
}
}

View File

@@ -69,7 +69,20 @@ namespace Umbraco.Core.Services
/// </summary>
/// <param name="id">Id of the user to retrieve</param>
/// <returns><see cref="IUser"/></returns>
IUser GetUserById(int id);
IUser GetUserById(int id);
/// <summary>
/// Get an <see cref="IUser"/> by username
/// </summary>
/// <param name="username">Username to use for retrieval</param>
/// <param name="includeSecurityData">
/// Can be used for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes).
/// This is really only used for a shim in order to upgrade to 7.6.
/// </param>
/// <returns>
/// A non cached <see cref="IUser"/> instance
/// </returns>
IUser GetByUsername(string username, bool includeSecurityData);
/// <summary>
/// Gets a users by Id

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Data.SqlClient;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
@@ -197,11 +198,49 @@ namespace Umbraco.Core.Services
using (var uow = UowProvider.GetUnitOfWork(readOnly: true))
{
var repository = RepositoryFactory.CreateUserRepository(uow);
var query = Query<IUser>.Builder.Where(x => x.Username.Equals(username));
return repository.GetByQuery(query).FirstOrDefault();
try
{
return repository.GetByUsername(username, includeSecurityData: true);
}
catch (SqlException ex)
{
//we need to handle this one specific case which is when we are upgrading to 7.6 since the user group
//tables don't exist yet. This is the 'easiest' way to deal with this without having to create special
//version checks in the BackOfficeSignInManager and calling into other special overloads that we'd need
//like "GetUserById(int id, bool includeSecurityData)" which may cause confusion because the result of
//that method would not be cached.
if (ApplicationContext.Current.IsUpgrading)
{
//NOTE: this will not be cached
return repository.GetByUsername(username, includeSecurityData: false);
}
throw;
}
}
}
/// <summary>
/// Get an <see cref="IUser"/> by username
/// </summary>
/// <param name="username">Username to use for retrieval</param>
/// <param name="includeSecurityData">
/// Can be used for slightly faster user lookups if the result doesn't require security data (i.e. groups, apps & start nodes).
/// This is really only used for a shim in order to upgrade to 7.6.
/// </param>
/// <returns>
/// A non cached <see cref="IUser"/> instance
/// </returns>
public IUser GetByUsername(string username, bool includeSecurityData)
{
using (var uow = UowProvider.GetUnitOfWork(readOnly: true))
{
var repository = RepositoryFactory.CreateUserRepository(uow);
return repository.GetByUsername(username, includeSecurityData);
}
}
/// <summary>
/// Deletes an <see cref="IUser"/>
/// </summary>
@@ -661,9 +700,26 @@ namespace Umbraco.Core.Services
using (var uow = UowProvider.GetUnitOfWork(readOnly: true))
{
var repository = RepositoryFactory.CreateUserRepository(uow);
return repository.Get(id);
try
{
return repository.Get(id);
}
catch (SqlException ex)
{
//we need to handle this one specific case which is when we are upgrading to 7.6 since the user group
//tables don't exist yet. This is the 'easiest' way to deal with this without having to create special
//version checks in the BackOfficeSignInManager and calling into other special overloads that we'd need
//like "GetUserById(int id, bool includeSecurityData)" which may cause confusion because the result of
//that method would not be cached.
if (ApplicationContext.Current.IsUpgrading)
{
//NOTE: this will not be cached
return repository.Get(id, includeSecurityData: false);
}
throw;
}
}
}
}
public IEnumerable<IUser> GetUsersById(params int[] ids)
{

View File

@@ -661,6 +661,7 @@
<Compile Include="Security\BackOfficeUserManagerMarker.cs" />
<Compile Include="Security\BackOfficeUserPasswordCheckerResult.cs" />
<Compile Include="Security\BackOfficeUserStore.cs" />
<Compile Include="Security\BackOfficeUserValidator.cs" />
<Compile Include="Security\IBackOfficeUserManagerMarker.cs" />
<Compile Include="Security\IBackOfficeUserPasswordChecker.cs" />
<Compile Include="Security\MembershipPasswordHasher.cs" />

View File

@@ -220,7 +220,7 @@ namespace Umbraco.Web.Editors
case SignInStatus.Success:
//get the user
var user = Security.GetBackOfficeUser(loginModel.Username);
var user = Security.GetOrCreateBackOfficeUser(loginModel.Username);
return SetPrincipalAndReturnUserDetail(user);
case SignInStatus.RequiresVerification:
@@ -246,7 +246,7 @@ namespace Umbraco.Web.Editors
typeof(IUmbracoBackOfficeTwoFactorOptions) + ".GetTwoFactorView returned an empty string"));
}
var attemptedUser = Security.GetBackOfficeUser(loginModel.Username);
var attemptedUser = Security.GetOrCreateBackOfficeUser(loginModel.Username);
//create a with information to display a custom two factor send code view
var verifyResponse = Request.CreateResponse(HttpStatusCode.PaymentRequired, new
@@ -365,7 +365,7 @@ namespace Umbraco.Web.Editors
{
case SignInStatus.Success:
//get the user
var user = Security.GetBackOfficeUser(userName);
var user = Security.GetOrCreateBackOfficeUser(userName);
return SetPrincipalAndReturnUserDetail(user);
case SignInStatus.LockedOut:
return Request.CreateValidationErrorResponse("User is locked out");

View File

@@ -186,34 +186,31 @@ namespace Umbraco.Web.Security
{
var membershipProvider = Core.Security.MembershipProviderExtensions.GetUsersMembershipProvider();
return membershipProvider != null ? membershipProvider.GetUser(username, setOnline) : null;
}
}
/// <summary>
/// Gets (and creates if not found) the back office <see cref="IUser"/> instance for the username specified
/// and updates the user's online flag
/// </summary>
/// <param name="username"></param>
/// <returns></returns>
/// <remarks>
/// <para>
/// This will return an <see cref="IUser"/> instance no matter what membership provider is installed for the back office, it will automatically
/// create any missing <see cref="IUser"/> accounts if one is not found and a custom membership provider or <see cref="IBackOfficeUserPasswordChecker"/> is being used.
/// create any missing <see cref="IUser"/> accounts if one is not found and a custom membership provider is being used.
/// </para>
/// <para>
/// This will try to deal with any custom membership provider that may exist, though this is not a recommended approach anymore people still
/// have these so we need to maintain compat.
/// </para>
/// TODO: I don't think this method is needed so the todo statment below, pretty sure we can simplify this whole thing
/// </remarks>
internal IUser GetBackOfficeUser(string username)
internal IUser GetOrCreateBackOfficeUser(string username)
{
//get the membership user (set user to be 'online' in the provider too)
var membershipUser = GetBackOfficeMembershipUser(username, true);
var provider = Core.Security.MembershipProviderExtensions.GetUsersMembershipProvider();
if (membershipUser == null)
{
throw new InvalidOperationException(
"The username & password validated but the membership provider '" +
provider.Name +
"' did not return a MembershipUser with the username supplied");
}
//regarldess of the membership provider used, see if this user object already exists in the umbraco data
var user = _applicationContext.Services.UserService.GetByUsername(membershipUser.UserName);
//See if this user object already exists in the umbraco data
var user = _applicationContext.Services.UserService.GetByUsername(username);
var provider = Core.Security.MembershipProviderExtensions.GetUsersMembershipProvider();
//we're using the built-in membership provider so the user will already be available
if (provider.IsUmbracoUsersProvider())
{
@@ -223,12 +220,27 @@ namespace Umbraco.Web.Security
throw new InvalidOperationException("The user '" + username + "' could not be found in the Umbraco database");
}
return user;
}
//we are using a custom membership provider for the back office, in this case we need to create user accounts for the logged in member.
//if we already have a user object in Umbraco we don't need to do anything, otherwise we need to create a mapped Umbraco account.
}
//TODO: I don't think this logic can ever get hit! This will only ever get called after the PasswordSignInAsync method will
// be executed and that requires that a user exists locally, it will not work if one doesn't!
// VERIFY THIS!
//we are using a custom membership provider for the back office, in this case we need to create user accounts for the logged in member.
//if we already have a user object in Umbraco we don't need to do anything, otherwise we need to create a mapped Umbraco account.
if (user != null) return user;
//get the membership user from the custom provider (set user to be 'online' in the provider too)
var membershipUser = GetBackOfficeMembershipUser(username, true);
if (membershipUser == null)
{
throw new InvalidOperationException(
"The username & password validated but the membership provider '" +
provider.Name +
"' did not return a MembershipUser with the username supplied");
}
var email = membershipUser.Email;
if (email.IsNullOrWhiteSpace())
{