Merge pull request 'feat/redis-session-store' (#2) from feat/redis-session-store into develop
Reviewed-on: https://git.gay/Pupsie/VRCAuthProxy/pulls/2
This commit is contained in:
commit
fadc13b6e6
13 changed files with 680 additions and 71 deletions
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
|
|
@ -22,14 +22,21 @@ jobs:
|
|||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set Docker tags
|
||||
id: docker_meta
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/heads/develop ]]; then
|
||||
echo "tags=ghcr.io/pridevrinc/vrcauthproxy:latest" >> $GITHUB_OUTPUT
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "tags=ghcr.io/pridevrinc/vrcauthproxy:${VERSION},ghcr.io/pridevrinc/vrcauthproxy:latest" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
push: true
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
ghcr.io/pridevrinc/vrcauthproxy:latest
|
||||
ghcr.io/pridevrinc/vrcauthproxy:${{ github.ref }} # Push the tagged version
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["VRCAuthProxy/VRCAuthProxy.csproj", "VRCAuthProxy/"]
|
||||
|
|
@ -15,7 +15,7 @@ FROM build AS publish
|
|||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "VRCAuthProxy.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
RUN apt update && \
|
||||
apt install -y curl
|
||||
WORKDIR /app
|
||||
|
|
|
|||
78
Tests/Helpers/RedisFixture.cs
Normal file
78
Tests/Helpers/RedisFixture.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Collection definition for Redis tests
|
||||
/// </summary>
|
||||
[CollectionDefinition("Redis")]
|
||||
public class RedisCollection : ICollectionFixture<RedisFixture>
|
||||
{
|
||||
// This class has no code, and is never created. Its purpose is simply
|
||||
// to be the place to apply [CollectionDefinition] and all the
|
||||
// ICollectionFixture<> interfaces.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redis test fixture that ensures a Redis instance is available for tests
|
||||
/// </summary>
|
||||
public class RedisFixture : IDisposable
|
||||
{
|
||||
private Process? _redisProcess; // Kept for future implementation where we might start Redis
|
||||
private bool _disposedValue;
|
||||
|
||||
public RedisFixture()
|
||||
{
|
||||
// For CI environments, this would start Redis using Docker
|
||||
// For local development, we assume Redis is already running
|
||||
|
||||
// Check if Redis is available
|
||||
if (!IsRedisRunning())
|
||||
{
|
||||
// In a real implementation, we could start Redis here if needed
|
||||
Console.WriteLine("Warning: Redis is not running. Redis integration tests will be skipped.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if Redis is available on localhost:6379
|
||||
/// </summary>
|
||||
private bool IsRedisRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
var result = client.BeginConnect("localhost", 6379, null, null);
|
||||
var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(1));
|
||||
client.EndConnect(result);
|
||||
return success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// Stop Redis if we started it
|
||||
_redisProcess?.Kill();
|
||||
_redisProcess?.Dispose();
|
||||
}
|
||||
|
||||
_disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
Tests/Integration/ProgramRedisIntegrationTests.cs
Normal file
143
Tests/Integration/ProgramRedisIntegrationTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using VRCAuthProxy.Services;
|
||||
using VRCAuthProxy.Tests.Helpers;
|
||||
using WireMock.RequestBuilders;
|
||||
using WireMock.ResponseBuilders;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Integration
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection("Redis")]
|
||||
public class ProgramRedisIntegrationTests : IClassFixture<TestSetup>
|
||||
{
|
||||
private readonly TestSetup _testSetup;
|
||||
private readonly RedisService _redisService;
|
||||
|
||||
public ProgramRedisIntegrationTests(TestSetup testSetup)
|
||||
{
|
||||
_testSetup = testSetup;
|
||||
_redisService = new RedisService("localhost:6379");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires Redis instance - for manual integration testing")]
|
||||
public async Task LoginShouldStoreAuthTokenInRedis()
|
||||
{
|
||||
// Arrange
|
||||
var mockServer = _testSetup.MockVRChatApi;
|
||||
var testUsername = "redis-test-user";
|
||||
var testPassword = "redis-test-password";
|
||||
|
||||
// Clean up any existing tokens
|
||||
await _redisService.RemoveAuthToken(testUsername);
|
||||
|
||||
// Mock the authentication endpoint
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet()
|
||||
.WithHeader("Authorization", "*"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithHeader("Set-Cookie", "auth=test-redis-auth-token; path=/; secure; httponly")
|
||||
.WithBodyAsJson(new VRCAuthProxy.types.User { displayName = "RedisTestUser" }));
|
||||
|
||||
var mockServerUrl = mockServer.Urls.First();
|
||||
|
||||
// Create a config with test account
|
||||
var config = TestSetup.CreateTestConfig();
|
||||
config.Accounts.Clear();
|
||||
config.Accounts.Add(new ConfigAccount
|
||||
{
|
||||
username = testUsername,
|
||||
password = testPassword
|
||||
});
|
||||
|
||||
// Act - Call the login method (simplified version of what Program.cs does)
|
||||
var handler = new HttpClientHandler { UseCookies = true };
|
||||
var cookieContainer = handler.CookieContainer;
|
||||
var httpClient = new HttpClientCookieContainer(handler)
|
||||
{
|
||||
BaseAddress = new Uri(mockServerUrl),
|
||||
Username = testUsername
|
||||
};
|
||||
|
||||
// Simulate login process
|
||||
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{testUsername}:{testPassword}"));
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
|
||||
// Store cookies in Redis (like in Program.cs)
|
||||
var cookies = cookieContainer.GetAllCookies().Cast<Cookie>()
|
||||
.ToDictionary(c => c.Name, c => c.Value);
|
||||
await _redisService.SaveAuthToken(testUsername, cookies);
|
||||
|
||||
// Assert
|
||||
var storedToken = await _redisService.GetAuthToken(testUsername);
|
||||
storedToken.Should().NotBeNull();
|
||||
if (storedToken != null)
|
||||
{
|
||||
storedToken.Should().ContainKey("auth");
|
||||
storedToken["auth"].Should().Be("test-redis-auth-token");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await _redisService.RemoveAuthToken(testUsername);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires Redis instance - for manual integration testing")]
|
||||
public async Task LoginWithStoredTokenShouldReuseRedisToken()
|
||||
{
|
||||
// Arrange
|
||||
var mockServer = _testSetup.MockVRChatApi;
|
||||
var testUsername = "redis-existing-user";
|
||||
|
||||
// Setup stored token in Redis
|
||||
var existingCookies = new Dictionary<string, string>
|
||||
{
|
||||
{ "auth", "existing-redis-token" }
|
||||
};
|
||||
await _redisService.SaveAuthToken(testUsername, existingCookies);
|
||||
|
||||
// Mock the user endpoint for a successful request with the token
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet()
|
||||
.WithCookie("auth", "existing-redis-token"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBodyAsJson(new VRCAuthProxy.types.User { displayName = "ExistingRedisUser" }));
|
||||
|
||||
var mockServerUrl = mockServer.Urls.First();
|
||||
|
||||
// Act - Simulate restoring cookies and making request
|
||||
var handler = new HttpClientHandler { UseCookies = true };
|
||||
var cookieContainer = handler.CookieContainer;
|
||||
|
||||
// Restore cookies from Redis
|
||||
var storedCookies = await _redisService.GetAuthToken(testUsername);
|
||||
if (storedCookies != null)
|
||||
{
|
||||
foreach (var cookie in storedCookies)
|
||||
{
|
||||
cookieContainer.Add(new Uri(mockServerUrl), new Cookie(cookie.Key, cookie.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var response = await httpClient.GetAsync($"{mockServerUrl}/api/1/auth/user");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
responseContent.Should().Contain("ExistingRedisUser");
|
||||
|
||||
// Clean up
|
||||
await _redisService.RemoveAuthToken(testUsername);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
Tests/Integration/RedisIntegrationTests.cs
Normal file
104
Tests/Integration/RedisIntegrationTests.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using System.Text.Json;
|
||||
using VRCAuthProxy.Services;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Integration
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection("Redis")]
|
||||
public class RedisIntegrationTests
|
||||
{
|
||||
private readonly RedisService _redisService;
|
||||
private const string TEST_USERNAME = "testuser";
|
||||
|
||||
public RedisIntegrationTests()
|
||||
{
|
||||
// For integration tests, use the actual Redis service
|
||||
// This assumes Redis is running locally or in a Docker container
|
||||
// as configured in docker-compose.yml (accessible at localhost:6379)
|
||||
_redisService = new RedisService("localhost:6379");
|
||||
|
||||
// Clean up any previous test data
|
||||
_redisService.RemoveAuthToken(TEST_USERNAME).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires Redis instance - for manual integration testing")]
|
||||
public async Task SaveAndGetAuthToken_ShouldStoreAndRetrieveTokens()
|
||||
{
|
||||
// Arrange
|
||||
var cookies = new Dictionary<string, string>
|
||||
{
|
||||
{ "auth", "integration-test-token" },
|
||||
{ "session", "integration-test-session" }
|
||||
};
|
||||
|
||||
// Act - Save token
|
||||
await _redisService.SaveAuthToken(TEST_USERNAME, cookies);
|
||||
|
||||
// Act - Retrieve token
|
||||
var retrievedCookies = await _redisService.GetAuthToken(TEST_USERNAME);
|
||||
|
||||
// Assert
|
||||
retrievedCookies.Should().NotBeNull();
|
||||
if (retrievedCookies != null)
|
||||
{
|
||||
retrievedCookies.Should().ContainKey("auth");
|
||||
retrievedCookies.Should().ContainKey("session");
|
||||
retrievedCookies["auth"].Should().Be("integration-test-token");
|
||||
retrievedCookies["session"].Should().Be("integration-test-session");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires Redis instance - for manual integration testing")]
|
||||
public async Task RemoveAuthToken_ShouldDeleteTokenFromRedis()
|
||||
{
|
||||
// Arrange
|
||||
var cookies = new Dictionary<string, string>
|
||||
{
|
||||
{ "auth", "token-to-delete" }
|
||||
};
|
||||
await _redisService.SaveAuthToken(TEST_USERNAME, cookies);
|
||||
|
||||
// Verify token exists
|
||||
var tokenBeforeDelete = await _redisService.GetAuthToken(TEST_USERNAME);
|
||||
tokenBeforeDelete.Should().NotBeNull();
|
||||
|
||||
// Act
|
||||
await _redisService.RemoveAuthToken(TEST_USERNAME);
|
||||
|
||||
// Assert
|
||||
var tokenAfterDelete = await _redisService.GetAuthToken(TEST_USERNAME);
|
||||
tokenAfterDelete.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires Redis instance - for manual integration testing")]
|
||||
public async Task GetAllAuthTokens_ShouldReturnAllStoredTokens()
|
||||
{
|
||||
// Arrange
|
||||
var user1 = "testuser1";
|
||||
var user2 = "testuser2";
|
||||
|
||||
// Clean up
|
||||
await _redisService.RemoveAuthToken(user1);
|
||||
await _redisService.RemoveAuthToken(user2);
|
||||
|
||||
// Add test data
|
||||
await _redisService.SaveAuthToken(user1, new Dictionary<string, string> { { "auth", "token1" } });
|
||||
await _redisService.SaveAuthToken(user2, new Dictionary<string, string> { { "auth", "token2" } });
|
||||
|
||||
// Act
|
||||
var allTokens = await _redisService.GetAllAuthTokens();
|
||||
|
||||
// Assert
|
||||
allTokens.Should().ContainKey(user1);
|
||||
allTokens.Should().ContainKey(user2);
|
||||
allTokens[user1]["auth"].Should().Be("token1");
|
||||
allTokens[user2]["auth"].Should().Be("token2");
|
||||
|
||||
// Cleanup
|
||||
await _redisService.RemoveAuthToken(user1);
|
||||
await _redisService.RemoveAuthToken(user2);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
Tests/Unit/RedisServiceTests.cs
Normal file
144
Tests/Unit/RedisServiceTests.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using VRCAuthProxy.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Unit
|
||||
{
|
||||
public class RedisServiceTests
|
||||
{
|
||||
// Using a minimal implementation for testing
|
||||
private class InMemoryRedisService
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _store = new();
|
||||
|
||||
public Task SaveAuthToken(string username, Dictionary<string, string> cookies)
|
||||
{
|
||||
_store[username] = new Dictionary<string, string>(cookies);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Dictionary<string, string>?> GetAuthToken(string username)
|
||||
{
|
||||
if (_store.TryGetValue(username, out var cookies))
|
||||
return Task.FromResult<Dictionary<string, string>?>(new Dictionary<string, string>(cookies));
|
||||
|
||||
return Task.FromResult<Dictionary<string, string>?>(null);
|
||||
}
|
||||
|
||||
public Task<Dictionary<string, Dictionary<string, string>>> GetAllAuthTokens()
|
||||
{
|
||||
var result = new Dictionary<string, Dictionary<string, string>>();
|
||||
|
||||
foreach (var pair in _store)
|
||||
{
|
||||
result[pair.Key] = new Dictionary<string, string>(pair.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task RemoveAuthToken(string username)
|
||||
{
|
||||
_store.Remove(username);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly InMemoryRedisService _redisService;
|
||||
|
||||
public RedisServiceTests()
|
||||
{
|
||||
_redisService = new InMemoryRedisService();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndGetAuthToken_ShouldStoreAndRetrieveTokens()
|
||||
{
|
||||
// Arrange
|
||||
var username = "testuser";
|
||||
var cookies = new Dictionary<string, string>
|
||||
{
|
||||
{ "auth", "test-auth-token" },
|
||||
{ "session", "test-session-token" }
|
||||
};
|
||||
|
||||
// Act - Save token
|
||||
await _redisService.SaveAuthToken(username, cookies);
|
||||
|
||||
// Act - Retrieve token
|
||||
var retrievedCookies = await _redisService.GetAuthToken(username);
|
||||
|
||||
// Assert
|
||||
retrievedCookies.Should().NotBeNull();
|
||||
if (retrievedCookies != null)
|
||||
{
|
||||
retrievedCookies.Should().ContainKey("auth");
|
||||
retrievedCookies.Should().ContainKey("session");
|
||||
retrievedCookies["auth"].Should().Be("test-auth-token");
|
||||
retrievedCookies["session"].Should().Be("test-session-token");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthToken_WhenTokenDoesNotExist_ShouldReturnNull()
|
||||
{
|
||||
// Arrange
|
||||
var username = "nonexistentuser";
|
||||
|
||||
// Act
|
||||
var result = await _redisService.GetAuthToken(username);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAuthTokens_ShouldReturnAllStoredTokens()
|
||||
{
|
||||
// Arrange
|
||||
var user1 = "testuser1";
|
||||
var user2 = "testuser2";
|
||||
|
||||
// Clear any existing tokens
|
||||
await _redisService.RemoveAuthToken(user1);
|
||||
await _redisService.RemoveAuthToken(user2);
|
||||
|
||||
// Add test data
|
||||
await _redisService.SaveAuthToken(user1, new Dictionary<string, string> { { "auth", "token1" } });
|
||||
await _redisService.SaveAuthToken(user2, new Dictionary<string, string> { { "auth", "token2" } });
|
||||
|
||||
// Act
|
||||
var allTokens = await _redisService.GetAllAuthTokens();
|
||||
|
||||
// Assert
|
||||
allTokens.Should().ContainKey(user1);
|
||||
allTokens.Should().ContainKey(user2);
|
||||
allTokens[user1]["auth"].Should().Be("token1");
|
||||
allTokens[user2]["auth"].Should().Be("token2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAuthToken_ShouldDeleteToken()
|
||||
{
|
||||
// Arrange
|
||||
var username = "testuser";
|
||||
var cookies = new Dictionary<string, string>
|
||||
{
|
||||
{ "auth", "token-to-delete" }
|
||||
};
|
||||
await _redisService.SaveAuthToken(username, cookies);
|
||||
|
||||
// Verify token exists
|
||||
var tokenBeforeDelete = await _redisService.GetAuthToken(username);
|
||||
tokenBeforeDelete.Should().NotBeNull();
|
||||
|
||||
// Act
|
||||
await _redisService.RemoveAuthToken(username);
|
||||
|
||||
// Assert
|
||||
var tokenAfterDelete = await _redisService.GetAuthToken(username);
|
||||
tokenAfterDelete.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
|
@ -22,7 +22,8 @@
|
|||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
|
||||
<PackageReference Include="WireMock.Net" Version="1.5.44" />
|
||||
<PackageReference Include="WireMock.Net" Version="1.5.40" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.7.17" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
namespace VRCAuthProxy;
|
||||
|
||||
public class HttpClientCookieContainer(HttpClientHandler handler) : HttpClient(handler)
|
||||
public class HttpClientCookieContainer : HttpClient
|
||||
{
|
||||
public CookieContainer CookieContainer => handler.CookieContainer;
|
||||
public CookieContainer CookieContainer { get; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public HttpClientCookieContainer(HttpClientHandler handler) : base(handler)
|
||||
{
|
||||
CookieContainer = handler.CookieContainer;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Builder;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using OtpNet;
|
||||
using VRCAuthProxy;
|
||||
using VRCAuthProxy.Services;
|
||||
using VRCAuthProxy.types;
|
||||
using HttpMethod = System.Net.Http.HttpMethod;
|
||||
using User = VRCAuthProxy.types.User;
|
||||
|
|
@ -18,102 +19,134 @@ using User = VRCAuthProxy.types.User;
|
|||
string userAgent = "VRCAuthProxy V1.0.0 (https://github.com/PrideVRCommunity/VRCAuthProxy)";
|
||||
|
||||
var apiAccounts = new List<HttpClientCookieContainer>();
|
||||
|
||||
|
||||
var redisConnection = Environment.GetEnvironmentVariable("REDIS_CONNECTION") ?? "localhost:6379";
|
||||
var redisService = new RedisService(redisConnection);
|
||||
|
||||
// Push the first account to the end of the list
|
||||
void RotateAccount()
|
||||
async Task RotateAccount()
|
||||
{
|
||||
var account = apiAccounts.First();
|
||||
apiAccounts.Remove(account);
|
||||
apiAccounts.Add(account);
|
||||
|
||||
// Store updated cookies in Redis
|
||||
var cookies = account.CookieContainer.GetAllCookies().Cast<Cookie>()
|
||||
.ToDictionary(c => c.Name, c => c.Value);
|
||||
await redisService.SaveAuthToken(account.Username, cookies);
|
||||
}
|
||||
|
||||
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var app = builder.Build();
|
||||
app.UseWebSockets();
|
||||
|
||||
void LogInAllAccounts()
|
||||
async Task LogInAllAccounts()
|
||||
{
|
||||
Config.Instance.Accounts.ForEach(async account =>
|
||||
foreach (var account in Config.Instance.Accounts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cookieContainer = new CookieContainer();
|
||||
|
||||
// Try to restore cookies from Redis
|
||||
var storedCookies = await redisService.GetAuthToken(account.username);
|
||||
if (storedCookies != null)
|
||||
{
|
||||
foreach (var cookie in storedCookies)
|
||||
{
|
||||
cookieContainer.Add(new Uri("https://api.vrchat.cloud"), new Cookie(cookie.Key, cookie.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
CookieContainer = cookieContainer
|
||||
};
|
||||
var httpClient = new HttpClientCookieContainer(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://api.vrchat.cloud/api/1")
|
||||
|
||||
BaseAddress = new Uri("https://api.vrchat.cloud/api/1"),
|
||||
Username = account.username
|
||||
};
|
||||
httpClient.DefaultRequestHeaders.Add("User-Agent", userAgent);
|
||||
|
||||
Console.WriteLine($"Creating API for {account.username}");
|
||||
|
||||
string encodedUsername = HttpUtility.UrlEncode(account.username);
|
||||
string encodedPassword = HttpUtility.UrlEncode(account.password);
|
||||
|
||||
// Create Basic auth string
|
||||
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{encodedUsername}:{encodedPassword}"));
|
||||
|
||||
// Add basic auth header
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
||||
var authResp = await httpClient.SendAsync(request);
|
||||
if ((await authResp.Content.ReadAsStringAsync()).Contains("totp"))
|
||||
// If we don't have stored cookies or they're invalid, perform login
|
||||
var curUserResp = await httpClient.GetAsync("/api/1/auth/user");
|
||||
if (!curUserResp.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"TOTP required for {account.username}");
|
||||
if (account.totpSecret == null)
|
||||
Console.WriteLine($"Unable to use cached cookies for {account.username}. Getting new ones...");
|
||||
|
||||
string encodedUsername = HttpUtility.UrlEncode(account.username);
|
||||
string encodedPassword = HttpUtility.UrlEncode(account.password);
|
||||
|
||||
// Create Basic auth string
|
||||
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{encodedUsername}:{encodedPassword}"));
|
||||
|
||||
// Add basic auth header
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
||||
var authResp = await httpClient.SendAsync(request);
|
||||
|
||||
if ((await authResp.Content.ReadAsStringAsync()).Contains("totp"))
|
||||
{
|
||||
Console.WriteLine($"No TOTP secret found for {account.username}");
|
||||
return;
|
||||
}
|
||||
|
||||
// totp constructor needs a byte array decoded from the base32 secret
|
||||
var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
|
||||
var code = totp.ComputeTotp();
|
||||
if (code == null)
|
||||
{
|
||||
Console.WriteLine($"Failed to generate TOTP for {account.username}");
|
||||
return;
|
||||
}
|
||||
|
||||
var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
|
||||
// set content type
|
||||
verifyReq.Content = new StringContent($"{{\"code\":\"{code}\"}}", Encoding.UTF8, "application/json");
|
||||
var verifyResp = await httpClient.SendAsync(verifyReq);
|
||||
var verifyRes = await verifyResp.Content.ReadFromJsonAsync<TotpVerifyResponse>();
|
||||
|
||||
if (verifyRes.verified == false)
|
||||
{
|
||||
Console.WriteLine($"Failed to verify TOTP for {account.username}");
|
||||
return;
|
||||
Console.WriteLine($"TOTP required for {account.username}");
|
||||
if (account.totpSecret == null)
|
||||
{
|
||||
Console.WriteLine($"No TOTP secret found for {account.username}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
|
||||
var code = totp.ComputeTotp();
|
||||
if (code == null)
|
||||
{
|
||||
Console.WriteLine($"Failed to generate TOTP for {account.username}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
|
||||
verifyReq.Content = new StringContent($"{{\"code\":\"{code}\"}}", Encoding.UTF8, "application/json");
|
||||
var verifyResp = await httpClient.SendAsync(verifyReq);
|
||||
var verifyRes = await verifyResp.Content.ReadFromJsonAsync<TotpVerifyResponse>();
|
||||
|
||||
if (verifyRes?.verified != true)
|
||||
{
|
||||
Console.WriteLine($"Failed to verify TOTP for {account.username}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
curUserResp = await httpClient.GetAsync("/api/1/auth/user");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Using cached cookies for {account.username}");
|
||||
}
|
||||
|
||||
var curUserResp = await httpClient.GetAsync("/api/1/auth/user");
|
||||
var curUser = await curUserResp.Content.ReadFromJsonAsync<User>();
|
||||
Console.WriteLine($"Logged in as {curUser.displayName}");
|
||||
Console.WriteLine($"Logged in as {curUser?.displayName}");
|
||||
|
||||
// Store cookies in Redis
|
||||
var cookies = cookieContainer.GetAllCookies().Cast<Cookie>()
|
||||
.ToDictionary(c => c.Name, c => c.Value);
|
||||
await redisService.SaveAuthToken(account.username, cookies);
|
||||
|
||||
apiAccounts.Add(httpClient);
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
Console.WriteLine($"Failed to create API for {account.username}: {e.Message}, {e.StatusCode}, {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
LogInAllAccounts();
|
||||
|
||||
// Initialize services
|
||||
await LogInAllAccounts();
|
||||
|
||||
app.MapGet("/", () => $"Logged in with {apiAccounts.Count} accounts");
|
||||
app.MapGet("/rotate", () =>
|
||||
app.MapGet("/rotate", async () =>
|
||||
{
|
||||
RotateAccount();
|
||||
await RotateAccount();
|
||||
return "Rotated account";
|
||||
});
|
||||
|
||||
|
|
@ -243,12 +276,12 @@ async Task DoRequest(HttpContext context, bool retriedAlready = false, bool reAu
|
|||
return;
|
||||
}
|
||||
// re-login all accounts and try again
|
||||
LogInAllAccounts();
|
||||
await LogInAllAccounts();
|
||||
await DoRequest(context, true, true);
|
||||
return;
|
||||
}
|
||||
Console.Error.WriteLine($"Retrying request due to {response.StatusCode}");
|
||||
RotateAccount();
|
||||
await RotateAccount();
|
||||
await DoRequest(context, true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -287,6 +320,4 @@ app.Use(async (context, next) =>
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
app.Run();
|
||||
59
VRCAuthProxy/Services/RedisService.cs
Normal file
59
VRCAuthProxy/Services/RedisService.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace VRCAuthProxy.Services;
|
||||
|
||||
public class RedisService
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IDatabase _db;
|
||||
private const string AUTH_TOKEN_KEY = "vrcauthproxy:tokens";
|
||||
|
||||
public RedisService(string connectionString)
|
||||
{
|
||||
_redis = ConnectionMultiplexer.Connect(connectionString);
|
||||
_db = _redis.GetDatabase();
|
||||
}
|
||||
|
||||
// Constructor for testing with mocked dependencies
|
||||
internal RedisService(IConnectionMultiplexer redis, IDatabase database)
|
||||
{
|
||||
_redis = redis;
|
||||
_db = database;
|
||||
}
|
||||
|
||||
public virtual async Task SaveAuthToken(string username, Dictionary<string, string> cookies)
|
||||
{
|
||||
var serializedCookies = JsonSerializer.Serialize(cookies);
|
||||
await _db.HashSetAsync(AUTH_TOKEN_KEY, username, serializedCookies);
|
||||
}
|
||||
|
||||
public virtual async Task<Dictionary<string, string>?> GetAuthToken(string username)
|
||||
{
|
||||
var value = await _db.HashGetAsync(AUTH_TOKEN_KEY, username);
|
||||
if (!value.HasValue) return null;
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(value!);
|
||||
}
|
||||
|
||||
public virtual async Task<Dictionary<string, Dictionary<string, string>>> GetAllAuthTokens()
|
||||
{
|
||||
var entries = await _db.HashGetAllAsync(AUTH_TOKEN_KEY);
|
||||
var result = new Dictionary<string, Dictionary<string, string>>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var cookies = JsonSerializer.Deserialize<Dictionary<string, string>>(entry.Value!);
|
||||
if (cookies != null)
|
||||
{
|
||||
result[entry.Name!] = cookies;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public virtual async Task RemoveAuthToken(string username)
|
||||
{
|
||||
await _db.HashDeleteAsync(AUTH_TOKEN_KEY, username);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,9 @@
|
|||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.3.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.7.17" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
namespace VRCAuthProxy.types;
|
||||
|
||||
public struct TotpVerifyResponse
|
||||
public class TotpVerifyResponse
|
||||
{
|
||||
public bool verified { get; set; }
|
||||
}
|
||||
|
||||
public struct User
|
||||
public class User
|
||||
{
|
||||
public string displayName { get; set; }
|
||||
public string? displayName { get; set; }
|
||||
public bool? requiresTwoFactorAuth { get; set; }
|
||||
public bool? totp { get; set; }
|
||||
}
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
vrcauthproxy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- REDIS_CONNECTION=redis:6379
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
driver: local
|
||||
Loading…
Add table
Add a link
Reference in a new issue