From 22c2de9cc6cbd1e7a1a1f1552c4c07c3d73c057f Mon Sep 17 00:00:00 2001 From: Lillith Fox Date: Mon, 11 Nov 2024 14:15:51 -0500 Subject: [PATCH] Initial Commit --- .dockerignore | 25 ++++ .github/workflows/build.yml | 31 +++++ .gitignore | 45 ++++++ Models/VrcAuthProxyFlow.drawio | 56 ++++++++ Models/VrcAuthProxyModels.drawio | 101 ++++++++++++++ VRCAuthProxy.sln | 16 +++ VRCAuthProxy/ApiClientWithCookies.cs | 12 ++ VRCAuthProxy/Config.cs | 62 +++++++++ VRCAuthProxy/Dockerfile | 21 +++ VRCAuthProxy/Program.cs | 184 +++++++++++++++++++++++++ VRCAuthProxy/VRCAuthProxy.csproj | 32 +++++ VRCAuthProxy/appsettings.template.json | 9 ++ 12 files changed, 594 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Models/VrcAuthProxyFlow.drawio create mode 100644 Models/VrcAuthProxyModels.drawio create mode 100644 VRCAuthProxy.sln create mode 100644 VRCAuthProxy/ApiClientWithCookies.cs create mode 100644 VRCAuthProxy/Config.cs create mode 100644 VRCAuthProxy/Dockerfile create mode 100644 VRCAuthProxy/Program.cs create mode 100644 VRCAuthProxy/VRCAuthProxy.csproj create mode 100644 VRCAuthProxy/appsettings.template.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad4c8aa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: Build Docker Image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + push: true + file: ./VRCAuthProxy/Dockerfile + platforms: linux/amd64 + tags: ghcr.io/pridevrcommunity/VRCAuthProxy:latest + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de01230 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode/ + +# Rider +.idea/ + +# Visual Studio +.vs/ + +# Fleet +.fleet/ + +# Code Rush +.cr/ + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +VRCAuthProxy/appsettings.json \ No newline at end of file diff --git a/Models/VrcAuthProxyFlow.drawio b/Models/VrcAuthProxyFlow.drawio new file mode 100644 index 0000000..a5ea798 --- /dev/null +++ b/Models/VrcAuthProxyFlow.drawio @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Models/VrcAuthProxyModels.drawio b/Models/VrcAuthProxyModels.drawio new file mode 100644 index 0000000..15507f2 --- /dev/null +++ b/Models/VrcAuthProxyModels.drawio @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VRCAuthProxy.sln b/VRCAuthProxy.sln new file mode 100644 index 0000000..0301a3e --- /dev/null +++ b/VRCAuthProxy.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCAuthProxy", "VRCAuthProxy\VRCAuthProxy.csproj", "{BB549439-B34F-4BCA-B8FC-173114631646}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BB549439-B34F-4BCA-B8FC-173114631646}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB549439-B34F-4BCA-B8FC-173114631646}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB549439-B34F-4BCA-B8FC-173114631646}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB549439-B34F-4BCA-B8FC-173114631646}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/VRCAuthProxy/ApiClientWithCookies.cs b/VRCAuthProxy/ApiClientWithCookies.cs new file mode 100644 index 0000000..a3526d8 --- /dev/null +++ b/VRCAuthProxy/ApiClientWithCookies.cs @@ -0,0 +1,12 @@ +using System.Net; +using VRChat.API.Client; + +namespace VRCAuthProxy; + +public class ApiClientWithCookies : ApiClient +{ + public CookieContainer GetCookieContainer() + { + return CookieContainer; + } +} \ No newline at end of file diff --git a/VRCAuthProxy/Config.cs b/VRCAuthProxy/Config.cs new file mode 100644 index 0000000..d68abee --- /dev/null +++ b/VRCAuthProxy/Config.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace VRCAuthProxy; + +public class ConfigAccount +{ + public string username { get; set; } + public string password { get; set; } + public string? totpSecret { get; set; } +} + +public class iConfig +{ + public List accounts { get; set; } +} + +// Load config from appsettings.json +public class Config +{ + private static Config? _instance; + public List Accounts { get; set; } + + public static Config Instance + { + get + { + if (_instance == null) _instance = Load(); + return _instance; + } + } + + public static Config Load() + { + var config = new Config(); + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (File.Exists(configPath)) + { + var json = File.ReadAllText(configPath); + // load iConfig + var iConfig = JsonSerializer.Deserialize(json); + if (iConfig == null) throw new Exception("Failed to load config"); + config.Accounts = iConfig.accounts; + } + else + { + Console.WriteLine("No config found at " + configPath); + } + + return config; + } + + public void Save() + { + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + var iConfig = new iConfig + { + accounts = Accounts + }; + var json = JsonSerializer.Serialize(iConfig, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(configPath, json); + } +} \ No newline at end of file diff --git a/VRCAuthProxy/Dockerfile b/VRCAuthProxy/Dockerfile new file mode 100644 index 0000000..264b8be --- /dev/null +++ b/VRCAuthProxy/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["VRCAuthProxy/VRCAuthProxy.csproj", "VRCAuthProxy/"] +RUN dotnet restore "VRCAuthProxy/VRCAuthProxy.csproj" +COPY . . +WORKDIR "/src/VRCAuthProxy" +RUN dotnet build "VRCAuthProxy.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "VRCAuthProxy.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "VRCAuthProxy.dll"] diff --git a/VRCAuthProxy/Program.cs b/VRCAuthProxy/Program.cs new file mode 100644 index 0000000..4c53869 --- /dev/null +++ b/VRCAuthProxy/Program.cs @@ -0,0 +1,184 @@ +// See https://aka.ms/new-console-template for more information + +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using OtpNet; +using VRCAuthProxy; +using VRChat.API.Api; +using VRChat.API.Client; +using VRChat.API.Model; +using HttpMethod = System.Net.Http.HttpMethod; + +var httpClient = new HttpClient(); + +var apiAccounts = new List(); + + + +// Push the first account to the end of the list +void RotateAccount() +{ + var account = Config.Instance.Accounts.First(); + Config.Instance.Accounts.Remove(account); + Config.Instance.Accounts.Add(account); +} + + +String UserAgent = "VRCAuthProxy V1.0.0 (https://github.com/PrideVRCommunity/VRCAuthProxy)"; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +Config.Instance.Accounts.ForEach(async account => +{ + try + { + Console.WriteLine($"Creating API for {account.username}"); + // clone base config + var config = new Configuration(); + config.UserAgent = UserAgent; + config.BasePath = "https://api.vrchat.cloud/api/1"; + config.Username = account.username; + config.Password = account.password; + + // create api client + var api = new ApiClientWithCookies(); + var authApi = new AuthenticationApi(api, api, config); + var authResp = authApi.GetCurrentUserWithHttpInfo(); + if (authResp.RawContent.Contains("totp")) + { + Console.WriteLine($"TOTP required for {account.username}"); + if (account.totpSecret == null) + { + 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 verifyRes = authApi.Verify2FA(new TwoFactorAuthCode(code)); + if (verifyRes == null || verifyRes.Verified == false) + { + Console.WriteLine($"Failed to verify TOTP for {account.username}"); + return; + } + + } + + var curUser = authApi.GetCurrentUser(); + if (curUser == null) throw new Exception("Failed to get current user"); + Console.WriteLine($"Logged in as {curUser.DisplayName}"); + apiAccounts.Add(api); + } catch (ApiException e) + { + Console.WriteLine($"Failed to create API for {account.username}: {e.Message}, {e.ErrorCode}, {e}"); + } +}); + +app.MapGet("/", () => $"Logged in with {apiAccounts.Count} accounts"); +app.MapGet("/rotate", () => +{ + RotateAccount(); + return "Rotated account"; +}); +// Proxy all requests starting with /api/1 to the VRChat API +app.Use(async (context, next) => +{ + if (context.Request.Path.StartsWithSegments("/api/1")) + { + if (apiAccounts.Count == 0) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("No accounts available"); + return; + } + + var account = apiAccounts.First(); + var requestOpts = new RequestOptions(); + requestOpts.Operation = context.Request.Method; + var path = context.Request.Path.ToString().Replace("/api/1", "") + context.Request.QueryString; + + var message = new HttpRequestMessage + { + RequestUri = new Uri("https://api.vrchat.cloud/api/1" + path), + Method = new HttpMethod(context.Request.Method) + }; + + // Add common headers to request message + message.Headers.Add("User-Agent", UserAgent); + message.Headers.Add("Cookie", account.GetCookieContainer().GetCookieHeader(new Uri("https://api.vrchat.cloud"))); + + // Handle request body for methods that support content (POST, PUT, DELETE) + if (context.Request.ContentLength > 0 || context.Request.Headers.ContainsKey("Transfer-Encoding")) + { + message.Content = new StreamContent(context.Request.Body); + + // Add content-specific headers to message.Content.Headers + foreach (var header in context.Request.Headers) + { + if (header.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || + header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) || + header.Key.Equals("Content-Disposition", StringComparison.OrdinalIgnoreCase)) + { + message.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + else if (!message.Headers.Contains(header.Key) && header.Key != "Host") + { + // Add non-content headers to message.Headers + message.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + } + else + { + // Add non-body headers to message.Headers for GET and other bodyless requests + foreach (var header in context.Request.Headers) + { + if (!message.Headers.Contains(header.Key) && header.Key != "Host") + { + message.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + } + + // Send the request + var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead); + + // Copy response status code and headers + context.Response.StatusCode = (int)response.StatusCode; + foreach (var header in response.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + foreach (var header in response.Content.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + context.Response.Headers.Remove("transfer-encoding"); + + // Copy response content to the response body + using (var responseStream = await response.Content.ReadAsStreamAsync()) + using (var memoryStream = new MemoryStream()) + { + await responseStream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + await memoryStream.CopyToAsync(context.Response.Body); + } + } + else + { + await next(); + } +}); + + + +app.Run(); \ No newline at end of file diff --git a/VRCAuthProxy/VRCAuthProxy.csproj b/VRCAuthProxy/VRCAuthProxy.csproj new file mode 100644 index 0000000..e59f137 --- /dev/null +++ b/VRCAuthProxy/VRCAuthProxy.csproj @@ -0,0 +1,32 @@ + + + + Exe + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + Always + + + Always + + + + diff --git a/VRCAuthProxy/appsettings.template.json b/VRCAuthProxy/appsettings.template.json new file mode 100644 index 0000000..ab37772 --- /dev/null +++ b/VRCAuthProxy/appsettings.template.json @@ -0,0 +1,9 @@ +{ + "accounts": [ + { + "username": "username", + "password": "password", + "totpSecret": "totp secret" + } + ] +} \ No newline at end of file