From 2b1ecd434decb3bdd81e87330bcad35dd4897907 Mon Sep 17 00:00:00 2001 From: Sebastian Jeltsch Date: Wed, 11 Mar 2026 22:04:39 +0100 Subject: [PATCH] Dotnet client: add two-factor and OTP login support. --- client/dotnet/test/ClientTest.cs | 40 +++++- client/dotnet/test/TrailBase.Tests.csproj | 4 +- client/dotnet/trailbase/Client.cs | 150 ++++++++++++++++++++-- client/dotnet/trailbase/Makefile | 13 +- client/dotnet/trailbase/TrailBase.csproj | 2 +- 5 files changed, 189 insertions(+), 20 deletions(-) diff --git a/client/dotnet/test/ClientTest.cs b/client/dotnet/test/ClientTest.cs index b114bd60..48d40627 100644 --- a/client/dotnet/test/ClientTest.cs +++ b/client/dotnet/test/ClientTest.cs @@ -1,9 +1,10 @@ -using Xunit; +using OtpNet; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Diagnostics.CodeAnalysis; +using Xunit; namespace TrailBase; @@ -65,7 +66,7 @@ public class ClientTestFixture : IDisposable { var client = new HttpClient(); Task.Run(async () => { - for (int i = 0; i < 100; ++i) { + for (int i = 0; i < 200; ++i) { try { var response = await client.GetAsync($"http://{address}/api/healthcheck"); if (response.StatusCode == System.Net.HttpStatusCode.OK) { @@ -111,8 +112,10 @@ public class ClientTest : IClassFixture { [Fact] public async Task AuthTest() { var client = new Client($"http://127.0.0.1:{Constants.Port}", null); - var oldTokens = await client.Login("admin@localhost", "secret"); - Assert.NotNull(oldTokens?.auth_token); + var mfaToken = await client.Login("admin@localhost", "secret"); + Assert.Null(mfaToken); + var firstTokens = client.Tokens(); + Assert.NotNull(firstTokens); var user = client.User(); Assert.NotNull(user); Assert.Equal("admin@localhost", user!.email); @@ -121,9 +124,34 @@ public class ClientTest : IClassFixture { await client.Logout(); await Task.Delay(1500); - var newTokens = await client.Login("admin@localhost", "secret"); + await client.Login("admin@localhost", "secret"); - Assert.NotEqual(newTokens?.auth_token, oldTokens?.auth_token); + Assert.NotEqual(client.Tokens()?.auth_token, firstTokens?.auth_token); + } + + [Fact] + public async Task MultiFactorAuthTest() { + var client = new Client($"http://127.0.0.1:{Constants.Port}", null); + var mfaToken = await client.Login("alice@trailbase.io", "secret"); + Assert.NotNull(mfaToken); + + var secret = Base32Encoding.ToBytes("YCUTAYEZ346ZUEI7FLCG57BOMZQHHRA5"); + var totp = new Totp(secret, mode: OtpHashMode.Sha1); + var totpCode = totp.ComputeTotp(); + + await client.LoginSecond(mfaToken, totpCode); + Assert.Equal("alice@trailbase.io", client.User()?.email); + } + + [Fact] + public async Task OTPAuthTest() { + var client = new Client($"http://127.0.0.1:{Constants.Port}", null); + + await client.RequestOTP("fake0@localhost"); + await client.RequestOTP("fake1@localhost", redirectUri: "/target"); + + var exception = await Assert.ThrowsAsync(() => client.LoginOTP("fake0@localhost", "invalid")); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, exception.Status); } [Fact] diff --git a/client/dotnet/test/TrailBase.Tests.csproj b/client/dotnet/test/TrailBase.Tests.csproj index 740b24c1..3fc12e04 100644 --- a/client/dotnet/test/TrailBase.Tests.csproj +++ b/client/dotnet/test/TrailBase.Tests.csproj @@ -8,7 +8,8 @@ https://trailbase.io https://github.com/trailbaseio/trailbase - net8.0;net9.0 + + net10.0 true true @@ -18,6 +19,7 @@ + diff --git a/client/dotnet/trailbase/Client.cs b/client/dotnet/trailbase/Client.cs index a794f11d..16afaa78 100644 --- a/client/dotnet/trailbase/Client.cs +++ b/client/dotnet/trailbase/Client.cs @@ -4,9 +4,35 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; using System.Text.Json; +using System.Diagnostics.CodeAnalysis; namespace TrailBase; +/// +/// Error representing fetch errors. +/// +public class FetchException : Exception { + /// Auth subject, i.e. user id. + public System.Net.HttpStatusCode Status { get; } + /// The user's email address. + public string Message { get; } + + /// + /// FetchException constructor. + /// + /// HTTP status code. + /// Error message + public FetchException(System.Net.HttpStatusCode status, string message) { + this.Status = status; + this.Message = message; + } + + /// Stringify FetchException. + public override string ToString() { + return $"FetchException(status={Status}, '{Message}')"; + } +} + /// /// Representation of User JSON objects. /// @@ -47,6 +73,26 @@ public class Credentials { } } +/// +/// Representation of MultiFactorAuthCredentials JSON objects used for multi-factor log in. +/// +public class MultiFactorAuthCredentials { + /// The user's email address. + public string mfa_token { get; } + /// The user's password. + public string totp { get; } + + /// + /// Credentials constructor. + /// + /// Multi-factor auth token received on first-factor login + /// TOTP code, e.g. from an authenticator app + public MultiFactorAuthCredentials(string mfa_token, string totp) { + this.mfa_token = mfa_token; + this.totp = totp; + } +} + /// /// Representation of RefreshTokenRequest JSON objects. /// @@ -103,7 +149,7 @@ public class Tokens { this.csrf_token = csrf_token; } - /// Serialize Tokens. + /// Stringify Tokens. public override string ToString() { return $"Tokens({auth_token}, {refresh_token}, {csrf_token})"; } @@ -141,13 +187,36 @@ public class JwtToken { } } +/// +/// Representation of a MultiFactorAuthToken +/// +public class MultiFactorAuthToken { + /// User auth token. + public string mfa_token { get; } + + /// + /// MultiFactorAuthToken constructor. + /// + public MultiFactorAuthToken(string mfa_token) { + this.mfa_token = mfa_token; + } + + /// Stringify Tokens. + public override string ToString() { + return $"MFAToken({mfa_token})"; + } +} + [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(Credentials))] +[JsonSerializable(typeof(MultiFactorAuthCredentials))] [JsonSerializable(typeof(JwtToken))] [JsonSerializable(typeof(Tokens))] +[JsonSerializable(typeof(MultiFactorAuthToken))] [JsonSerializable(typeof(RefreshTokenResponse))] [JsonSerializable(typeof(RefreshTokenRequest))] [JsonSerializable(typeof(User))] +[JsonSerializable(typeof(Dictionary))] internal partial class SourceGenerationContext : JsonSerializerContext { } @@ -293,18 +362,79 @@ public class Client { } /// Log in with the given credentials. - public async Task Login(string email, string password) { + public async Task Login(string email, string password) { var response = await Fetch( $"{_authApi}/login", HttpMethod.Post, JsonContent.Create(new Credentials(email, password), SourceGenerationContext.Default.Credentials), + null, + throwOnError: false + ); + + + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) { + MultiFactorAuthToken mfaToken = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(), + SourceGenerationContext.Default.MultiFactorAuthToken)!; + return mfaToken; + } + else if (response.StatusCode != System.Net.HttpStatusCode.OK) { + throw new FetchException(response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + Tokens tokens = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(), + SourceGenerationContext.Default.Tokens)!; + updateTokens(tokens); + + return null; + } + + /// Log in with a second factor. + public async Task LoginSecond(MultiFactorAuthToken token, string code) { + var response = await Fetch( + $"{_authApi}/login_mfa", + HttpMethod.Post, + JsonContent.Create(new MultiFactorAuthCredentials(token.mfa_token, code), SourceGenerationContext.Default.MultiFactorAuthCredentials), null ); - string json = await response.Content.ReadAsStringAsync(); - Tokens tokens = JsonSerializer.Deserialize(json, SourceGenerationContext.Default.Tokens)!; + Tokens tokens = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), SourceGenerationContext.Default.Tokens)!; + updateTokens(tokens); + } + + /// Request an OTP code via Email. + public async Task RequestOTP(string email, string? redirectUri = null) { + var json = new Dictionary() { + ["email"] = email, + }; + + if (redirectUri != null) { + json.Add("redirect_uri", redirectUri); + } + + await Fetch( + $"{_authApi}/otp/request", + HttpMethod.Post, + JsonContent.Create(json, SourceGenerationContext.Default.DictionaryStringString), + queryParams: null + ); + } + + /// Log in with a second factor. + public async Task LoginOTP(string email, string code) { + var response = await Fetch( + $"{_authApi}/otp/login", + HttpMethod.Post, + JsonContent.Create(new Dictionary() { + ["email"] = email, + ["code"] = code, + }, SourceGenerationContext.Default.DictionaryStringString), + queryParams: null + ); + + Tokens tokens = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), SourceGenerationContext.Default.Tokens)!; updateTokens(tokens); - return tokens; } /// Log out the current user. @@ -375,7 +505,7 @@ public class Client { SourceGenerationContext.Default.RefreshTokenRequest ), HttpMethod.Post, - null + queryParams: null ); string json = await response.Content.ReadAsStringAsync(); @@ -396,7 +526,8 @@ public class Client { HttpMethod? method, HttpContent? data, Dictionary? queryParams, - HttpCompletionOption completion = HttpCompletionOption.ResponseContentRead + HttpCompletionOption completion = HttpCompletionOption.ResponseContentRead, + bool throwOnError = true ) { var ts = tokenState; var refreshToken = shouldRefresh(tokenState); @@ -406,9 +537,8 @@ public class Client { var response = await client.Fetch(path, ts, data, method, queryParams, completion); - if (response.StatusCode != System.Net.HttpStatusCode.OK) { - string errMsg = await response.Content.ReadAsStringAsync(); - throw new Exception($"Fetch failed [{response.StatusCode}]: {errMsg}"); + if (response.StatusCode != System.Net.HttpStatusCode.OK && throwOnError) { + throw new FetchException(response.StatusCode, await response.Content.ReadAsStringAsync()); } return response; diff --git a/client/dotnet/trailbase/Makefile b/client/dotnet/trailbase/Makefile index 8684c1e7..45f23b2e 100644 --- a/client/dotnet/trailbase/Makefile +++ b/client/dotnet/trailbase/Makefile @@ -1,5 +1,14 @@ +test: + dotnet test ../test + +# Publish to http://nuget.org release: clean - dotnet build -c Release && dotnet pack && dotnet nuget push bin/Release/TrailBase.${VERSION}.nupkg --api-key ${NUGET_KEY} --source https://api.nuget.org/v3/index.json + dotnet build -c Release \ + && dotnet pack \ + && dotnet nuget push bin/Release/TrailBase.${VERSION}.nupkg --api-key ${NUGET_KEY} --source https://api.nuget.org/v3/index.json + +format: + dotnet format . ; dotnet format ../test docs: cd ../docs && docfx docfx.json --serve @@ -7,4 +16,4 @@ docs: clean: rm -rf bin obj -.PHONY: release clean docs +.PHONY: release clean docs format test diff --git a/client/dotnet/trailbase/TrailBase.csproj b/client/dotnet/trailbase/TrailBase.csproj index c4579168..8fcf5b36 100644 --- a/client/dotnet/trailbase/TrailBase.csproj +++ b/client/dotnet/trailbase/TrailBase.csproj @@ -9,7 +9,7 @@ https://trailbase.io https://github.com/trailbaseio/trailbase - net8.0;net9.0 + net8.0;net9.0;net10.0 true true