diff --git a/src/Ydb.Sdk/src/Auth/IUseDriverConfig.cs b/src/Ydb.Sdk/src/Auth/IUseDriverConfig.cs new file mode 100644 index 00000000..a7b99d8e --- /dev/null +++ b/src/Ydb.Sdk/src/Auth/IUseDriverConfig.cs @@ -0,0 +1,6 @@ +namespace Ydb.Sdk.Auth; + +public interface IUseDriverConfig +{ + public Task ProvideConfig(DriverConfig driverConfig); +} \ No newline at end of file diff --git a/src/Ydb.Sdk/src/Auth/IamProviderBase.cs b/src/Ydb.Sdk/src/Auth/IamProviderBase.cs new file mode 100644 index 00000000..edea5607 --- /dev/null +++ b/src/Ydb.Sdk/src/Auth/IamProviderBase.cs @@ -0,0 +1,160 @@ +namespace Ydb.Sdk.Auth; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +public abstract class IamProviderBase : ICredentialsProvider +{ + private static readonly TimeSpan IamRefreshInterval = TimeSpan.FromMinutes(5); + + private static readonly TimeSpan IamRefreshGap = TimeSpan.FromMinutes(1); + + private const int IamMaxRetries = 5; + + private readonly object _lock = new(); + + private readonly ILogger _logger; + + private volatile IamTokenData? _iamToken; + private volatile Task? _refreshTask; + + protected IamProviderBase(ILoggerFactory? loggerFactory) + { + loggerFactory ??= NullLoggerFactory.Instance; + _logger = loggerFactory.CreateLogger(); + } + + public async Task Initialize() + { + _iamToken = await ReceiveIamToken(); + } + + public string? GetAuthInfo() + { + var iamToken = _iamToken; + + if (iamToken is null) + { + lock (_lock) + { + if (_iamToken is not null) return _iamToken.Token; + _logger.LogWarning("Blocking for initial IAM token acquirement" + + ", please use explicit Initialize async method."); + + _iamToken = ReceiveIamToken().Result; + + return _iamToken.Token; + } + } + + if (iamToken.IsExpired()) + { + lock (_lock) + { + if (!_iamToken!.IsExpired()) return _iamToken.Token; + _logger.LogWarning("Blocking on expired IAM token."); + + _iamToken = ReceiveIamToken().Result; + + return _iamToken.Token; + } + } + + if (!iamToken.IsExpiring() || _refreshTask is not null) return _iamToken!.Token; + lock (_lock) + { + if (!_iamToken!.IsExpiring() || _refreshTask is not null) return _iamToken!.Token; + _logger.LogInformation("Refreshing IAM token."); + + _refreshTask = Task.Run(RefreshIamToken); + } + + return _iamToken!.Token; + } + + private async Task RefreshIamToken() + { + var iamToken = await ReceiveIamToken(); + + lock (_lock) + { + _iamToken = iamToken; + _refreshTask = null; + } + } + + protected async Task ReceiveIamToken() + { + var retryAttempt = 0; + while (true) + { + try + { + _logger.LogTrace($"Attempting to receive IAM token, attempt: {retryAttempt}"); + + var iamToken = await FetchToken(); + + _logger.LogInformation($"Received IAM token, expires at: {iamToken.ExpiresAt}"); + + return iamToken; + } + catch (Exception e) + { + _logger.LogDebug($"Failed to fetch IAM token, {e}"); + + if (retryAttempt >= IamMaxRetries) + { + throw; + } + + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + ++retryAttempt; + } + } + } + + protected abstract Task FetchToken(); + + protected class IamTokenData + { + public IamTokenData(string token, DateTime expiresAt) + { + var now = DateTime.UtcNow; + + Token = token; + ExpiresAt = expiresAt; + + if (expiresAt <= now) + { + RefreshAt = expiresAt; + } + else + { + var refreshSeconds = new Random().Next((int)IamRefreshInterval.TotalSeconds); + RefreshAt = expiresAt - IamRefreshGap - TimeSpan.FromSeconds(refreshSeconds); + + if (RefreshAt < now) + { + RefreshAt = expiresAt; + } + } + } + + public string Token { get; } + public DateTime ExpiresAt { get; } + + public DateTime RefreshAt { get; } + + public bool IsExpired() + { + return DateTime.UtcNow >= ExpiresAt; + } + + public bool IsExpiring() + { + return DateTime.UtcNow >= RefreshAt; + } + } +} \ No newline at end of file diff --git a/src/Ydb.Sdk/src/Auth/StaticProvider.cs b/src/Ydb.Sdk/src/Auth/StaticProvider.cs new file mode 100644 index 00000000..5a0e8659 --- /dev/null +++ b/src/Ydb.Sdk/src/Auth/StaticProvider.cs @@ -0,0 +1,54 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Ydb.Sdk.Services.Auth; + +namespace Ydb.Sdk.Auth; + +public class StaticProvider : IamProviderBase, IUseDriverConfig +{ + private readonly ILogger _logger; + + private readonly string _user; + private readonly string? _password; + + private Driver? _driver; + + + public StaticProvider(string user, string? password = null, ILoggerFactory? loggerFactory = null) : base( + loggerFactory) + { + _user = user; + _password = password; + loggerFactory ??= NullLoggerFactory.Instance; + _logger = loggerFactory.CreateLogger(); + } + + protected override async Task FetchToken() + { + if (_driver is null) + { + _logger.LogError("Driver in for static auth not provided"); + throw new NullReferenceException(); + } + + var client = new AuthClient(_driver); + var loginResponse = await client.Login(_user, _password); + loginResponse.Status.EnsureSuccess(); + var token = loginResponse.Result.Token; + var jwt = new JwtSecurityToken(token); + return new IamTokenData(token, jwt.ValidTo); + } + + public async Task ProvideConfig(DriverConfig driverConfig) + { + _driver = await Driver.CreateInitialized( + new DriverConfig( + driverConfig.Endpoint, + driverConfig.Database, + new AnonymousProvider(), + driverConfig.DefaultTransportTimeout, + driverConfig.DefaultStreamingTransportTimeout, + driverConfig.CustomServerCertificate)); + } +} \ No newline at end of file diff --git a/src/Ydb.Sdk/src/Driver.cs b/src/Ydb.Sdk/src/Driver.cs index 59214d1a..30ed0bd0 100644 --- a/src/Ydb.Sdk/src/Driver.cs +++ b/src/Ydb.Sdk/src/Driver.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Ydb.Discovery; using Ydb.Discovery.V1; +using Ydb.Sdk.Auth; namespace Ydb.Sdk; @@ -72,6 +73,12 @@ public ValueTask DisposeAsync() public async Task Initialize() { + if (_config.Credentials is IUseDriverConfig useDriverConfig) + { + await useDriverConfig.ProvideConfig(_config); + _logger.LogInformation("DriverConfig provided to IUseDriverConfig interface"); + } + _logger.LogInformation("Started initial endpoint discovery"); try diff --git a/src/Ydb.Sdk/src/Services/Auth/AuthClient.cs b/src/Ydb.Sdk/src/Services/Auth/AuthClient.cs new file mode 100644 index 00000000..f4325648 --- /dev/null +++ b/src/Ydb.Sdk/src/Services/Auth/AuthClient.cs @@ -0,0 +1,10 @@ +using Ydb.Sdk.Client; + +namespace Ydb.Sdk.Services.Auth; + +public partial class AuthClient : ClientBase +{ + public AuthClient(Driver driver) : base(driver) + { + } +} \ No newline at end of file diff --git a/src/Ydb.Sdk/src/Services/Auth/Login.cs b/src/Ydb.Sdk/src/Services/Auth/Login.cs new file mode 100644 index 00000000..665c378b --- /dev/null +++ b/src/Ydb.Sdk/src/Services/Auth/Login.cs @@ -0,0 +1,72 @@ +using Ydb.Auth; +using Ydb.Auth.V1; +using Ydb.Sdk.Client; + +namespace Ydb.Sdk.Services.Auth; + +public class LoginSettings : OperationRequestSettings +{ +} + +public class LoginResponse : ResponseWithResultBase +{ + internal LoginResponse(Status status, ResultData? result = null) + : base(status, result) + { + } + + public class ResultData + { + public string Token { get; } + + internal ResultData(string token) + { + Token = token; + } + + + internal static ResultData FromProto(LoginResult resultProto) + { + var token = resultProto.Token; + return new ResultData(token); + } + } +} + +public partial class AuthClient +{ + public async Task Login(string user, string? password, LoginSettings? settings = null) + { + settings ??= new LoginSettings(); + var request = new LoginRequest + { + OperationParams = MakeOperationParams(settings), + Password = password, + User = user + }; + + try + { + var response = await Driver.UnaryCall( + method: AuthService.LoginMethod, + request: request, + settings: settings + ); + + var status = UnpackOperation(response.Data.Operation, out LoginResult? resultProto); + + LoginResponse.ResultData? result = null; + + if (status.IsSuccess && resultProto is not null) + { + result = LoginResponse.ResultData.FromProto(resultProto); + } + + return new LoginResponse(status, result); + } + catch (Driver.TransportException e) + { + return new LoginResponse(e.Status); + } + } +} \ No newline at end of file diff --git a/src/Ydb.Sdk/src/Ydb.Sdk.csproj b/src/Ydb.Sdk/src/Ydb.Sdk.csproj index d1980b7d..6c7a353f 100644 --- a/src/Ydb.Sdk/src/Ydb.Sdk.csproj +++ b/src/Ydb.Sdk/src/Ydb.Sdk.csproj @@ -26,8 +26,9 @@ - + + @@ -36,4 +37,8 @@ + + + + diff --git a/src/Ydb.Sdk/tests/Auth/TestStaticAuth.cs b/src/Ydb.Sdk/tests/Auth/TestStaticAuth.cs new file mode 100644 index 00000000..c9456760 --- /dev/null +++ b/src/Ydb.Sdk/tests/Auth/TestStaticAuth.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; +using Ydb.Sdk.Auth; +using Ydb.Sdk.Services.Table; + +namespace Ydb.Sdk.Tests.Auth; + +[Trait("Category", "AuthStatic")] +public class TestStaticAuth +{ + // ReSharper disable once NotAccessedField.Local + private readonly ITestOutputHelper _output; + + public TestStaticAuth(ITestOutputHelper output) + { + _output = output; + } + + private static ServiceProvider GetServiceProvider() + { + return new ServiceCollection() + .AddLogging(configure => configure.AddConsole().SetMinimumLevel(LogLevel.Information)) + .BuildServiceProvider(); + } + + [Fact] + public static async Task GoodAuth() + { + var serviceProvider = GetServiceProvider(); + var loggerFactory = serviceProvider.GetService(); + + var driverConfig = new DriverConfig( + endpoint: "grpc://localhost:2136", + database: "/local", + new StaticProvider("testuser", "test_password") + ); + + await using var driver = await Driver.CreateInitialized(driverConfig, loggerFactory); + + using var tableClient = new TableClient(driver); + + var response = await Utils.ExecuteDataQuery(tableClient, "SELECT 1"); + var row = response.Result.ResultSets[0].Rows[0]; + Assert.Equal(1, row[0].GetInt32()); + } + + [Fact] + public static async Task WrongAuth() + { + var serviceProvider = GetServiceProvider(); + var loggerFactory = serviceProvider.GetService(); + + var driverConfig = new DriverConfig( + endpoint: "grpc://localhost:2136", + database: "/local", + new StaticProvider("nouser", "nopass") + ); + + await Assert.ThrowsAsync(async delegate + { + await using var driver = await Driver.CreateInitialized(driverConfig, loggerFactory); + }); + } +} \ No newline at end of file diff --git a/src/Ydb.Sdk/tests/Value/Value.Tests.csproj b/src/Ydb.Sdk/tests/Tests.csproj similarity index 83% rename from src/Ydb.Sdk/tests/Value/Value.Tests.csproj rename to src/Ydb.Sdk/tests/Tests.csproj index 6c6bdab0..f238f217 100644 --- a/src/Ydb.Sdk/tests/Value/Value.Tests.csproj +++ b/src/Ydb.Sdk/tests/Tests.csproj @@ -3,8 +3,8 @@ net6.0;net7.0; enable - Ydb.Sdk.Value.Tests - Ydb.Sdk.Value.Tests + Ydb.Sdk.Tests + Ydb.Sdk.Tests false enable @@ -16,6 +16,7 @@ + @@ -35,7 +36,7 @@ - + diff --git a/src/Ydb.Sdk/tests/Value/Utils.cs b/src/Ydb.Sdk/tests/Utils.cs similarity index 94% rename from src/Ydb.Sdk/tests/Value/Utils.cs rename to src/Ydb.Sdk/tests/Utils.cs index 3ead6dfc..c35bca97 100644 --- a/src/Ydb.Sdk/tests/Value/Utils.cs +++ b/src/Ydb.Sdk/tests/Utils.cs @@ -1,8 +1,9 @@ using Ydb.Sdk.Services.Table; +using Ydb.Sdk.Value; -namespace Ydb.Sdk.Value.Tests; +namespace Ydb.Sdk.Tests; -public class Utils +public static class Utils { public static async Task ExecuteDataQuery(TableClient tableClient, string query, Dictionary? parameters = null) diff --git a/src/Ydb.Sdk/tests/Value/TestBasicUnit.cs b/src/Ydb.Sdk/tests/Value/TestBasicUnit.cs index 6afa0e7d..e101b395 100644 --- a/src/Ydb.Sdk/tests/Value/TestBasicUnit.cs +++ b/src/Ydb.Sdk/tests/Value/TestBasicUnit.cs @@ -1,8 +1,9 @@ using System.Text; using Xunit; using Xunit.Abstractions; +using Ydb.Sdk.Value; -namespace Ydb.Sdk.Value.Tests; +namespace Ydb.Sdk.Tests.Value; [Trait("Category", "Unit")] public class TestBasicUnit diff --git a/src/Ydb.Sdk/tests/Value/TestBasicsIntegration.cs b/src/Ydb.Sdk/tests/Value/TestBasicsIntegration.cs index 8009d521..b23f74eb 100644 --- a/src/Ydb.Sdk/tests/Value/TestBasicsIntegration.cs +++ b/src/Ydb.Sdk/tests/Value/TestBasicsIntegration.cs @@ -1,9 +1,10 @@ using System.Text; using Xunit; using Xunit.Abstractions; +using Ydb.Sdk.Value; using Ydb.Sdk.Services.Table; -namespace Ydb.Sdk.Value.Tests; +namespace Ydb.Sdk.Tests.Value; [Trait("Category", "Integration")] public class TestBasicIntegration : IDisposable diff --git a/src/YdbSdk.sln b/src/YdbSdk.sln index 3248b084..6b4d3dc4 100644 --- a/src/YdbSdk.sln +++ b/src/YdbSdk.sln @@ -11,7 +11,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{316B82EF EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ydb.Sdk", "Ydb.Sdk\src\Ydb.Sdk.csproj", "{C91FA8B1-713B-40F4-B07A-EB6CD4106392}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Value.Tests", "Ydb.Sdk\tests\Value\Value.Tests.csproj", "{37345AE2-D477-4998-9123-84C51290672A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Ydb.Sdk\tests\Tests.csproj", "{A27FD249-6ACB-4392-B00F-CD08FB727C98}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ydb.Protos", "..\..\ydb-dotnet-genproto\src\Ydb.Protos\Ydb.Protos.csproj", "{083152BD-B7BA-4680-8423-CFBEF0317692}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -23,10 +25,14 @@ Global {C91FA8B1-713B-40F4-B07A-EB6CD4106392}.Debug|Any CPU.Build.0 = Debug|Any CPU {C91FA8B1-713B-40F4-B07A-EB6CD4106392}.Release|Any CPU.ActiveCfg = Release|Any CPU {C91FA8B1-713B-40F4-B07A-EB6CD4106392}.Release|Any CPU.Build.0 = Release|Any CPU - {37345AE2-D477-4998-9123-84C51290672A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {37345AE2-D477-4998-9123-84C51290672A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {37345AE2-D477-4998-9123-84C51290672A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {37345AE2-D477-4998-9123-84C51290672A}.Release|Any CPU.Build.0 = Release|Any CPU + {A27FD249-6ACB-4392-B00F-CD08FB727C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A27FD249-6ACB-4392-B00F-CD08FB727C98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A27FD249-6ACB-4392-B00F-CD08FB727C98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A27FD249-6ACB-4392-B00F-CD08FB727C98}.Release|Any CPU.Build.0 = Release|Any CPU + {083152BD-B7BA-4680-8423-CFBEF0317692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {083152BD-B7BA-4680-8423-CFBEF0317692}.Debug|Any CPU.Build.0 = Debug|Any CPU + {083152BD-B7BA-4680-8423-CFBEF0317692}.Release|Any CPU.ActiveCfg = Release|Any CPU + {083152BD-B7BA-4680-8423-CFBEF0317692}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -35,7 +41,7 @@ Global {E21B559D-5E8D-47AE-950E-03435F3066DF} = {34D81B90-76BA-430B-B3B1-B830B7206134} {316B82EF-019D-4267-95A9-5E243086B240} = {34D81B90-76BA-430B-B3B1-B830B7206134} {C91FA8B1-713B-40F4-B07A-EB6CD4106392} = {E21B559D-5E8D-47AE-950E-03435F3066DF} - {37345AE2-D477-4998-9123-84C51290672A} = {316B82EF-019D-4267-95A9-5E243086B240} + {A27FD249-6ACB-4392-B00F-CD08FB727C98} = {316B82EF-019D-4267-95A9-5E243086B240} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0AB27123-0C66-4E43-A75F-D9EAB9ED0849}