using System.Security.Cryptography; using GameAuthApi.Models; using MongoDB.Driver; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; namespace GameAuthApi.Services; public class SteamAuthService { private const string Provider = "steam"; private readonly IMongoCollection _transactions; private readonly IMongoCollection _links; private readonly IConfiguration _config; private readonly HttpClient _http; public SteamAuthService(IConfiguration config, IHttpClientFactory factory) { _config = config; _http = factory.CreateClient(); var client = new MongoClient(config["MongoDB:ConnectionString"]); var database = client.GetDatabase(config["MongoDB:DatabaseName"]); _transactions = database.GetCollection("SteamAuthTransactions"); _links = database.GetCollection("ExternalIdentityLinks"); } public virtual async Task<(SteamAuthTransaction Transaction, string AuthUrl)> StartAsync() { var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); var transaction = new SteamAuthTransaction { Id = id, CreatedUtc = DateTime.UtcNow, ExpiresUtc = DateTime.UtcNow.AddMinutes(10) }; await _transactions.InsertOneAsync(transaction); var baseUrl = (_config["Auth:PublicBaseUrl"] ?? "https://pauth.ranaze.com").TrimEnd('/'); var returnTo = $"{baseUrl}/api/Auth/steam/callback?transactionId={Uri.EscapeDataString(id)}"; var realm = baseUrl + "/"; var query = new Dictionary { ["openid.ns"] = "http://specs.openid.net/auth/2.0", ["openid.mode"] = "checkid_setup", ["openid.return_to"] = returnTo, ["openid.realm"] = realm, ["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select", ["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select" }; var authUrl = "https://steamcommunity.com/openid/login?" + string.Join("&", query.Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value)}")); return (transaction, authUrl); } public virtual async Task GetAsync(string id) => await _transactions.Find(x => x.Id == id && x.ExpiresUtc > DateTime.UtcNow).FirstOrDefaultAsync(); public virtual async Task VerifyCallbackAsync(string transactionId, IQueryCollection query) { var transaction = await GetAsync(transactionId); if (transaction == null) return false; var payload = query.Where(x => x.Key.StartsWith("openid.")).ToDictionary(x => x.Key, x => x.Value.ToString()); payload["openid.mode"] = "check_authentication"; using var response = await _http.PostAsync("https://steamcommunity.com/openid/login", new FormUrlEncodedContent(payload)); var result = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode || !result.Contains("is_valid:true", StringComparison.Ordinal)) return false; var claimed = query["openid.claimed_id"].ToString(); const string prefix = "https://steamcommunity.com/openid/id/"; if (!claimed.StartsWith(prefix, StringComparison.Ordinal) || !claimed[prefix.Length..].All(char.IsDigit)) return false; var steamId = claimed[prefix.Length..]; var link = await _links.Find(x => x.Provider == Provider && x.Subject == steamId).FirstOrDefaultAsync(); await _transactions.UpdateOneAsync(x => x.Id == transactionId, Builders.Update .Set(x => x.SteamId, steamId) .Set(x => x.UserId, link?.UserId) .Set(x => x.Status, link == null ? "requires_account" : "authenticated")); return true; } public virtual async Task LinkAsync(string transactionId, string userId) { var transaction = await GetAsync(transactionId) ?? throw new InvalidOperationException("Steam transaction expired."); if (transaction.Status != "requires_account" || string.IsNullOrWhiteSpace(transaction.SteamId)) throw new InvalidOperationException("Steam transaction is not ready."); await _links.InsertOneAsync(new ExternalIdentityLink { Provider = Provider, Subject = transaction.SteamId, UserId = userId, CreatedUtc = DateTime.UtcNow }); await _transactions.UpdateOneAsync(x => x.Id == transactionId, Builders.Update.Set(x => x.UserId, userId).Set(x => x.Status, "authenticated")); } public virtual async Task CompleteAsync(string transactionId, User user, string accessToken, string refreshToken) { await _transactions.UpdateOneAsync(x => x.Id == transactionId, Builders.Update .Set(x => x.Status, "complete") .Set(x => x.AccessToken, accessToken) .Set(x => x.RefreshToken, refreshToken) .Set(x => x.Username, user.Username) .Set(x => x.Role, user.Role)); } public async Task EnsureIndexesAsync() { await _transactions.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.ExpiresUtc), new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "steam_transaction_ttl" })); await _links.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Provider).Ascending(x => x.Subject), new CreateIndexOptions { Unique = true, Name = "external_identity_unique" })); } }