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:
dotfrizzy 2025-05-20 22:12:00 +02:00 committed by git.gay
commit fadc13b6e6
No known key found for this signature in database
GPG key ID: BDA2A7586B5E1432
13 changed files with 680 additions and 71 deletions

View file

@ -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

View file

@ -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

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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();
}
}
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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();

View 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);
}
}

View file

@ -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>

View file

@ -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
View 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