diff --git a/samples/TestFtpServer/CustomMembershipProvider.cs b/samples/TestFtpServer/CustomMembershipProvider.cs index 6acbda6f..1c2e89f6 100644 --- a/samples/TestFtpServer/CustomMembershipProvider.cs +++ b/samples/TestFtpServer/CustomMembershipProvider.cs @@ -3,6 +3,7 @@ // using System.Security.Claims; +using System.Threading; using System.Threading.Tasks; using FubarDev.FtpServer.AccountManagement; @@ -12,10 +13,13 @@ namespace TestFtpServer /// /// Custom membership provider /// - public class CustomMembershipProvider : IMembershipProvider + public class CustomMembershipProvider : IMembershipProviderAsync { /// - public Task ValidateUserAsync(string username, string password) + public Task ValidateUserAsync( + string username, + string password, + CancellationToken cancellationToken) { if (username != "tester" || password != "testing") { @@ -38,5 +42,17 @@ public Task ValidateUserAsync(string username, string pa user)); } + + /// + public Task LogOutAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task ValidateUserAsync(string username, string password) + { + return ValidateUserAsync(username, password, CancellationToken.None); + } } } diff --git a/src/FubarDev.FtpServer.Abstractions/AccountManagement/AnonymousMembershipProvider.cs b/src/FubarDev.FtpServer.Abstractions/AccountManagement/AnonymousMembershipProvider.cs index 83636b62..4148f037 100644 --- a/src/FubarDev.FtpServer.Abstractions/AccountManagement/AnonymousMembershipProvider.cs +++ b/src/FubarDev.FtpServer.Abstractions/AccountManagement/AnonymousMembershipProvider.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Security.Claims; +using System.Threading; using System.Threading.Tasks; using FubarDev.FtpServer.AccountManagement.Anonymous; @@ -16,7 +17,7 @@ namespace FubarDev.FtpServer.AccountManagement /// /// Allow any anonymous login. /// - public class AnonymousMembershipProvider : IMembershipProvider + public class AnonymousMembershipProvider : IMembershipProviderAsync { private readonly IAnonymousPasswordValidator _anonymousPasswordValidator; @@ -68,7 +69,10 @@ public static ClaimsPrincipal CreateAnonymousPrincipal(string? email) } /// - public Task ValidateUserAsync(string username, string password) + public Task ValidateUserAsync( + string username, + string password, + CancellationToken cancellationToken) { if (string.Equals(username, "anonymous")) { @@ -83,5 +87,17 @@ public Task ValidateUserAsync(string username, string pa return Task.FromResult(new MemberValidationResult(MemberValidationStatus.InvalidLogin)); } + + /// + public Task LogOutAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task ValidateUserAsync(string username, string password) + { + return ValidateUserAsync(username, password, CancellationToken.None); + } } } diff --git a/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProvider.cs b/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProvider.cs index c320d4fd..fcc6d691 100644 --- a/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProvider.cs +++ b/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProvider.cs @@ -5,6 +5,7 @@ // Mark Junker //----------------------------------------------------------------------- +using System; using System.Threading.Tasks; namespace FubarDev.FtpServer.AccountManagement @@ -23,6 +24,9 @@ public interface IMembershipProvider /// The user name. /// The password. /// The result of the validation. - Task ValidateUserAsync(string username, string password); + [Obsolete("Use IMembershipProviderAsync.ValidateUserAsync")] + Task ValidateUserAsync( + string username, + string password); } } diff --git a/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProviderAsync.cs b/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProviderAsync.cs new file mode 100644 index 00000000..7fcec967 --- /dev/null +++ b/src/FubarDev.FtpServer.Abstractions/AccountManagement/IMembershipProviderAsync.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) Fubar Development Junker. All rights reserved. +// + +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +namespace FubarDev.FtpServer.AccountManagement +{ + /// + /// Membership provider interface. + /// + /// + /// This interface must be implemented to allow the username/password authentication. + /// + public interface IMembershipProviderAsync : IMembershipProvider + { + /// + /// Validates if the combination of and is valid. + /// + /// The user name. + /// The password. + /// The . + /// The result of the validation. + Task ValidateUserAsync( + string username, + string password, + CancellationToken cancellationToken = default); + + /// + /// Logout of the given . + /// + /// The principal to be logged out. + /// The . + /// The task. + Task LogOutAsync( + ClaimsPrincipal principal, + CancellationToken cancellationToken = default); + } +} diff --git a/src/FubarDev.FtpServer.Abstractions/Authorization/Actions/FillConnectionAccountDataAction.cs b/src/FubarDev.FtpServer.Abstractions/Authorization/Actions/FillConnectionAccountDataAction.cs index 726c61f8..988a26fd 100644 --- a/src/FubarDev.FtpServer.Abstractions/Authorization/Actions/FillConnectionAccountDataAction.cs +++ b/src/FubarDev.FtpServer.Abstractions/Authorization/Actions/FillConnectionAccountDataAction.cs @@ -40,6 +40,7 @@ public Task AuthorizedAsync(IAccountInformation accountInformation, Cancellation authInfoFeature.User = accountInformation.User; #pragma warning restore 618 authInfoFeature.FtpUser = accountInformation.FtpUser; + authInfoFeature.MembershipProvider = accountInformation.MembershipProvider; #pragma warning disable 618 connection.Data.IsAnonymous = accountInformation.User is IAnonymousFtpUser; diff --git a/src/FubarDev.FtpServer.Abstractions/Authorization/PasswordAuthorization.cs b/src/FubarDev.FtpServer.Abstractions/Authorization/PasswordAuthorization.cs index baadddb5..ce32a54e 100644 --- a/src/FubarDev.FtpServer.Abstractions/Authorization/PasswordAuthorization.cs +++ b/src/FubarDev.FtpServer.Abstractions/Authorization/PasswordAuthorization.cs @@ -66,12 +66,20 @@ public override async Task HandlePassAsync(string password, Cancel foreach (var membershipProvider in _membershipProviders) { - var validationResult = await membershipProvider - .ValidateUserAsync(_userName, password) - .ConfigureAwait(false); +#pragma warning disable 618 + var validationResult = membershipProvider is IMembershipProviderAsync membershipProviderAsync + ? await membershipProviderAsync + .ValidateUserAsync(_userName, password, cancellationToken) + .ConfigureAwait(false) + : await membershipProvider + .ValidateUserAsync(_userName, password) + .ConfigureAwait(false); +#pragma warning restore 618 if (validationResult.IsSuccess) { - var accountInformation = new DefaultAccountInformation(validationResult.FtpUser); + var accountInformation = new DefaultAccountInformation( + validationResult.FtpUser, + membershipProvider); foreach (var authorizationAction in _authorizationActions) { @@ -132,8 +140,11 @@ public UnauthenticatedUser(string name) private class DefaultAccountInformation : IAccountInformation { - public DefaultAccountInformation(ClaimsPrincipal user) + public DefaultAccountInformation( + ClaimsPrincipal user, + IMembershipProvider membershipProvider) { + MembershipProvider = membershipProvider; FtpUser = user; #pragma warning disable 618 #pragma warning disable 612 @@ -148,6 +159,9 @@ public DefaultAccountInformation(ClaimsPrincipal user) /// public ClaimsPrincipal FtpUser { get; } + + /// + public IMembershipProvider MembershipProvider { get; } } } } diff --git a/src/FubarDev.FtpServer.Abstractions/Commands/DefaultFtpCommandDispatcher.cs b/src/FubarDev.FtpServer.Abstractions/Commands/DefaultFtpCommandDispatcher.cs index b3a3cce4..1658ad0c 100644 --- a/src/FubarDev.FtpServer.Abstractions/Commands/DefaultFtpCommandDispatcher.cs +++ b/src/FubarDev.FtpServer.Abstractions/Commands/DefaultFtpCommandDispatcher.cs @@ -4,16 +4,12 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; using System.Linq; -using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using FubarDev.FtpServer.Features; using FubarDev.FtpServer.Features.Impl; -using FubarDev.FtpServer.FileSystem.Error; using FubarDev.FtpServer.ServerCommands; using JetBrains.Annotations; diff --git a/src/FubarDev.FtpServer.Abstractions/ConnectionExtensions.cs b/src/FubarDev.FtpServer.Abstractions/ConnectionExtensions.cs index a54e068c..75cbb2d1 100644 --- a/src/FubarDev.FtpServer.Abstractions/ConnectionExtensions.cs +++ b/src/FubarDev.FtpServer.Abstractions/ConnectionExtensions.cs @@ -4,7 +4,6 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Diagnostics; #if !NETSTANDARD1_3 using System.Net.Sockets; #endif diff --git a/src/FubarDev.FtpServer.Abstractions/Features/IAuthorizationInformationFeature.cs b/src/FubarDev.FtpServer.Abstractions/Features/IAuthorizationInformationFeature.cs index 28d36a6a..1944a2ab 100644 --- a/src/FubarDev.FtpServer.Abstractions/Features/IAuthorizationInformationFeature.cs +++ b/src/FubarDev.FtpServer.Abstractions/Features/IAuthorizationInformationFeature.cs @@ -24,5 +24,10 @@ public interface IAuthorizationInformationFeature /// Gets or sets the current user. /// ClaimsPrincipal? FtpUser { get; set; } + + /// + /// Gets or sets the membership provider that authenticated the . + /// + IMembershipProvider? MembershipProvider { get; set; } } } diff --git a/src/FubarDev.FtpServer.Abstractions/Features/Impl/AuthorizationInformationFeature.cs b/src/FubarDev.FtpServer.Abstractions/Features/Impl/AuthorizationInformationFeature.cs index b589a4b5..82094eca 100644 --- a/src/FubarDev.FtpServer.Abstractions/Features/Impl/AuthorizationInformationFeature.cs +++ b/src/FubarDev.FtpServer.Abstractions/Features/Impl/AuthorizationInformationFeature.cs @@ -20,5 +20,8 @@ internal class AuthorizationInformationFeature : IAuthorizationInformationFeatur /// public ClaimsPrincipal? FtpUser { get; set; } + + /// + public IMembershipProvider? MembershipProvider { get; set; } } } diff --git a/src/FubarDev.FtpServer.Abstractions/FtpConnectionData.cs b/src/FubarDev.FtpServer.Abstractions/FtpConnectionData.cs index 2672a7ac..c1e48ae8 100644 --- a/src/FubarDev.FtpServer.Abstractions/FtpConnectionData.cs +++ b/src/FubarDev.FtpServer.Abstractions/FtpConnectionData.cs @@ -54,7 +54,7 @@ public FtpConnectionData( } /// - [Obsolete("User the IAuthorizationInformationFeature services to get the current status.")] + [Obsolete("Use the IAuthorizationInformationFeature services to get the current status.")] public IFtpUser? User { get => _featureCollection.Get().User; @@ -67,7 +67,7 @@ public IFtpUser? User } /// - [Obsolete("User the IAuthorizationInformationFeature services to get the current status.")] + [Obsolete("Use the IAuthorizationInformationFeature services to get the current status.")] public ClaimsPrincipal? FtpUser { get => _featureCollection.Get().FtpUser; @@ -79,11 +79,23 @@ public ClaimsPrincipal? FtpUser } } + /// + [Obsolete("Use the IAuthorizationInformationFeature services to get the current status.")] + public IMembershipProvider? MembershipProvider + { + get => _featureCollection.Get().MembershipProvider; + set + { + var feature = _featureCollection.Get(); + feature.MembershipProvider = value; + } + } + /// /// Gets or sets a value indicating whether the user with the . /// is logged in. /// - [Obsolete("User the IFtpLoginStateMachine services to get the current status.")] + [Obsolete("Use the IFtpLoginStateMachine services to get the current status.")] public bool IsLoggedIn { get; set; } /// diff --git a/src/FubarDev.FtpServer.Abstractions/IAccountInformation.cs b/src/FubarDev.FtpServer.Abstractions/IAccountInformation.cs index 8834f630..42095010 100644 --- a/src/FubarDev.FtpServer.Abstractions/IAccountInformation.cs +++ b/src/FubarDev.FtpServer.Abstractions/IAccountInformation.cs @@ -24,5 +24,10 @@ public interface IAccountInformation /// Gets the current FTP user. /// ClaimsPrincipal FtpUser { get; } + + /// + /// Gets the membership provider which authenticated the . + /// + IMembershipProvider MembershipProvider { get; } } } diff --git a/src/FubarDev.FtpServer.Commands/CommandHandlers/ReinCommandHandler.cs b/src/FubarDev.FtpServer.Commands/CommandHandlers/ReinCommandHandler.cs index d907fdd2..96e6b06c 100644 --- a/src/FubarDev.FtpServer.Commands/CommandHandlers/ReinCommandHandler.cs +++ b/src/FubarDev.FtpServer.Commands/CommandHandlers/ReinCommandHandler.cs @@ -3,10 +3,10 @@ // using System; -using System.Reflection; using System.Threading; using System.Threading.Tasks; +using FubarDev.FtpServer.AccountManagement; using FubarDev.FtpServer.Commands; using FubarDev.FtpServer.DataConnection; using FubarDev.FtpServer.Features; @@ -49,6 +49,15 @@ public ReinCommandHandler( /// public override async Task Process(FtpCommand command, CancellationToken cancellationToken) { + // User-Logout + var authorizationInformationFeature = Connection.Features.Get(); + var user = authorizationInformationFeature.FtpUser; + var membershipProvider = authorizationInformationFeature.MembershipProvider; + if (user != null && membershipProvider is IMembershipProviderAsync membershipProviderAsync) + { + await membershipProviderAsync.LogOutAsync(user, cancellationToken); + } + // Reset the login var loginStateMachine = Connection.ConnectionServices.GetRequiredService(); loginStateMachine.Reset(); @@ -91,7 +100,11 @@ await secureConnectionFeature.CloseEncryptedControlStream(cancellationToken) catch (Exception ex) { // Ignore exceptions - _logger?.LogWarning(ex, "Failed to dispose feature of type {featureType}: {errorMessage}", featureItem.Key, ex.Message); + _logger?.LogWarning( + ex, + "Failed to dispose feature of type {FeatureType}: {ErrorMessage}", + featureItem.Key, + ex.Message); } // Remove from features collection diff --git a/src/FubarDev.FtpServer.MembershipProvider.Pam/PamMembershipProvider.cs b/src/FubarDev.FtpServer.MembershipProvider.Pam/PamMembershipProvider.cs index 36b74844..eb66bc30 100644 --- a/src/FubarDev.FtpServer.MembershipProvider.Pam/PamMembershipProvider.cs +++ b/src/FubarDev.FtpServer.MembershipProvider.Pam/PamMembershipProvider.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net; using System.Security.Claims; +using System.Threading; using System.Threading.Tasks; using FubarDev.FtpServer.AccountManagement; @@ -22,7 +23,7 @@ namespace FubarDev.FtpServer.MembershipProvider.Pam /// /// The PAM membership provider. /// - public class PamMembershipProvider : IMembershipProvider + public class PamMembershipProvider : IMembershipProviderAsync { private readonly IFtpConnectionAccessor _connectionAccessor; private readonly IPamService _pamService; @@ -80,7 +81,8 @@ public static ClaimsPrincipal CreateUnixPrincipal(UnixUserInfo userInfo) /// public Task ValidateUserAsync( string username, - string password) + string password, + CancellationToken cancellationToken) { MemberValidationResult result; var credentials = new NetworkCredential(username, password); @@ -120,5 +122,17 @@ public Task ValidateUserAsync( return Task.FromResult(result); } + + /// + public Task LogOutAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task ValidateUserAsync(string username, string password) + { + return ValidateUserAsync(username, password, CancellationToken.None); + } } } diff --git a/src/FubarDev.FtpServer/FtpConnection.cs b/src/FubarDev.FtpServer/FtpConnection.cs index cd07206e..f7daea0c 100644 --- a/src/FubarDev.FtpServer/FtpConnection.cs +++ b/src/FubarDev.FtpServer/FtpConnection.cs @@ -18,6 +18,7 @@ using System.Threading.Channels; using System.Threading.Tasks; +using FubarDev.FtpServer.AccountManagement; using FubarDev.FtpServer.Authentication; using FubarDev.FtpServer.CommandHandlers; using FubarDev.FtpServer.Commands; @@ -41,7 +42,11 @@ namespace FubarDev.FtpServer /// /// This class represents a FTP connection. /// - public sealed class FtpConnection : FtpConnectionContext, IFtpConnection, IObservable, IFtpConnectionEventHost + public sealed class FtpConnection + : FtpConnectionContext, + IFtpConnection, + IObservable, + IFtpConnectionEventHost { private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); @@ -110,6 +115,8 @@ public sealed class FtpConnection : FtpConnectionContext, IFtpConnection, IObser private readonly FtpConnectionKeepAlive _keepAlive; #pragma warning restore 612 + private readonly IAuthorizationInformationFeature _authorizationInformationFeature; + private bool _connectionClosing; private int _connectionClosed; @@ -232,6 +239,8 @@ public FtpConnection( Features = features; + _authorizationInformationFeature = features.Get(); + _commandReader = ReadCommandsFromPipeline( applicationInputPipe.Reader, _ftpCommandChannel.Writer, @@ -352,7 +361,7 @@ await _networkStreamFeature.SecureConnectionAdapter.StartAsync(CancellationToken /// public async Task StopAsync() { - var success = await _stopSemaphore.WaitAsync(0) + var success = await _stopSemaphore.WaitAsync(0, CancellationToken.None) .ConfigureAwait(false); if (!success) { @@ -364,7 +373,8 @@ public async Task StopAsync() { _logger?.LogTrace("StopAsync called"); - await _serviceControl.WaitAsync().ConfigureAwait(false); + await _serviceControl.WaitAsync(CancellationToken.None) + .ConfigureAwait(false); try { if (Interlocked.CompareExchange(ref _connectionClosed, 1, 0) != 0) @@ -372,6 +382,15 @@ public async Task StopAsync() return; } + var currentUser = _authorizationInformationFeature.FtpUser; + var membershipProvider = _authorizationInformationFeature.MembershipProvider; + if (currentUser != null + && membershipProvider is IMembershipProviderAsync membershipProviderAsync) + { + await membershipProviderAsync.LogOutAsync(currentUser, CancellationToken.None) + .ConfigureAwait(false); + } + Abort(); try @@ -399,7 +418,7 @@ await _streamWriterService.StopAsync(CancellationToken.None) catch (Exception ex) { // Something went wrong... badly! - _logger?.LogError(ex, ex.Message); + _logger?.LogError(ex, "Failed to stop the client connection: {ErrorMessage}", ex.Message); } // Dispose all features (if disposable) diff --git a/test/FubarDev.FtpServer.Tests/FtpServerTestsBase.cs b/test/FubarDev.FtpServer.Tests/FtpServerTestsBase.cs index 06782ee8..b4b967b6 100644 --- a/test/FubarDev.FtpServer.Tests/FtpServerTestsBase.cs +++ b/test/FubarDev.FtpServer.Tests/FtpServerTestsBase.cs @@ -20,10 +20,10 @@ public class FtpServerTestsBase : IAsyncLifetime private IFtpServer? _server; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The test output helper. - public FtpServerTestsBase(ITestOutputHelper testOutputHelper) + protected FtpServerTestsBase(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; } @@ -33,6 +33,11 @@ public FtpServerTestsBase(ITestOutputHelper testOutputHelper) /// public IFtpServer Server => _server ?? throw new InvalidOperationException(); + /// + /// Gets the service provider. + /// + public IServiceProvider ServiceProvider => _serviceProvider ?? throw new InvalidOperationException(); + /// public virtual Task InitializeAsync() { diff --git a/test/FubarDev.FtpServer.Tests/Issues/Issue30CustomFtpUser.cs b/test/FubarDev.FtpServer.Tests/Issues/Issue30CustomFtpUser.cs index 38baa788..d631a47d 100644 --- a/test/FubarDev.FtpServer.Tests/Issues/Issue30CustomFtpUser.cs +++ b/test/FubarDev.FtpServer.Tests/Issues/Issue30CustomFtpUser.cs @@ -2,7 +2,9 @@ // Copyright (c) Fubar Development Junker. All rights reserved. // +using System; using System.Security.Claims; +using System.Threading; using System.Threading.Tasks; using FluentFTP; @@ -30,6 +32,32 @@ public async Task LoginSucceedsWithTester() await client.ConnectAsync(); } + [Fact] + public async Task LogoutCalledAfterSuccessfulLogin() + { + using (var client = new FtpClient("127.0.0.1", Server.Port, "tester", "test")) + { + await client.ConnectAsync(); + } + + var membershipProvider = + (CustomMembershipProvider)ServiceProvider.GetRequiredService(); + var maxDelay = TimeSpan.FromSeconds(2); + var start = DateTimeOffset.UtcNow; + while (membershipProvider.LogoutCalled == 0) + { + await Task.Delay(100); + var end = DateTimeOffset.UtcNow; + var delay = end - start; + if (delay > maxDelay) + { + break; + } + } + + Assert.Equal(1, membershipProvider.LogoutCalled); + } + [Fact] public async Task LoginFailsWithWrongUserName() { @@ -61,10 +89,15 @@ protected override IServiceCollection Configure(IServiceCollection services) .AddSingleton(); } - private class CustomMembershipProvider : IMembershipProvider + private class CustomMembershipProvider : IMembershipProviderAsync { + public int LogoutCalled { get; set; } + /// - public Task ValidateUserAsync(string username, string password) + public Task ValidateUserAsync( + string username, + string password, + CancellationToken cancellationToken) { if (username == "tester" && password == "test") { @@ -77,6 +110,19 @@ public Task ValidateUserAsync(string username, string pa return Task.FromResult(new MemberValidationResult(MemberValidationStatus.InvalidLogin)); } + + /// + public Task LogOutAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) + { + LogoutCalled += 1; + return Task.CompletedTask; + } + + /// + public Task ValidateUserAsync(string username, string password) + { + return ValidateUserAsync(username, password, CancellationToken.None); + } } } }