Dotnet client: add two-factor and OTP login support.

This commit is contained in:
Sebastian Jeltsch
2026-03-11 22:04:39 +01:00
parent a433a04283
commit 2b1ecd434d
5 changed files with 189 additions and 20 deletions
+34 -6
View File
@@ -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<ClientTestFixture> {
[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<ClientTestFixture> {
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<FetchException>(() => client.LoginOTP("fake0@localhost", "invalid"));
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, exception.Status);
}
[Fact]
+3 -1
View File
@@ -8,7 +8,8 @@
<PackageProjectUrl>https://trailbase.io</PackageProjectUrl>
<RepositoryUrl>https://github.com/trailbaseio/trailbase</RepositoryUrl>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<!-- <TargetFrameworks>net8.0;net10.0</TargetFrameworks> -->
<TargetFrameworks>net10.0</TargetFrameworks>
<IsPackable>true</IsPackable>
<IsAotCompatible>true</IsAotCompatible>
@@ -18,6 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Otp.NET" Version="1.4.1" />
<ProjectReference Include="../trailbase\TrailBase.csproj" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
+140 -10
View File
@@ -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;
/// <summary>
/// Error representing fetch errors.
/// </summary>
public class FetchException : Exception {
/// <summary>Auth subject, i.e. user id.</summary>
public System.Net.HttpStatusCode Status { get; }
/// <summary>The user's email address.</summary>
public string Message { get; }
/// <summary>
/// FetchException constructor.
/// </summary>
/// <param name="status">HTTP status code.</param>
/// <param name="message">Error message</param>
public FetchException(System.Net.HttpStatusCode status, string message) {
this.Status = status;
this.Message = message;
}
/// <summary>Stringify FetchException.</summary>
public override string ToString() {
return $"FetchException(status={Status}, '{Message}')";
}
}
/// <summary>
/// Representation of User JSON objects.
/// </summary>
@@ -47,6 +73,26 @@ public class Credentials {
}
}
/// <summary>
/// Representation of MultiFactorAuthCredentials JSON objects used for multi-factor log in.
/// </summary>
public class MultiFactorAuthCredentials {
/// <summary>The user's email address.</summary>
public string mfa_token { get; }
/// <summary>The user's password.</summary>
public string totp { get; }
/// <summary>
/// Credentials constructor.
/// </summary>
/// <param name="mfa_token">Multi-factor auth token received on first-factor login</param>
/// <param name="totp">TOTP code, e.g. from an authenticator app</param>
public MultiFactorAuthCredentials(string mfa_token, string totp) {
this.mfa_token = mfa_token;
this.totp = totp;
}
}
/// <summary>
/// Representation of RefreshTokenRequest JSON objects.
/// </summary>
@@ -103,7 +149,7 @@ public class Tokens {
this.csrf_token = csrf_token;
}
/// <summary>Serialize Tokens.</summary>
/// <summary>Stringify Tokens.</summary>
public override string ToString() {
return $"Tokens({auth_token}, {refresh_token}, {csrf_token})";
}
@@ -141,13 +187,36 @@ public class JwtToken {
}
}
/// <summary>
/// Representation of a MultiFactorAuthToken
/// </summary>
public class MultiFactorAuthToken {
/// <summary>User auth token.</summary>
public string mfa_token { get; }
/// <summary>
/// MultiFactorAuthToken constructor.
/// </summary>
public MultiFactorAuthToken(string mfa_token) {
this.mfa_token = mfa_token;
}
/// <summary>Stringify Tokens.</summary>
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<string, string>))]
internal partial class SourceGenerationContext : JsonSerializerContext {
}
@@ -293,18 +362,79 @@ public class Client {
}
/// <summary>Log in with the given credentials.</summary>
public async Task<Tokens> Login(string email, string password) {
public async Task<MultiFactorAuthToken?> 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<MultiFactorAuthToken>(
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<Tokens>(
await response.Content.ReadAsStringAsync(),
SourceGenerationContext.Default.Tokens)!;
updateTokens(tokens);
return null;
}
/// <summary>Log in with a second factor.</summary>
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<Tokens>(json, SourceGenerationContext.Default.Tokens)!;
Tokens tokens = JsonSerializer.Deserialize<Tokens>(await response.Content.ReadAsStringAsync(), SourceGenerationContext.Default.Tokens)!;
updateTokens(tokens);
}
/// <summary>Request an OTP code via Email.</summary>
public async Task RequestOTP(string email, string? redirectUri = null) {
var json = new Dictionary<string, string>() {
["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
);
}
/// <summary>Log in with a second factor.</summary>
public async Task LoginOTP(string email, string code) {
var response = await Fetch(
$"{_authApi}/otp/login",
HttpMethod.Post,
JsonContent.Create(new Dictionary<string, string>() {
["email"] = email,
["code"] = code,
}, SourceGenerationContext.Default.DictionaryStringString),
queryParams: null
);
Tokens tokens = JsonSerializer.Deserialize<Tokens>(await response.Content.ReadAsStringAsync(), SourceGenerationContext.Default.Tokens)!;
updateTokens(tokens);
return tokens;
}
/// <summary>Log out the current user.</summary>
@@ -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<string, string>? 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;
+11 -2
View File
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@
<PackageProjectUrl>https://trailbase.io</PackageProjectUrl>
<RepositoryUrl>https://github.com/trailbaseio/trailbase</RepositoryUrl>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<IsPackable>true</IsPackable>
<IsAotCompatible>true</IsAotCompatible>