Member 2FA (#11889)

* Bugfix - Take ufprt from form data if the request has form content type, otherwise fallback to use the query

* External linking for members

* Changed migration to reuse old table

* removed unnecessary web.config files

* Cleanup

* Extracted class to own file

* Clean up

* Rollback changes to Umbraco.Web.UI.csproj

* Fixed migration for SqlCE

* Added 2fa for members

* Change notification handler to be on deleted

* Update src/Umbraco.Infrastructure/Security/MemberUserStore.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* updated snippets

* Fixed issue with errors not shown on member linking

* fixed issue with errors

* clean up

* Fix issue where external logins could not be used to upgrade Umbraco, because the externalLogin table was expected to look different. (Like after the migration)

* Fixed issue in Ignore legacy column now using result column.

* Updated 2fa for members + publish notification when 2fa is requested.

* Changed so only Members out of box supports 2fa

* Cleanup

* rollback of csproj file, that should not have been changed

* Removed confirmed flag from db. It was not used.

Handle case where a user is signed up for 2fa, but the provider do not exist anymore. Then it is just ignored until it shows up again

Reintroduced ProviderName on interface, to ensure the class can be renamed safely

* Bugfix

* Registering DeleteTwoFactorLoginsOnMemberDeletedHandler

* Rollback nuget packages added by mistake

* Update src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Added providername to snippet

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Bjarke Berg
2022-01-21 13:10:34 +01:00
committed by GitHub
parent 52a3e285a9
commit 12abd883a9
36 changed files with 1044 additions and 57 deletions

View File

@@ -0,0 +1,12 @@
using System;
using Umbraco.Cms.Core.Models.Entities;
namespace Umbraco.Cms.Core.Models
{
public interface ITwoFactorLogin: IEntity, IRememberBeingDirty
{
string ProviderName { get; }
string Secret { get; }
Guid UserOrMemberKey { get; }
}
}

View File

@@ -0,0 +1,13 @@
using System;
using Umbraco.Cms.Core.Models.Entities;
namespace Umbraco.Cms.Core.Models
{
public class TwoFactorLogin : EntityBase, ITwoFactorLogin
{
public string ProviderName { get; set; }
public string Secret { get; set; }
public Guid UserOrMemberKey { get; set; }
public bool Confirmed { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using System;
namespace Umbraco.Cms.Core.Notifications
{
public class MemberTwoFactorRequestedNotification : INotification
{
public MemberTwoFactorRequestedNotification(Guid memberKey)
{
MemberKey = memberKey;
}
public Guid MemberKey { get; }
}
}

View File

@@ -55,6 +55,7 @@ namespace Umbraco.Cms.Core
public const string UserGroup2Node = TableNamePrefix + "UserGroup2Node";
public const string UserGroup2NodePermission = TableNamePrefix + "UserGroup2NodePermission";
public const string ExternalLogin = TableNamePrefix + "ExternalLogin";
public const string TwoFactorLogin = TableNamePrefix + "TwoFactorLogin";
public const string ExternalLoginToken = TableNamePrefix + "ExternalLoginToken";
public const string Macro = /*TableNamePrefix*/ "cms" + "Macro";

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Persistence.Repositories
{
public interface ITwoFactorLoginRepository: IReadRepository<int, ITwoFactorLogin>, IWriteRepository<ITwoFactorLogin>
{
Task<bool> DeleteUserLoginsAsync(Guid userOrMemberKey);
Task<bool> DeleteUserLoginsAsync(Guid userOrMemberKey, string providerName);
Task<IEnumerable<ITwoFactorLogin>> GetByUserOrMemberKeyAsync(Guid userOrMemberKey);
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services
{
public interface ITwoFactorLoginService : IService
{
/// <summary>
/// Deletes all user logins - normally used when a member is deleted
/// </summary>
Task DeleteUserLoginsAsync(Guid userOrMemberKey);
Task<bool> IsTwoFactorEnabledAsync(Guid userKey);
Task<string> GetSecretForUserAndProviderAsync(Guid userKey, string providerName);
Task<object> GetSetupInfoAsync(Guid userOrMemberKey, string providerName);
IEnumerable<string> GetAllProviderNames();
Task<bool> DisableAsync(Guid userOrMemberKey, string providerName);
bool ValidateTwoFactorSetup(string providerName, string secret, string code);
Task SaveAsync(TwoFactorLogin twoFactorLogin);
Task<IEnumerable<string>> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
using Umbraco.Extensions;
@@ -30,6 +31,7 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection
builder.Services.AddUnique<IDocumentTypeContainerRepository, DocumentTypeContainerRepository>();
builder.Services.AddUnique<IDomainRepository, DomainRepository>();
builder.Services.AddUnique<IEntityRepository, EntityRepository>();
builder.Services.AddUnique<ITwoFactorLoginRepository, TwoFactorLoginRepository>();
builder.Services.AddUnique<ExternalLoginRepository>();
builder.Services.AddUnique<IExternalLoginRepository>(factory => factory.GetRequiredService<ExternalLoginRepository>());
builder.Services.AddUnique<IExternalLoginWithKeyRepository>(factory => factory.GetRequiredService<ExternalLoginRepository>());

View File

@@ -75,6 +75,7 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection
));
builder.Services.AddUnique<IExternalLoginService>(factory => factory.GetRequiredService<ExternalLoginService>());
builder.Services.AddUnique<IExternalLoginWithKeyService>(factory => factory.GetRequiredService<ExternalLoginService>());
builder.Services.AddUnique<ITwoFactorLoginService, TwoFactorLoginService>();
builder.Services.AddUnique<IRedirectUrlService, RedirectUrlService>();
builder.Services.AddUnique<IConsentService, ConsentService>();
builder.Services.AddTransient(SourcesFactory);

View File

@@ -60,6 +60,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Install
typeof(CacheInstructionDto),
typeof(ExternalLoginDto),
typeof(ExternalLoginTokenDto),
typeof(TwoFactorLoginDto),
typeof(RedirectUrlDto),
typeof(LockDto),
typeof(UserGroupDto),

View File

@@ -275,6 +275,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade
// TO 9.3.0
To<MovePackageXMLToDb>("{A2F22F17-5870-4179-8A8D-2362AA4A0A5F}");
To<UpdateExternalLoginToUseKeyInsteadOfId>("{CA7A1D9D-C9D4-4914-BC0A-459E7B9C3C8C}");
To<AddTwoFactorLoginTable>("{0828F206-DCF7-4F73-ABBB-6792275532EB}");
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0
{
public class AddTwoFactorLoginTable : MigrationBase
{
public AddTwoFactorLoginTable(IMigrationContext context) : base(context)
{
}
protected override void Migrate()
{
IEnumerable<string> tables = SqlSyntax.GetTablesInSchema(Context.Database);
if (tables.InvariantContains(TwoFactorLoginDto.TableName))
{
return;
}
Create.Table<TwoFactorLoginDto>().Do();
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using NPoco;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
namespace Umbraco.Cms.Infrastructure.Persistence.Dtos
{
[TableName(TableName)]
[ExplicitColumns]
[PrimaryKey("Id")]
internal class TwoFactorLoginDto
{
public const string TableName = Cms.Core.Constants.DatabaseSchema.Tables.TwoFactorLogin;
[Column("id")]
[PrimaryKeyColumn]
public int Id { get; set; }
[Column("userOrMemberKey")]
[Index(IndexTypes.NonClustered)]
public Guid UserOrMemberKey { get; set; }
[Column("providerName")]
[Length(400)]
[NullSetting(NullSetting = NullSettings.NotNull)]
[Index(IndexTypes.UniqueNonClustered, ForColumns = "providerName,userOrMemberKey", Name = "IX_" + TableName + "_ProviderName")]
public string ProviderName { get; set; }
[Column("secret")]
[Length(400)]
[NullSetting(NullSetting = NullSettings.NotNull)]
public string Secret { get; set; }
}
}

View File

@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NPoco;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Querying;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
{
internal class TwoFactorLoginRepository : EntityRepositoryBase<int, ITwoFactorLogin>, ITwoFactorLoginRepository
{
public TwoFactorLoginRepository(IScopeAccessor scopeAccessor, AppCaches cache,
ILogger<TwoFactorLoginRepository> logger)
: base(scopeAccessor, cache, logger)
{
}
protected override Sql<ISqlContext> GetBaseQuery(bool isCount)
{
var sql = SqlContext.Sql();
sql = isCount
? sql.SelectCount()
: sql.Select<TwoFactorLoginDto>();
sql.From<TwoFactorLoginDto>();
return sql;
}
protected override string GetBaseWhereClause() =>
Core.Constants.DatabaseSchema.Tables.TwoFactorLogin + ".id = @id";
protected override IEnumerable<string> GetDeleteClauses() => Enumerable.Empty<string>();
protected override ITwoFactorLogin PerformGet(int id)
{
var sql = GetBaseQuery(false).Where<TwoFactorLoginDto>(x => x.Id == id);
var dto = Database.Fetch<TwoFactorLoginDto>(sql).FirstOrDefault();
return dto == null ? null : Map(dto);
}
protected override IEnumerable<ITwoFactorLogin> PerformGetAll(params int[] ids)
{
var sql = GetBaseQuery(false).WhereIn<TwoFactorLoginDto>(x => x.Id, ids);
var dtos = Database.Fetch<TwoFactorLoginDto>(sql);
return dtos.WhereNotNull().Select(Map);
}
protected override IEnumerable<ITwoFactorLogin> PerformGetByQuery(IQuery<ITwoFactorLogin> query)
{
var sqlClause = GetBaseQuery(false);
var translator = new SqlTranslator<ITwoFactorLogin>(sqlClause, query);
var sql = translator.Translate();
return Database.Fetch<TwoFactorLoginDto>(sql).Select(Map);
}
protected override void PersistNewItem(ITwoFactorLogin entity)
{
var dto = Map(entity);
Database.Insert(dto);
}
protected override void PersistUpdatedItem(ITwoFactorLogin entity)
{
var dto = Map(entity);
Database.Update(dto);
}
private static TwoFactorLoginDto Map(ITwoFactorLogin entity)
{
if (entity == null) return null;
return new TwoFactorLoginDto
{
Id = entity.Id,
UserOrMemberKey = entity.UserOrMemberKey,
ProviderName = entity.ProviderName,
Secret = entity.Secret,
};
}
private static ITwoFactorLogin Map(TwoFactorLoginDto dto)
{
if (dto == null) return null;
return new TwoFactorLogin
{
Id = dto.Id,
UserOrMemberKey = dto.UserOrMemberKey,
ProviderName = dto.ProviderName,
Secret = dto.Secret,
};
}
public async Task<bool> DeleteUserLoginsAsync(Guid userOrMemberKey)
{
return await DeleteUserLoginsAsync(userOrMemberKey, null);
}
public async Task<bool> DeleteUserLoginsAsync(Guid userOrMemberKey, string providerName)
{
var sql = Sql()
.Delete()
.From<TwoFactorLoginDto>()
.Where<TwoFactorLoginDto>(x => x.UserOrMemberKey == userOrMemberKey);
if (providerName is not null)
{
sql = sql.Where<TwoFactorLoginDto>(x => x.ProviderName == providerName);
}
var deletedRows = await Database.ExecuteAsync(sql);
return deletedRows > 0;
}
public async Task<IEnumerable<ITwoFactorLogin>> GetByUserOrMemberKeyAsync(Guid userOrMemberKey)
{
var sql = Sql()
.Select<TwoFactorLoginDto>()
.From<TwoFactorLoginDto>()
.Where<TwoFactorLoginDto>(x => x.UserOrMemberKey == userOrMemberKey);
var dtos = await Database.FetchAsync<TwoFactorLoginDto>(sql);
return dtos.WhereNotNull().Select(Map);
}
}
}

View File

@@ -159,5 +159,24 @@ namespace Umbraco.Cms.Core.Security
}
private static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture));
public Guid Key => UserIdToInt(Id).ToGuid();
private static int UserIdToInt(string userId)
{
if(int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
{
return result;
}
if(Guid.TryParse(userId, out var key))
{
// Reverse the IntExtensions.ToGuid
return BitConverter.ToInt32(key.ToByteArray(), 0);
}
throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture");
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Threading;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.Security
{
/// <summary>
/// Deletes the two factor for the deleted members. This cannot be handled by the database as there is not foreign keys.
/// </summary>
public class DeleteTwoFactorLoginsOnMemberDeletedHandler : INotificationAsyncHandler<MemberDeletedNotification>
{
private readonly ITwoFactorLoginService _twoFactorLoginService;
/// <summary>
/// Initializes a new instance of the <see cref="DeleteTwoFactorLoginsOnMemberDeletedHandler"/> class.
/// </summary>
public DeleteTwoFactorLoginsOnMemberDeletedHandler(ITwoFactorLoginService twoFactorLoginService)
=> _twoFactorLoginService = twoFactorLoginService;
/// <inheritdoc/>
public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken)
{
foreach (IMember member in notification.DeletedEntities)
{
await _twoFactorLoginService.DeleteUserLoginsAsync(member.Key);
}
}
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Threading.Tasks;
namespace Umbraco.Cms.Core.Security
{
public interface ITwoFactorProvider
{
string ProviderName { get; }
Task<object> GetSetupDataAsync(Guid userOrMemberKey, string secret);
bool ValidateTwoFactorPIN(string secret, string token);
/// <summary>
///
/// </summary>
/// <remarks>Called to confirm the setup of two factor on the user.</remarks>
bool ValidateTwoFactorSetup(string secret, string token);
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Serialization;
namespace Umbraco.Cms.Core.Security
{
public class MemberIdentityBuilder : IdentityBuilder
{
/// <summary>
/// Initializes a new instance of the <see cref="MemberIdentityBuilder"/> class.
/// </summary>
public MemberIdentityBuilder(IServiceCollection services)
: base(typeof(MemberIdentityUser), services)
=> InitializeServices(services);
/// <summary>
/// Initializes a new instance of the <see cref="MemberIdentityBuilder"/> class.
/// </summary>
public MemberIdentityBuilder(Type role, IServiceCollection services)
: base(typeof(MemberIdentityUser), role, services)
=> InitializeServices(services);
private void InitializeServices(IServiceCollection services)
{
}
// override to add itself, by default identity only wants a single IdentityErrorDescriber
public override IdentityBuilder AddErrorDescriber<TDescriber>()
{
if (!typeof(MembersErrorDescriber).IsAssignableFrom(typeof(TDescriber)))
{
throw new InvalidOperationException($"The type {typeof(TDescriber)} does not inherit from {typeof(MembersErrorDescriber)}");
}
Services.AddScoped<TDescriber>();
return this;
}
/// <summary>
/// Adds a token provider for the <seealso cref="MemberIdentityBuilder"/>.
/// </summary>
/// <param name="providerName">The name of the provider to add.</param>
/// <param name="provider">The type of the <see cref="IUserTwoFactorTokenProvider{MemberIdentityBuilder}"/> to add.</param>
/// <returns>The current <see cref="IdentityBuilder"/> instance.</returns>
public override IdentityBuilder AddTokenProvider(string providerName, Type provider)
{
if (!typeof(IUserTwoFactorTokenProvider<>).MakeGenericType(UserType).GetTypeInfo().IsAssignableFrom(provider.GetTypeInfo()))
{
throw new InvalidOperationException($"Invalid Type for TokenProvider: {provider.FullName}");
}
Services.Configure<IdentityOptions>(options => options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider));
Services.AddTransient(provider);
return this;
}
}
}

View File

@@ -29,6 +29,7 @@ namespace Umbraco.Cms.Core.Security
private readonly IScopeProvider _scopeProvider;
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly IExternalLoginWithKeyService _externalLoginService;
private readonly ITwoFactorLoginService _twoFactorLoginService;
/// <summary>
/// Initializes a new instance of the <see cref="MemberUserStore"/> class for the members identity store
@@ -37,7 +38,9 @@ namespace Umbraco.Cms.Core.Security
/// <param name="mapper">The mapper for properties</param>
/// <param name="scopeProvider">The scope provider</param>
/// <param name="describer">The error describer</param>
/// <param name="publishedSnapshotAccessor">The published snapshot accessor</param>
/// <param name="externalLoginService">The external login service</param>
/// <param name="twoFactorLoginService">The two factor login service</param>
[ActivatorUtilitiesConstructor]
public MemberUserStore(
IMemberService memberService,
@@ -45,7 +48,8 @@ namespace Umbraco.Cms.Core.Security
IScopeProvider scopeProvider,
IdentityErrorDescriber describer,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IExternalLoginWithKeyService externalLoginService
IExternalLoginWithKeyService externalLoginService,
ITwoFactorLoginService twoFactorLoginService
)
: base(describer)
{
@@ -54,9 +58,10 @@ namespace Umbraco.Cms.Core.Security
_scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider));
_publishedSnapshotAccessor = publishedSnapshotAccessor;
_externalLoginService = externalLoginService;
_twoFactorLoginService = twoFactorLoginService;
}
[Obsolete("Use ctor with IExternalLoginWithKeyService param")]
[Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")]
public MemberUserStore(
IMemberService memberService,
IUmbracoMapper mapper,
@@ -64,19 +69,19 @@ namespace Umbraco.Cms.Core.Security
IdentityErrorDescriber describer,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IExternalLoginService externalLoginService)
: this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>())
: this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>(), StaticServiceProvider.Instance.GetRequiredService<ITwoFactorLoginService>())
{
}
[Obsolete("Use ctor with IExternalLoginWithKeyService param")]
[Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")]
public MemberUserStore(
IMemberService memberService,
IUmbracoMapper mapper,
IScopeProvider scopeProvider,
IdentityErrorDescriber describer,
IPublishedSnapshotAccessor publishedSnapshotAccessor)
: this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>())
: this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>(), StaticServiceProvider.Instance.GetRequiredService<ITwoFactorLoginService>())
{
}
@@ -678,5 +683,34 @@ namespace Umbraco.Cms.Core.Security
LoginOnly,
FullSave
}
/// <summary>
/// Overridden to support Umbraco's own data storage requirements
/// </summary>
/// <remarks>
/// The base class's implementation of this calls into FindTokenAsync, RemoveUserTokenAsync and AddUserTokenAsync, both methods will only work with ORMs that are change
/// tracking ORMs like EFCore.
/// </remarks>
/// <inheritdoc />
public override Task<string> GetTokenAsync(MemberIdentityUser user, string loginProvider, string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ThrowIfDisposed();
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
IIdentityUserToken token = user.LoginTokens.FirstOrDefault(x => x.LoginProvider.InvariantEquals(loginProvider) && x.Name.InvariantEquals(name));
return Task.FromResult(token?.Value);
}
/// <inheritdoc />
public override async Task<bool> GetTwoFactorEnabledAsync(MemberIdentityUser user,
CancellationToken cancellationToken = default(CancellationToken))
{
return await _twoFactorLoginService.IsTwoFactorEnabledAsync(user.Key);
}
}
}

View File

@@ -263,5 +263,13 @@ namespace Umbraco.Cms.Core.Security
return await VerifyPasswordAsync(userPasswordStore, user, password) == PasswordVerificationResult.Success;
}
/// <inheritdoc/>
public virtual async Task<IList<string>> GetValidTwoFactorProvidersAsync(TUser user)
{
var results = await base.GetValidTwoFactorProvidersAsync(user);
return results;
}
}
}

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Core.Services
{
public class TwoFactorLoginService : ITwoFactorLoginService
{
private readonly ITwoFactorLoginRepository _twoFactorLoginRepository;
private readonly IScopeProvider _scopeProvider;
private readonly IOptions<IdentityOptions> _identityOptions;
private readonly IDictionary<string, ITwoFactorProvider> _twoFactorSetupGenerators;
public TwoFactorLoginService(
ITwoFactorLoginRepository twoFactorLoginRepository,
IScopeProvider scopeProvider,
IEnumerable<ITwoFactorProvider> twoFactorSetupGenerators,
IOptions<IdentityOptions> identityOptions)
{
_twoFactorLoginRepository = twoFactorLoginRepository;
_scopeProvider = scopeProvider;
_identityOptions = identityOptions;
_twoFactorSetupGenerators = twoFactorSetupGenerators.ToDictionary(x=>x.ProviderName);
}
public async Task DeleteUserLoginsAsync(Guid userOrMemberKey)
{
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
await _twoFactorLoginRepository.DeleteUserLoginsAsync(userOrMemberKey);
}
public async Task<IEnumerable<string>> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey)
{
return await GetEnabledProviderNamesAsync(userOrMemberKey);
}
private async Task<IEnumerable<string>> GetEnabledProviderNamesAsync(Guid userOrMemberKey)
{
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
var providersOnUser = (await _twoFactorLoginRepository.GetByUserOrMemberKeyAsync(userOrMemberKey))
.Select(x => x.ProviderName).ToArray();
return providersOnUser.Where(x => _identityOptions.Value.Tokens.ProviderMap.ContainsKey(x));
}
public async Task<bool> IsTwoFactorEnabledAsync(Guid userOrMemberKey)
{
return (await GetEnabledProviderNamesAsync(userOrMemberKey)).Any();
}
public async Task<string> GetSecretForUserAndProviderAsync(Guid userOrMemberKey, string providerName)
{
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
return (await _twoFactorLoginRepository.GetByUserOrMemberKeyAsync(userOrMemberKey)).FirstOrDefault(x=>x.ProviderName == providerName)?.Secret;
}
public async Task<object> GetSetupInfoAsync(Guid userOrMemberKey, string providerName)
{
var secret = await GetSecretForUserAndProviderAsync(userOrMemberKey, providerName);
//Dont allow to generate a new secrets if user already has one
if (!string.IsNullOrEmpty(secret))
{
return default;
}
secret = GenerateSecret();
if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider generator))
{
throw new InvalidOperationException($"No ITwoFactorSetupGenerator found for provider: {providerName}");
}
return await generator.GetSetupDataAsync(userOrMemberKey, secret);
}
public IEnumerable<string> GetAllProviderNames() => _twoFactorSetupGenerators.Keys;
public async Task<bool> DisableAsync(Guid userOrMemberKey, string providerName)
{
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
return (await _twoFactorLoginRepository.DeleteUserLoginsAsync(userOrMemberKey, providerName));
}
public bool ValidateTwoFactorSetup(string providerName, string secret, string code)
{
if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider generator))
{
throw new InvalidOperationException($"No ITwoFactorSetupGenerator found for provider: {providerName}");
}
return generator.ValidateTwoFactorSetup(secret, code);
}
public Task SaveAsync(TwoFactorLogin twoFactorLogin)
{
using IScope scope = _scopeProvider.CreateScope(autoComplete: true);
_twoFactorLoginRepository.Save(twoFactorLogin);
return Task.CompletedTask;
}
/// <summary>
/// Generates a new random unique secret.
/// </summary>
/// <returns>The random secret</returns>
protected virtual string GenerateSecret() => Guid.NewGuid().ToString();
}
}

View File

@@ -51,7 +51,8 @@ namespace Umbraco.Extensions
factory.GetRequiredService<IScopeProvider>(),
factory.GetRequiredService<IdentityErrorDescriber>(),
factory.GetRequiredService<IPublishedSnapshotAccessor>(),
factory.GetRequiredService<IExternalLoginWithKeyService>()
factory.GetRequiredService<IExternalLoginWithKeyService>(),
factory.GetRequiredService<ITwoFactorLoginService>()
))
.AddRoleStore<MemberRoleStore>()
.AddRoleManager<IMemberRoleManager, MemberRoleManager>()
@@ -63,6 +64,7 @@ namespace Umbraco.Extensions
builder.AddNotificationHandler<MemberDeletedNotification, DeleteExternalLoginsOnMemberDeletedHandler>();
builder.AddNotificationAsyncHandler<MemberDeletedNotification, DeleteTwoFactorLoginsOnMemberDeletedHandler>();
services.ConfigureOptions<ConfigureMemberIdentityOptions>();
services.AddScoped<IMemberUserStore>(x => (IMemberUserStore)x.GetRequiredService<IUserStore<MemberIdentityUser>>());

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Infrastructure.Security;
namespace Umbraco.Extensions
{
@@ -59,5 +60,14 @@ namespace Umbraco.Extensions
identityBuilder.Services.AddScoped(typeof(TInterface), implementationFactory);
return identityBuilder;
}
public static MemberIdentityBuilder AddTwoFactorProvider<T>(this MemberIdentityBuilder identityBuilder, string providerName) where T : class, ITwoFactorProvider
{
identityBuilder.Services.AddSingleton<ITwoFactorProvider, T>();
identityBuilder.Services.AddSingleton<T>();
identityBuilder.AddTokenProvider<TwoFactorMemberValidationProvider<T>>(providerName);
return identityBuilder;
}
}
}

View File

@@ -20,5 +20,33 @@ namespace Umbraco.Extensions
builder.Services.Replace(ServiceDescriptor.Scoped(userManagerType, customType));
return builder;
}
public static IUmbracoBuilder SetBackOfficeUserStore<TUserStore>(this IUmbracoBuilder builder)
where TUserStore : BackOfficeUserStore
{
Type customType = typeof(TUserStore);
builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IUserStore<>).MakeGenericType(typeof(BackOfficeIdentityUser)), customType));
return builder;
}
public static IUmbracoBuilder SetMemberManager<TUserManager>(this IUmbracoBuilder builder)
where TUserManager : UserManager<MemberIdentityUser>, IMemberManager
{
Type customType = typeof(TUserManager);
Type userManagerType = typeof(UserManager<MemberIdentityUser>);
builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IMemberManager), customType));
builder.Services.AddScoped(customType, services => services.GetRequiredService(userManagerType));
builder.Services.Replace(ServiceDescriptor.Scoped(userManagerType, customType));
return builder;
}
public static IUmbracoBuilder SetMemberUserStore<TUserStore>(this IUmbracoBuilder builder)
where TUserStore : MemberUserStore
{
Type customType = typeof(TUserStore);
builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IUserStore<>).MakeGenericType(typeof(MemberIdentityUser)), customType));
return builder;
}
}
}

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Semver;
using Umbraco.Cms.Core.Serialization;
@@ -16,6 +18,7 @@ namespace Umbraco.Extensions
public const string TokenUmbracoVersion = "UmbracoVersion";
public const string TokenExternalSignInError = "ExternalSignInError";
public const string TokenPasswordResetCode = "PasswordResetCode";
public const string TokenTwoFactorRequired = "TwoFactorRequired";
public static bool FromTempData(this ViewDataDictionary viewData, ITempDataDictionary tempData, string token)
{
@@ -135,5 +138,16 @@ namespace Umbraco.Extensions
{
viewData[TokenPasswordResetCode] = value;
}
public static void SetTwoFactorProviderNames(this ViewDataDictionary viewData, IEnumerable<string> providerNames)
{
viewData[TokenTwoFactorRequired] = providerNames;
}
public static bool TryGetTwoFactorProviderNames(this ViewDataDictionary viewData, out IEnumerable<string> providerNames)
{
providerNames = viewData[TokenTwoFactorRequired] as IEnumerable<string>;
return providerNames is not null;
}
}
}

View File

@@ -2,6 +2,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Web.Common.Security
{
@@ -12,5 +13,7 @@ namespace Umbraco.Cms.Web.Common.Security
Task<ExternalLoginInfo> GetExternalLoginInfoAsync(string expectedXsrf = null);
Task<IdentityResult> UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin);
Task<SignInResult> ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false);
Task<MemberIdentityUser> GetTwoFactorAuthenticationUserAsync();
Task<SignInResult> TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient);
}
}

View File

@@ -45,6 +45,9 @@ namespace Umbraco.Cms.Web.Common.Security
_httpContextAccessor = httpContextAccessor;
}
/// <inheritdoc />
public override bool SupportsUserTwoFactor => true;
/// <inheritdoc />
public async Task<bool> IsMemberAuthorizedAsync(IEnumerable<string> allowTypes = null, IEnumerable<string> allowGroups = null, IEnumerable<int> allowMembers = null)
{

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
@@ -9,6 +10,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Extensions;
@@ -22,6 +25,7 @@ namespace Umbraco.Cms.Web.Common.Security
public class MemberSignInManager : UmbracoSignInManager<MemberIdentityUser>, IMemberSignInManagerExternalLogins
{
private readonly IMemberExternalLoginProviders _memberExternalLoginProviders;
private readonly IEventAggregator _eventAggregator;
public MemberSignInManager(
UserManager<MemberIdentityUser> memberManager,
@@ -31,10 +35,12 @@ namespace Umbraco.Cms.Web.Common.Security
ILogger<SignInManager<MemberIdentityUser>> logger,
IAuthenticationSchemeProvider schemes,
IUserConfirmation<MemberIdentityUser> confirmation,
IMemberExternalLoginProviders memberExternalLoginProviders) :
IMemberExternalLoginProviders memberExternalLoginProviders,
IEventAggregator eventAggregator) :
base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
{
_memberExternalLoginProviders = memberExternalLoginProviders;
_eventAggregator = eventAggregator;
}
[Obsolete("Use ctor with all params")]
@@ -46,7 +52,9 @@ namespace Umbraco.Cms.Web.Common.Security
ILogger<SignInManager<MemberIdentityUser>> logger,
IAuthenticationSchemeProvider schemes,
IUserConfirmation<MemberIdentityUser> confirmation) :
this(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, StaticServiceProvider.Instance.GetRequiredService<IMemberExternalLoginProviders>())
this(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation,
StaticServiceProvider.Instance.GetRequiredService<IMemberExternalLoginProviders>(),
StaticServiceProvider.Instance.GetRequiredService<IEventAggregator>())
{ }
// use default scheme for members
@@ -61,30 +69,6 @@ namespace Umbraco.Cms.Web.Common.Security
// use default scheme for members
protected override string TwoFactorRememberMeAuthenticationType => IdentityConstants.TwoFactorRememberMeScheme;
/// <inheritdoc />
public override Task<MemberIdentityUser> GetTwoFactorAuthenticationUserAsync()
=> throw new NotImplementedException("Two factor is not yet implemented for members");
/// <inheritdoc />
public override Task<SignInResult> TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient)
=> throw new NotImplementedException("Two factor is not yet implemented for members");
/// <inheritdoc />
public override Task<bool> IsTwoFactorClientRememberedAsync(MemberIdentityUser user)
=> throw new NotImplementedException("Two factor is not yet implemented for members");
/// <inheritdoc />
public override Task RememberTwoFactorClientAsync(MemberIdentityUser user)
=> throw new NotImplementedException("Two factor is not yet implemented for members");
/// <inheritdoc />
public override Task ForgetTwoFactorClientAsync()
=> throw new NotImplementedException("Two factor is not yet implemented for members");
/// <inheritdoc />
public override Task<SignInResult> TwoFactorRecoveryCodeSignInAsync(string recoveryCode)
=> throw new NotImplementedException("Two factor is not yet implemented for members");
/// <inheritdoc />
public override async Task<ExternalLoginInfo> GetExternalLoginInfoAsync(string expectedXsrf = null)
{
@@ -369,6 +353,29 @@ namespace Umbraco.Cms.Web.Common.Security
private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, MemberIdentityUser user) =>
Logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id);
protected override async Task<SignInResult> SignInOrTwoFactorAsync(MemberIdentityUser user, bool isPersistent,
string loginProvider = null, bool bypassTwoFactor = false)
{
var result = await base.SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor);
if (result.RequiresTwoFactor)
{
NotifyRequiresTwoFactor(user);
}
return result;
}
protected void NotifyRequiresTwoFactor(MemberIdentityUser user) => Notify(user,
(currentUser) => new MemberTwoFactorRequestedNotification(currentUser.Key)
);
private T Notify<T>(MemberIdentityUser currentUser, Func<MemberIdentityUser, T> createNotification) where T : INotification
{
var notification = createNotification(currentUser);
_eventAggregator.Publish(notification);
return notification;
}
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Microsoft.Extensions.Logging;
namespace Umbraco.Cms.Infrastructure.Security
{
public class TwoFactorBackOfficeValidationProvider<TTwoFactorSetupGenerator> : TwoFactorValidationProvider<BackOfficeIdentityUser, TTwoFactorSetupGenerator>
where TTwoFactorSetupGenerator : ITwoFactorProvider
{
protected TwoFactorBackOfficeValidationProvider(IDataProtectionProvider dataProtectionProvider, IOptions<DataProtectionTokenProviderOptions> options, ILogger<TwoFactorBackOfficeValidationProvider<TTwoFactorSetupGenerator>> logger, ITwoFactorLoginService twoFactorLoginService, TTwoFactorSetupGenerator generator) : base(dataProtectionProvider, options, logger, twoFactorLoginService, generator)
{
}
}
public class TwoFactorMemberValidationProvider<TTwoFactorSetupGenerator> : TwoFactorValidationProvider<MemberIdentityUser, TTwoFactorSetupGenerator>
where TTwoFactorSetupGenerator : ITwoFactorProvider
{
public TwoFactorMemberValidationProvider(IDataProtectionProvider dataProtectionProvider, IOptions<DataProtectionTokenProviderOptions> options, ILogger<TwoFactorMemberValidationProvider<TTwoFactorSetupGenerator>> logger, ITwoFactorLoginService twoFactorLoginService, TTwoFactorSetupGenerator generator) : base(dataProtectionProvider, options, logger, twoFactorLoginService, generator)
{
}
}
public class TwoFactorValidationProvider<TUmbracoIdentityUser, TTwoFactorSetupGenerator>
: DataProtectorTokenProvider<TUmbracoIdentityUser>
where TUmbracoIdentityUser : UmbracoIdentityUser
where TTwoFactorSetupGenerator : ITwoFactorProvider
{
private readonly ITwoFactorLoginService _twoFactorLoginService;
private readonly TTwoFactorSetupGenerator _generator;
protected TwoFactorValidationProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<DataProtectionTokenProviderOptions> options,
ILogger<TwoFactorValidationProvider<TUmbracoIdentityUser, TTwoFactorSetupGenerator>> logger,
ITwoFactorLoginService twoFactorLoginService,
TTwoFactorSetupGenerator generator)
: base(dataProtectionProvider, options, logger)
{
_twoFactorLoginService = twoFactorLoginService;
_generator = generator;
}
public override Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUmbracoIdentityUser> manager,
TUmbracoIdentityUser user) => Task.FromResult(_generator is not null);
public override async Task<bool> ValidateAsync(string purpose, string token,
UserManager<TUmbracoIdentityUser> manager, TUmbracoIdentityUser user)
{
var secret =
await _twoFactorLoginService.GetSecretForUserAndProviderAsync(GetUserKey(user), _generator.ProviderName);
if (secret is null)
{
return false;
}
var validToken = _generator.ValidateTwoFactorPIN(secret, token);
return validToken;
}
protected Guid GetUserKey(TUmbracoIdentityUser user)
{
switch (user)
{
case MemberIdentityUser memberIdentityUser:
return memberIdentityUser.Key;
case BackOfficeIdentityUser backOfficeIdentityUser:
return backOfficeIdentityUser.Key;
default:
throw new NotSupportedException(
"Current we only support MemberIdentityUser and BackOfficeIdentityUser");
}
}
}
}

View File

@@ -1,7 +1,4 @@
@inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage
@using Umbraco.Cms.Core
@using Umbraco.Cms.Core.Security
@using Umbraco.Cms.Core.Services
@using Umbraco.Cms.Web.Common.Security
@using Umbraco.Cms.Web.Website.Controllers
@@ -11,6 +8,7 @@
@inject IMemberExternalLoginProviders memberExternalLoginProviders
@inject IExternalLoginWithKeyService externalLoginWithKeyService
@{
// Build a profile model to edit
var profileModel = await memberModelBuilderFactory
.CreateProfileModel()

View File

@@ -1,9 +1,12 @@
@inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage
@using Umbraco.Cms.Web.Common.Models
@using Umbraco.Cms.Web.Common.Security
@using Umbraco.Cms.Web.Website.Controllers
@using Umbraco.Cms.Core.Services
@using Umbraco.Extensions
@inject IMemberExternalLoginProviders memberExternalLoginProviders
@inject ITwoFactorLoginService twoFactorLoginService
@{
var loginModel = new LoginModel();
// You can modify this to redirect to a different URL instead of the current one
@@ -14,6 +17,33 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.0/jquery.validate.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"></script>
@if (ViewData.TryGetTwoFactorProviderNames(out var providerNames))
{
foreach (var providerName in providerNames)
{
<div class="2fa-form">
<h4>Two factor with @providerName.</h4>
<div asp-validation-summary="All" class="text-danger"></div>
@using (Html.BeginUmbracoForm<UmbTwoFactorLoginController>(nameof(UmbTwoFactorLoginController.Verify2FACode)))
{
<text>
<input type="hidden" name="provider" value="@providerName"/>
Input security code: <input name="code" value=""/><br/>
<button type="submit" class="btn btn-primary">Validate</button>
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
</text>
}
</div>
}
}
else
{
<div class="login-form">
@using (Html.BeginUmbracoForm<UmbLoginController>(
@@ -74,3 +104,4 @@
}
}
</div>
}

View File

@@ -4,12 +4,13 @@ using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
@@ -27,9 +28,12 @@ namespace Umbraco.Cms.Web.Website.Controllers
public class UmbExternalLoginController : SurfaceController
{
private readonly IMemberManager _memberManager;
private readonly ITwoFactorLoginService _twoFactorLoginService;
private readonly ILogger<UmbExternalLoginController> _logger;
private readonly IMemberSignInManagerExternalLogins _memberSignInManager;
public UmbExternalLoginController(
ILogger<UmbExternalLoginController> logger,
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
ServiceContext services,
@@ -37,7 +41,8 @@ namespace Umbraco.Cms.Web.Website.Controllers
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider,
IMemberSignInManagerExternalLogins memberSignInManager,
IMemberManager memberManager)
IMemberManager memberManager,
ITwoFactorLoginService twoFactorLoginService)
: base(
umbracoContextAccessor,
databaseFactory,
@@ -46,8 +51,10 @@ namespace Umbraco.Cms.Web.Website.Controllers
profilingLogger,
publishedUrlProvider)
{
_logger = logger;
_memberSignInManager = memberSignInManager;
_memberManager = memberManager;
_twoFactorLoginService = twoFactorLoginService;
}
/// <summary>
@@ -108,14 +115,12 @@ namespace Umbraco.Cms.Web.Website.Controllers
$"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}");
}
// create a with information to display a custom two factor send code view
var verifyResponse =
new ObjectResult(new { userId = attemptedUser.Id })
{
StatusCode = StatusCodes.Status402PaymentRequired
};
return verifyResponse;
var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key);
ViewData.SetTwoFactorProviderNames(providerNames);
return CurrentUmbracoPage();
}
if (result == SignInResult.LockedOut)

View File

@@ -1,14 +1,20 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Web.Common.ActionsResults;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Cms.Web.Common.Filters;
using Umbraco.Cms.Web.Common.Models;
using Umbraco.Cms.Web.Common.Security;
@@ -20,7 +26,29 @@ namespace Umbraco.Cms.Web.Website.Controllers
public class UmbLoginController : SurfaceController
{
private readonly IMemberSignInManager _signInManager;
private readonly IMemberManager _memberManager;
private readonly ITwoFactorLoginService _twoFactorLoginService;
[ActivatorUtilitiesConstructor]
public UmbLoginController(
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
ServiceContext services,
AppCaches appCaches,
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider,
IMemberSignInManager signInManager,
IMemberManager memberManager,
ITwoFactorLoginService twoFactorLoginService)
: base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider)
{
_signInManager = signInManager;
_memberManager = memberManager;
_twoFactorLoginService = twoFactorLoginService;
}
[Obsolete("Use ctor with all params")]
public UmbLoginController(
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
@@ -29,9 +57,11 @@ namespace Umbraco.Cms.Web.Website.Controllers
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider,
IMemberSignInManager signInManager)
: base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider)
: this(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider, signInManager,
StaticServiceProvider.Instance.GetRequiredService<IMemberManager>(),
StaticServiceProvider.Instance.GetRequiredService<ITwoFactorLoginService>())
{
_signInManager = signInManager;
}
[HttpPost]
@@ -74,15 +104,28 @@ namespace Umbraco.Cms.Web.Website.Controllers
if (result.RequiresTwoFactor)
{
throw new NotImplementedException("Two factor support is not supported for Umbraco members yet");
MemberIdentityUser attemptedUser = await _memberManager.FindByNameAsync(model.Username);
if (attemptedUser == null)
{
return new ValidationErrorResult(
$"No local member found for username {model.Username}");
}
var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key);
ViewData.SetTwoFactorProviderNames(providerNames);
}
else if (result.IsLockedOut)
{
ModelState.AddModelError("loginModel", "Member is locked out");
}
else if (result.IsNotAllowed)
{
ModelState.AddModelError("loginModel", "Member is not allowed");
}
else
{
ModelState.AddModelError("loginModel", "Invalid username or password");
}
// TODO: We can check for these and respond differently if we think it's important
// result.IsLockedOut
// result.IsNotAllowed
// Don't add a field level error, just model level.
ModelState.AddModelError("loginModel", "Invalid username or password");
return CurrentUmbracoPage();
}
@@ -97,5 +140,7 @@ namespace Umbraco.Cms.Web.Website.Controllers
model.RedirectUrl = redirectUrl.ToString();
}
}
}
}

View File

@@ -0,0 +1,160 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Web.Common.ActionsResults;
using Umbraco.Cms.Web.Common.Filters;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
namespace Umbraco.Cms.Web.Website.Controllers
{
[UmbracoMemberAuthorize]
public class UmbTwoFactorLoginController : SurfaceController
{
private readonly IMemberManager _memberManager;
private readonly ITwoFactorLoginService _twoFactorLoginService;
private readonly ILogger<UmbTwoFactorLoginController> _logger;
private readonly IMemberSignInManagerExternalLogins _memberSignInManager;
public UmbTwoFactorLoginController(
ILogger<UmbTwoFactorLoginController> logger,
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
ServiceContext services,
AppCaches appCaches,
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider,
IMemberSignInManagerExternalLogins memberSignInManager,
IMemberManager memberManager,
ITwoFactorLoginService twoFactorLoginService)
: base(
umbracoContextAccessor,
databaseFactory,
services,
appCaches,
profilingLogger,
publishedUrlProvider)
{
_logger = logger;
_memberSignInManager = memberSignInManager;
_memberManager = memberManager;
_twoFactorLoginService = twoFactorLoginService;
}
/// <summary>
/// Used to retrieve the 2FA providers for code submission
/// </summary>
/// <returns></returns>
[AllowAnonymous]
public async Task<ActionResult<IEnumerable<string>>> Get2FAProviders()
{
var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
_logger.LogWarning("Get2FAProviders :: No verified member found, returning 404");
return NotFound();
}
var userFactors = await _memberManager.GetValidTwoFactorProvidersAsync(user);
return new ObjectResult(userFactors);
}
[AllowAnonymous]
public async Task<IActionResult> Verify2FACode(Verify2FACodeModel model, string returnUrl = null)
{
var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
_logger.LogWarning("PostVerify2FACode :: No verified member found, returning 404");
return NotFound();
}
if (ModelState.IsValid)
{
var result = await _memberSignInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient);
if (result.Succeeded)
{
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Member is locked out");
}
else if (result.IsNotAllowed)
{
ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Member is not allowed");
}
else
{
ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Invalid code");
}
}
//We need to set this, to ensure we show the 2fa login page
var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key);
ViewData.SetTwoFactorProviderNames(providerNames);
return CurrentUmbracoPage();
}
[HttpPost]
public async Task<IActionResult> ValidateAndSaveSetup(string providerName, string secret, string code, string returnUrl = null)
{
var member = await _memberManager.GetCurrentMemberAsync();
var isValid = _twoFactorLoginService.ValidateTwoFactorSetup(providerName, secret, code);
if (isValid == false)
{
ModelState.AddModelError(nameof(code), "Invalid Code");
return CurrentUmbracoPage();
}
var twoFactorLogin = new TwoFactorLogin()
{
Confirmed = true,
Secret = secret,
UserOrMemberKey = member.Key,
ProviderName = providerName
};
await _twoFactorLoginService.SaveAsync(twoFactorLogin);
return RedirectToLocal(returnUrl);
}
[HttpPost]
public async Task<IActionResult> Disable(string providerName, string returnUrl = null)
{
var member = await _memberManager.GetCurrentMemberAsync();
var success = await _twoFactorLoginService.DisableAsync(member.Key, providerName);
if (!success)
{
return CurrentUmbracoPage();
}
return RedirectToLocal(returnUrl);
}
private IActionResult RedirectToLocal(string returnUrl) =>
Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToCurrentUmbracoPage();
}
}