mirror of
https://github.com/trailbaseio/trailbase.git
synced 2026-05-19 15:59:28 -05:00
Dotnet client: add two-factor and OTP login support.
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user