Files
gameauthapi/Services/SteamAuthService.cs
T

112 lines
5.8 KiB
C#

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<SteamAuthTransaction> _transactions;
private readonly IMongoCollection<ExternalIdentityLink> _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<SteamAuthTransaction>("SteamAuthTransactions");
_links = database.GetCollection<ExternalIdentityLink>("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<string, string>
{
["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<SteamAuthTransaction> GetAsync(string id) =>
await _transactions.Find(x => x.Id == id && x.ExpiresUtc > DateTime.UtcNow).FirstOrDefaultAsync();
public virtual async Task<bool> 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<SteamAuthTransaction>.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<SteamAuthTransaction>.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<SteamAuthTransaction>.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<SteamAuthTransaction>(
Builders<SteamAuthTransaction>.IndexKeys.Ascending(x => x.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "steam_transaction_ttl" }));
await _links.Indexes.CreateOneAsync(new CreateIndexModel<ExternalIdentityLink>(
Builders<ExternalIdentityLink>.IndexKeys.Ascending(x => x.Provider).Ascending(x => x.Subject),
new CreateIndexOptions { Unique = true, Name = "external_identity_unique" }));
}
}