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
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
tags: |
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
ghcr.io/pridevrinc/vrcauthproxy:latest
|
|
||||||
ghcr.io/pridevrinc/vrcauthproxy:${{ github.ref }} # Push the tagged version
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
USER $APP_UID
|
||||||
WORKDIR /app
|
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
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["VRCAuthProxy/VRCAuthProxy.csproj", "VRCAuthProxy/"]
|
COPY ["VRCAuthProxy/VRCAuthProxy.csproj", "VRCAuthProxy/"]
|
||||||
|
|
@ -15,7 +15,7 @@ FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
RUN dotnet publish "VRCAuthProxy.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
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 && \
|
RUN apt update && \
|
||||||
apt install -y curl
|
apt install -y curl
|
||||||
WORKDIR /app
|
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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.6.6" />
|
<PackageReference Include="xunit" Version="2.6.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
<PackageReference Include="Moq" Version="4.20.70" />
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
|
|
||||||
namespace VRCAuthProxy;
|
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 Microsoft.AspNetCore.Http;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
using VRCAuthProxy;
|
using VRCAuthProxy;
|
||||||
|
using VRCAuthProxy.Services;
|
||||||
using VRCAuthProxy.types;
|
using VRCAuthProxy.types;
|
||||||
using HttpMethod = System.Net.Http.HttpMethod;
|
using HttpMethod = System.Net.Http.HttpMethod;
|
||||||
using User = VRCAuthProxy.types.User;
|
using User = VRCAuthProxy.types.User;
|
||||||
|
|
@ -18,43 +19,63 @@ using User = VRCAuthProxy.types.User;
|
||||||
string userAgent = "VRCAuthProxy V1.0.0 (https://github.com/PrideVRCommunity/VRCAuthProxy)";
|
string userAgent = "VRCAuthProxy V1.0.0 (https://github.com/PrideVRCommunity/VRCAuthProxy)";
|
||||||
|
|
||||||
var apiAccounts = new List<HttpClientCookieContainer>();
|
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
|
// Push the first account to the end of the list
|
||||||
void RotateAccount()
|
async Task RotateAccount()
|
||||||
{
|
{
|
||||||
var account = apiAccounts.First();
|
var account = apiAccounts.First();
|
||||||
apiAccounts.Remove(account);
|
apiAccounts.Remove(account);
|
||||||
apiAccounts.Add(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 builder = WebApplication.CreateBuilder(args);
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseWebSockets();
|
app.UseWebSockets();
|
||||||
|
|
||||||
void LogInAllAccounts()
|
async Task LogInAllAccounts()
|
||||||
{
|
{
|
||||||
Config.Instance.Accounts.ForEach(async account =>
|
foreach (var account in Config.Instance.Accounts)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cookieContainer = new CookieContainer();
|
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
|
var handler = new HttpClientHandler
|
||||||
{
|
{
|
||||||
CookieContainer = cookieContainer
|
CookieContainer = cookieContainer
|
||||||
};
|
};
|
||||||
var httpClient = new HttpClientCookieContainer(handler)
|
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);
|
httpClient.DefaultRequestHeaders.Add("User-Agent", userAgent);
|
||||||
|
|
||||||
Console.WriteLine($"Creating API for {account.username}");
|
Console.WriteLine($"Creating API for {account.username}");
|
||||||
|
|
||||||
|
// 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($"Unable to use cached cookies for {account.username}. Getting new ones...");
|
||||||
|
|
||||||
string encodedUsername = HttpUtility.UrlEncode(account.username);
|
string encodedUsername = HttpUtility.UrlEncode(account.username);
|
||||||
string encodedPassword = HttpUtility.UrlEncode(account.password);
|
string encodedPassword = HttpUtility.UrlEncode(account.password);
|
||||||
|
|
||||||
|
|
@ -65,55 +86,67 @@ void LogInAllAccounts()
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
||||||
var authResp = await httpClient.SendAsync(request);
|
var authResp = await httpClient.SendAsync(request);
|
||||||
|
|
||||||
if ((await authResp.Content.ReadAsStringAsync()).Contains("totp"))
|
if ((await authResp.Content.ReadAsStringAsync()).Contains("totp"))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"TOTP required for {account.username}");
|
Console.WriteLine($"TOTP required for {account.username}");
|
||||||
if (account.totpSecret == null)
|
if (account.totpSecret == null)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"No TOTP secret found for {account.username}");
|
Console.WriteLine($"No TOTP secret found for {account.username}");
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// totp constructor needs a byte array decoded from the base32 secret
|
|
||||||
var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
|
var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
|
||||||
var code = totp.ComputeTotp();
|
var code = totp.ComputeTotp();
|
||||||
if (code == null)
|
if (code == null)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Failed to generate TOTP for {account.username}");
|
Console.WriteLine($"Failed to generate TOTP for {account.username}");
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
|
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");
|
verifyReq.Content = new StringContent($"{{\"code\":\"{code}\"}}", Encoding.UTF8, "application/json");
|
||||||
var verifyResp = await httpClient.SendAsync(verifyReq);
|
var verifyResp = await httpClient.SendAsync(verifyReq);
|
||||||
var verifyRes = await verifyResp.Content.ReadFromJsonAsync<TotpVerifyResponse>();
|
var verifyRes = await verifyResp.Content.ReadFromJsonAsync<TotpVerifyResponse>();
|
||||||
|
|
||||||
if (verifyRes.verified == false)
|
if (verifyRes?.verified != true)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Failed to verify TOTP for {account.username}");
|
Console.WriteLine($"Failed to verify TOTP for {account.username}");
|
||||||
return;
|
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>();
|
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);
|
apiAccounts.Add(httpClient);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException e)
|
catch (HttpRequestException e)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Failed to create API for {account.username}: {e.Message}, {e.StatusCode}, {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("/", () => $"Logged in with {apiAccounts.Count} accounts");
|
||||||
app.MapGet("/rotate", () =>
|
app.MapGet("/rotate", async () =>
|
||||||
{
|
{
|
||||||
RotateAccount();
|
await RotateAccount();
|
||||||
return "Rotated account";
|
return "Rotated account";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -243,12 +276,12 @@ async Task DoRequest(HttpContext context, bool retriedAlready = false, bool reAu
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// re-login all accounts and try again
|
// re-login all accounts and try again
|
||||||
LogInAllAccounts();
|
await LogInAllAccounts();
|
||||||
await DoRequest(context, true, true);
|
await DoRequest(context, true, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Console.Error.WriteLine($"Retrying request due to {response.StatusCode}");
|
Console.Error.WriteLine($"Retrying request due to {response.StatusCode}");
|
||||||
RotateAccount();
|
await RotateAccount();
|
||||||
await DoRequest(context, true);
|
await DoRequest(context, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +320,4 @@ app.Use(async (context, next) =>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.Run();
|
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>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
namespace VRCAuthProxy.types;
|
namespace VRCAuthProxy.types;
|
||||||
|
|
||||||
public struct TotpVerifyResponse
|
public class TotpVerifyResponse
|
||||||
{
|
{
|
||||||
public bool verified { get; set; }
|
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