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:
parent
eb4349031b
commit
30d631d246
6 changed files with 180 additions and 61 deletions
|
|
@ -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,102 +19,128 @@ 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}");
|
||||||
|
|
||||||
string encodedUsername = HttpUtility.UrlEncode(account.username);
|
// If we don't have stored cookies or they're invalid, perform login
|
||||||
string encodedPassword = HttpUtility.UrlEncode(account.password);
|
var curUserResp = await httpClient.GetAsync("/api/1/auth/user");
|
||||||
|
if (!curUserResp.IsSuccessStatusCode)
|
||||||
// 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($"TOTP required for {account.username}");
|
string encodedUsername = HttpUtility.UrlEncode(account.username);
|
||||||
if (account.totpSecret == null)
|
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}");
|
Console.WriteLine($"TOTP required for {account.username}");
|
||||||
return;
|
if (account.totpSecret == null)
|
||||||
}
|
{
|
||||||
|
Console.WriteLine($"No TOTP secret found for {account.username}");
|
||||||
// totp constructor needs a byte array decoded from the base32 secret
|
continue;
|
||||||
var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
|
}
|
||||||
var code = totp.ComputeTotp();
|
|
||||||
if (code == null)
|
var totp = new Totp(Base32Encoding.ToBytes(account.totpSecret.Replace(" ", "")));
|
||||||
{
|
var code = totp.ComputeTotp();
|
||||||
Console.WriteLine($"Failed to generate TOTP for {account.username}");
|
if (code == null)
|
||||||
return;
|
{
|
||||||
}
|
Console.WriteLine($"Failed to generate TOTP for {account.username}");
|
||||||
|
continue;
|
||||||
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 verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
|
||||||
var verifyResp = await httpClient.SendAsync(verifyReq);
|
verifyReq.Content = new StringContent($"{{\"code\":\"{code}\"}}", Encoding.UTF8, "application/json");
|
||||||
var verifyRes = await verifyResp.Content.ReadFromJsonAsync<TotpVerifyResponse>();
|
var verifyResp = await httpClient.SendAsync(verifyReq);
|
||||||
|
var verifyRes = await verifyResp.Content.ReadFromJsonAsync<TotpVerifyResponse>();
|
||||||
if (verifyRes.verified == false)
|
|
||||||
{
|
if (verifyRes?.verified != true)
|
||||||
Console.WriteLine($"Failed to verify TOTP for {account.username}");
|
{
|
||||||
return;
|
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>();
|
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 +270,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 +314,4 @@ app.Use(async (context, next) =>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
52
VRCAuthProxy/Services/RedisService.cs
Normal file
52
VRCAuthProxy/Services/RedisService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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