feat: An Initias Redis Store Implementation

- **docker-compose.yml**
  - Added Redis service configuration to support token storage.
  - Set up health checks and volume for Redis persistence.
  - Configured VRCAuthProxy service to depend on Redis.

- **HttpClientCookieContainer.cs**
  - Added `Username` property to support user-specific token management.

- **Program.cs**
  - Integrated Redis for storing and retrieving authentication tokens.
  - Updated login and token rotation logic to utilize Redis.
  - Improved async/await usage for better reliability.

- **VRCAuthProxy.csproj**
  - Added `StackExchange.Redis` package for Redis connectivity.
  - Corrected `Otp.NET` package reference.

- **API.cs**
  - Updated `TotpVerifyResponse` and `User` classes to be nullable-aware.

- **RedisService.cs**
  - Implemented Redis service for managing authentication tokens.
  - Added methods for saving, retrieving, and deleting tokens.
This commit is contained in:
MiscFrizzy 2025-04-07 07:47:27 -04:00
parent eb4349031b
commit 30d631d246
6 changed files with 180 additions and 61 deletions

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,128 @@ 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)
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");
}
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 +270,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 +314,4 @@ app.Use(async (context, next) =>
}
});
app.Run();

View file

@ -0,0 +1,52 @@
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();
}
public async Task SaveAuthToken(string username, Dictionary<string, string> cookies)
{
var serializedCookies = JsonSerializer.Serialize(cookies);
await _db.HashSetAsync(AUTH_TOKEN_KEY, username, serializedCookies);
}
public 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 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 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