From 30d631d246888b2c1c04b3406494d28760ce9406 Mon Sep 17 00:00:00 2001 From: MiscFrizzy Date: Mon, 7 Apr 2025 07:47:27 -0400 Subject: [PATCH] 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. --- VRCAuthProxy/HttpClientCookieContainer.cs | 10 +- VRCAuthProxy/Program.cs | 135 +++++++++++++--------- VRCAuthProxy/Services/RedisService.cs | 52 +++++++++ VRCAuthProxy/VRCAuthProxy.csproj | 4 +- VRCAuthProxy/types/API.cs | 8 +- docker-compose.yml | 32 +++++ 6 files changed, 180 insertions(+), 61 deletions(-) create mode 100644 VRCAuthProxy/Services/RedisService.cs create mode 100644 docker-compose.yml diff --git a/VRCAuthProxy/HttpClientCookieContainer.cs b/VRCAuthProxy/HttpClientCookieContainer.cs index 31203fa..e986781 100644 --- a/VRCAuthProxy/HttpClientCookieContainer.cs +++ b/VRCAuthProxy/HttpClientCookieContainer.cs @@ -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; + } } diff --git a/VRCAuthProxy/Program.cs b/VRCAuthProxy/Program.cs index 0e5af6e..c09fe1d 100644 --- a/VRCAuthProxy/Program.cs +++ b/VRCAuthProxy/Program.cs @@ -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(); - - +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() + .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(); - - 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(); + + 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(); - Console.WriteLine($"Logged in as {curUser.displayName}"); + Console.WriteLine($"Logged in as {curUser?.displayName}"); + + // Store cookies in Redis + var cookies = cookieContainer.GetAllCookies().Cast() + .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(); \ No newline at end of file diff --git a/VRCAuthProxy/Services/RedisService.cs b/VRCAuthProxy/Services/RedisService.cs new file mode 100644 index 0000000..ca253fd --- /dev/null +++ b/VRCAuthProxy/Services/RedisService.cs @@ -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 cookies) + { + var serializedCookies = JsonSerializer.Serialize(cookies); + await _db.HashSetAsync(AUTH_TOKEN_KEY, username, serializedCookies); + } + + public async Task?> GetAuthToken(string username) + { + var value = await _db.HashGetAsync(AUTH_TOKEN_KEY, username); + if (!value.HasValue) return null; + return JsonSerializer.Deserialize>(value!); + } + + public async Task>> GetAllAuthTokens() + { + var entries = await _db.HashGetAllAsync(AUTH_TOKEN_KEY); + var result = new Dictionary>(); + + foreach (var entry in entries) + { + var cookies = JsonSerializer.Deserialize>(entry.Value!); + if (cookies != null) + { + result[entry.Name!] = cookies; + } + } + + return result; + } + + public async Task RemoveAuthToken(string username) + { + await _db.HashDeleteAsync(AUTH_TOKEN_KEY, username); + } +} \ No newline at end of file diff --git a/VRCAuthProxy/VRCAuthProxy.csproj b/VRCAuthProxy/VRCAuthProxy.csproj index 31e4311..9d8de9e 100644 --- a/VRCAuthProxy/VRCAuthProxy.csproj +++ b/VRCAuthProxy/VRCAuthProxy.csproj @@ -16,7 +16,9 @@ - + + + diff --git a/VRCAuthProxy/types/API.cs b/VRCAuthProxy/types/API.cs index 2c683f6..ad80ede 100644 --- a/VRCAuthProxy/types/API.cs +++ b/VRCAuthProxy/types/API.cs @@ -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; } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..47b647e --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file