From 63730dbba0e01b1c36ef7f5e96ba89ac7d389000 Mon Sep 17 00:00:00 2001 From: Holden <122419606+midozen@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:48:19 -0500 Subject: [PATCH] Add Steam auth, platform validators & name gen --- .../Profiles/Requests/LoginRequest.cs | 1 - .../Neutrino/NeutrinoController.cs | 2 +- .../Common/Interfaces/IConfigService.cs | 3 +- .../Interfaces/IPlatformAuthValidator.cs | 14 + .../Common/Interfaces/ISteamAuthService.cs | 11 + .../Common/PlatformAuth/PlatformAuthResult.cs | 21 ++ .../Common/Steam/SteamAuthResult.cs | 21 ++ RecNet.Application/DependencyInjection.cs | 3 + .../Neutrino/NeutrinoAuthorizationService.cs | 4 +- RecNet.Application/Profiles/NameGenerator.cs | 17 ++ RecNet.Application/Profiles/ProfileDto.cs | 4 + RecNet.Application/Profiles/ProfileService.cs | 33 +- RecNet.Application/RecNet.Application.csproj | 4 + .../Configuration}/IServerConfigRepository.cs | 0 .../Profiles}/IProfileRepository.cs | 0 .../Profiles/Names/DefaultNameGenConfig.cs | 288 ++++++++++++++++++ .../Entities/Profiles/Names/NameGenConfig.cs | 7 + RecNet.Infrastructure/DependencyInjection.cs | 12 + .../RecNet.Infrastructure.csproj | 2 + .../Services/Configuration/ConfigService.cs | 15 +- .../Services/Steam/SteamAuthService.cs | 89 ++++++ .../Services/Steam/SteamAuthValidator.cs | 48 +++ .../Services/Steam/SteamOptions.cs | 8 + .../Services/Tokens/TokenService.cs | 2 +- 24 files changed, 592 insertions(+), 17 deletions(-) create mode 100644 RecNet.Application/Common/Interfaces/IPlatformAuthValidator.cs create mode 100644 RecNet.Application/Common/Interfaces/ISteamAuthService.cs create mode 100644 RecNet.Application/Common/PlatformAuth/PlatformAuthResult.cs create mode 100644 RecNet.Application/Common/Steam/SteamAuthResult.cs create mode 100644 RecNet.Application/Profiles/NameGenerator.cs rename RecNet.Domain/{Repositories => Entities/Configuration}/IServerConfigRepository.cs (100%) rename RecNet.Domain/{Repositories => Entities/Profiles}/IProfileRepository.cs (100%) create mode 100644 RecNet.Domain/Entities/Profiles/Names/DefaultNameGenConfig.cs create mode 100644 RecNet.Domain/Entities/Profiles/Names/NameGenConfig.cs create mode 100644 RecNet.Infrastructure/Services/Steam/SteamAuthService.cs create mode 100644 RecNet.Infrastructure/Services/Steam/SteamAuthValidator.cs create mode 100644 RecNet.Infrastructure/Services/Steam/SteamOptions.cs diff --git a/API/Contracts/Profiles/Requests/LoginRequest.cs b/API/Contracts/Profiles/Requests/LoginRequest.cs index 91430e4..eb07412 100644 --- a/API/Contracts/Profiles/Requests/LoginRequest.cs +++ b/API/Contracts/Profiles/Requests/LoginRequest.cs @@ -9,5 +9,4 @@ public class LoginRequest public required string PlatformAuthentication { get; set; } public required string PlatformId { get; set; } public PlatformType PlatformType { get; set; } - public required string Username { get; set; } } \ No newline at end of file diff --git a/API/Controllers/Neutrino/NeutrinoController.cs b/API/Controllers/Neutrino/NeutrinoController.cs index b8e2965..79a5e8a 100644 --- a/API/Controllers/Neutrino/NeutrinoController.cs +++ b/API/Controllers/Neutrino/NeutrinoController.cs @@ -46,7 +46,7 @@ public class NeutrinoController(INeutrinoAuthorizationService authorizationServi request.ProfileId, request.AccessToken), ct); - + if (!result.Succeeded) return Ok( NeutrinoAuthenticateResponse.Failure( diff --git a/RecNet.Application/Common/Interfaces/IConfigService.cs b/RecNet.Application/Common/Interfaces/IConfigService.cs index 998cefa..f9c11e9 100644 --- a/RecNet.Application/Common/Interfaces/IConfigService.cs +++ b/RecNet.Application/Common/Interfaces/IConfigService.cs @@ -2,6 +2,7 @@ namespace RecNet.Application.Common.Interfaces; public interface IConfigService { - Task GetAsync(string key, T? defaultValue = default, CancellationToken ct = default); + Task GetAsync(string key, CancellationToken ct = default); + Task GetAsync(string key, T defaultValue, CancellationToken ct = default); Task SetAsync(string key, T value, CancellationToken ct = default); } diff --git a/RecNet.Application/Common/Interfaces/IPlatformAuthValidator.cs b/RecNet.Application/Common/Interfaces/IPlatformAuthValidator.cs new file mode 100644 index 0000000..03bf241 --- /dev/null +++ b/RecNet.Application/Common/Interfaces/IPlatformAuthValidator.cs @@ -0,0 +1,14 @@ +using RecNet.Application.Common.PlatformAuth; +using RecNet.Domain.Common; + +namespace RecNet.Application.Common.Interfaces; + +public interface IPlatformAuthValidator +{ + PlatformType PlatformType { get; } + + Task ValidateAsync( + string platformAuthentication, + string platformId, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/RecNet.Application/Common/Interfaces/ISteamAuthService.cs b/RecNet.Application/Common/Interfaces/ISteamAuthService.cs new file mode 100644 index 0000000..55ffc0b --- /dev/null +++ b/RecNet.Application/Common/Interfaces/ISteamAuthService.cs @@ -0,0 +1,11 @@ +using RecNet.Application.Common.Steam; + +namespace RecNet.Application.Common.Interfaces; + +public interface ISteamAuthService +{ + Task AuthorizeAsync( + string ticket, + uint appId, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/RecNet.Application/Common/PlatformAuth/PlatformAuthResult.cs b/RecNet.Application/Common/PlatformAuth/PlatformAuthResult.cs new file mode 100644 index 0000000..122e4ab --- /dev/null +++ b/RecNet.Application/Common/PlatformAuth/PlatformAuthResult.cs @@ -0,0 +1,21 @@ +namespace RecNet.Application.Common.PlatformAuth; + +public class PlatformAuthResult +{ + public bool Succeeded { get; private set; } + public string? Message { get; private set; } + public string? Name { get; private set; } + + private PlatformAuthResult(bool succeeded, string? message, string? name) + { + Succeeded = succeeded; + Message = message; + Name = name; + } + + public static PlatformAuthResult Success(string? name) + => new(true, null, name); + + public static PlatformAuthResult Failure(string? message = null) + => new(false, message, null); +} \ No newline at end of file diff --git a/RecNet.Application/Common/Steam/SteamAuthResult.cs b/RecNet.Application/Common/Steam/SteamAuthResult.cs new file mode 100644 index 0000000..5816d51 --- /dev/null +++ b/RecNet.Application/Common/Steam/SteamAuthResult.cs @@ -0,0 +1,21 @@ +namespace RecNet.Application.Common.Steam; + +public class SteamAuthResult +{ + public bool Succeeded { get; private set; } + public string? SteamId { get; private set; } + public string? DisplayName { get; private set; } + + private SteamAuthResult(bool succeeded, string? steamId, string? displayName) + { + Succeeded = succeeded; + SteamId = steamId; + DisplayName = displayName; + } + + public static SteamAuthResult Success(string steamId, string? displayName) + => new(true, steamId, displayName); + + public static SteamAuthResult Failure() + => new (false, null, null); +} \ No newline at end of file diff --git a/RecNet.Application/DependencyInjection.cs b/RecNet.Application/DependencyInjection.cs index d4cbfdc..34fd06c 100644 --- a/RecNet.Application/DependencyInjection.cs +++ b/RecNet.Application/DependencyInjection.cs @@ -9,6 +9,9 @@ public static class DependencyInjection public static IServiceCollection AddApplication(this IServiceCollection services) { services.AddAutoMapper(_ => { }, typeof(DependencyInjection).Assembly); + + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs b/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs index 7149954..30da4e0 100644 --- a/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs +++ b/RecNet.Application/Neutrino/NeutrinoAuthorizationService.cs @@ -22,9 +22,9 @@ public class NeutrinoAuthorizationService( return NeutrinoAuthorizationResult.AuthenticationFailed(); var profile = await profileRepository.GetByIdAsync(command.ProfileId, ct); - if (profile is null) + if (profile is null || profile.IsBanned) return NeutrinoAuthorizationResult.AuthenticationFailed(); - + return NeutrinoAuthorizationResult.Success(profile.ProfileId, profile.Name); } } diff --git a/RecNet.Application/Profiles/NameGenerator.cs b/RecNet.Application/Profiles/NameGenerator.cs new file mode 100644 index 0000000..cf6a600 --- /dev/null +++ b/RecNet.Application/Profiles/NameGenerator.cs @@ -0,0 +1,17 @@ +using RecNet.Application.Common.Interfaces; +using RecNet.Domain.Entities.Profiles.Names; + +namespace RecNet.Application.Profiles; + +public class NameGenerator(IConfigService configService) +{ + public async Task GenerateAsync(CancellationToken ct = default) + { + var config = await configService.GetAsync("Profiles:NameGen", DefaultNameGenConfig.Value, ct); + + var adjective = config.Adjectives[Random.Shared.Next(config.Adjectives.Count)]; + var noun = config.Nouns[Random.Shared.Next(config.Nouns.Count)]; + + return $"{adjective}{noun}"; + } +} \ No newline at end of file diff --git a/RecNet.Application/Profiles/ProfileDto.cs b/RecNet.Application/Profiles/ProfileDto.cs index 7da37f7..c774831 100644 --- a/RecNet.Application/Profiles/ProfileDto.cs +++ b/RecNet.Application/Profiles/ProfileDto.cs @@ -1,8 +1,12 @@ +using System.Text.Json.Serialization; + namespace RecNet.Application.Profiles; public class ProfileDTO { + [JsonPropertyName("ProfileId")] public Guid ProfileId { get; init; } + [JsonPropertyName("Name")] public required string Name { get; init; } } diff --git a/RecNet.Application/Profiles/ProfileService.cs b/RecNet.Application/Profiles/ProfileService.cs index 5bf93d3..2b9c20a 100644 --- a/RecNet.Application/Profiles/ProfileService.cs +++ b/RecNet.Application/Profiles/ProfileService.cs @@ -6,8 +6,11 @@ using ProfileEntity = RecNet.Domain.Entities.Profiles.Profile; namespace RecNet.Application.Profiles; public class ProfileService( + NameGenerator nameGenerator, IProfileRepository profileRepository, ITokenService tokenService, + IConfigService configService, + IEnumerable validators, IMapper mapper) : IProfileService { public async Task GetProfileAsync(Guid profileId, CancellationToken ct = default) @@ -32,6 +35,19 @@ public class ProfileService( LoginProfileCommand command, CancellationToken ct = default) { + var validator = validators.FirstOrDefault(x => x.PlatformType == command.PlatformType); + if (validator is null) + return LoginProfileResult.Fail("Invalid platform type"); + + var ignoreAuthValidation = await configService.GetAsync("Profiles:IgnoreAuthValidation", true, ct); + + var auth = await validator.ValidateAsync( + command.PlatformAuthentication, + command.PlatformId, + ct); + if (!auth.Succeeded && !ignoreAuthValidation) + return LoginProfileResult.Fail($"Platform Auth Failed: {auth.Message}"); + var profile = await profileRepository.GetByPlatform( command.PlatformType, command.PlatformId, @@ -39,21 +55,26 @@ public class ProfileService( if (profile is null) { + var name = await nameGenerator.GenerateAsync(ct); + profile = ProfileEntity.Create( - GenerateRandomName(), + name, command.PlatformType, command.PlatformId); await profileRepository.AddAsync(profile, ct); } + + if (profile.IsBanned) + return LoginProfileResult.Fail("Profile is banned"); + + if (auth.Name is not null && profile.Name != auth.Name) + profile.SetName(auth.Name); profile.AddDeviceId(command.DeviceId); await profileRepository.SaveChangesAsync(ct); - if (profile.IsBanned) - return LoginProfileResult.Fail("Profile is banned"); - var token = tokenService.GenerateProfileToken(profile); return LoginProfileResult.Success( @@ -62,8 +83,4 @@ public class ProfileService( expiresIn: token.ExpiresIn ); } - - // Temporary until platform/user-name verification becomes a real use case. - private static string GenerateRandomName() - => string.Concat("rr", Guid.NewGuid().ToString("N").AsSpan(0, 18)); } \ No newline at end of file diff --git a/RecNet.Application/RecNet.Application.csproj b/RecNet.Application/RecNet.Application.csproj index d8ffb53..48a2f6e 100644 --- a/RecNet.Application/RecNet.Application.csproj +++ b/RecNet.Application/RecNet.Application.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/RecNet.Domain/Repositories/IServerConfigRepository.cs b/RecNet.Domain/Entities/Configuration/IServerConfigRepository.cs similarity index 100% rename from RecNet.Domain/Repositories/IServerConfigRepository.cs rename to RecNet.Domain/Entities/Configuration/IServerConfigRepository.cs diff --git a/RecNet.Domain/Repositories/IProfileRepository.cs b/RecNet.Domain/Entities/Profiles/IProfileRepository.cs similarity index 100% rename from RecNet.Domain/Repositories/IProfileRepository.cs rename to RecNet.Domain/Entities/Profiles/IProfileRepository.cs diff --git a/RecNet.Domain/Entities/Profiles/Names/DefaultNameGenConfig.cs b/RecNet.Domain/Entities/Profiles/Names/DefaultNameGenConfig.cs new file mode 100644 index 0000000..94b5323 --- /dev/null +++ b/RecNet.Domain/Entities/Profiles/Names/DefaultNameGenConfig.cs @@ -0,0 +1,288 @@ +namespace RecNet.Domain.Entities.Profiles.Names; + +public static class DefaultNameGenConfig +{ + public static readonly NameGenConfig Value = new() + { + Adjectives = + [ + "Adamant", + "Adorable", + "Adventurous", + "Agreeable", + "Aimless", + "Alert", + "Amused", + "Aromatic", + "Bashful", + "Beautiful", + "Bored", + "Brave", + "Bulbous", + "Busy", + "Calm", + "Carefree", + "Careless", + "Caring", + "Charming", + "Cheerful", + "Clever", + "Clumsy", + "Courageous", + "Cowardly", + "Cozy", + "Crabby", + "Cranky", + "Crawling", + "Creaky", + "Creative", + "Crispy", + "Decisive", + "Deep", + "Delightful", + "Determined", + "Diligent", + "Dull", + "Eager", + "Elated", + "Emotional", + "Enchanting", + "Encouraging", + "Endless", + "Energetic", + "Enthusiastic", + "Excited", + "Exuberant", + "Fair", + "Faithful", + "Fantastic", + "Fastidious", + "Fine", + "Fluttering", + "Fragrant", + "Friendly", + "Fulsome", + "Funny", + "Fussy", + "Fuzzy", + "Generous", + "Gentle", + "Glassy", + "Gloomy", + "Glorious", + "Radiating", + "Glowing", + "Good", + "Grand", + "Great", + "Greedy", + "Grimy", + "Happy", + "Hardworking", + "Hasty", + "Healthy", + "Heavy", + "Helpful", + "Hilarious", + "Hopeful", + "Icy", + "Important", + "Inquisitive", + "Jolly", + "Joyful", + "Joyous", + "Kind", + "Lazy", + "Lively", + "Loud", + "Lovely", + "Loyal", + "Lucky", + "Luminous", + "Lumpy", + "Majestic", + "Meek", + "Melodic", + "Mighty", + "Moody", + "Nice", + "Nimble", + "Odd", + "Optimistic", + "Perfect", + "Pervasive", + "Pleasant", + "Plucky", + "Plush", + "Polite", + "Practical", + "Proud", + "Quick", + "Quiet", + "Rapid", + "Redolent", + "Reliable", + "Relieved", + "Royal", + "Rusty", + "Scared", + "Selfish", + "Sensible", + "Sensitive", + "Shining", + "Shrill", + "Silly", + "Sincere", + "Sizzling", + "Sleepy", + "Smiling", + "Smooth", + "Snug", + "Soaring", + "Sparkling", + "Speedy", + "Spiky", + "Splendid", + "Spoiled", + "Steaming", + "Still", + "Strict", + "Stuffed", + "Sturdy", + "Successful", + "Surprised", + "Swift", + "Taciturn", + "Tense", + "Thankful", + "Thoughtful", + "Thrifty", + "Tough", + "Tricky", + "Truthful", + "Ubiquitous", + "Unusual", + "Versatile", + "Victorious", + "Wild", + "Wise", + "Witty", + "Wonderful", + "Worried", + "Wrinkly", + "Zany", + "Zealous" + ], + Nouns = + [ + "Aardvark", + "Alpaca", + "Ant", + "Armadillo", + "Badger", + "Bat", + "Bear", + "Bee", + "Buffalo", + "Butterfly", + "Capybara", + "Cat", + "Caterpillar", + "Chameleon", + "Cheetah", + "Chicken", + "Chimpanzee", + "Cobra", + "Coyote", + "Crane", + "Cricket", + "Crow", + "Deer", + "Dog", + "Dolphin", + "Donkey", + "Dove", + "Duck", + "Eagle", + "Echidna", + "Elephant", + "Elk", + "Emu", + "Ferret", + "Flamingo", + "Fish", + "Fox", + "Frog", + "Gazelle", + "Giraffe", + "Goat", + "Goose", + "Gorilla", + "Hamster", + "Hedgehog", + "Hippo", + "Horse", + "Hyena", + "Iguana", + "Jaguar", + "Jellyfish", + "Kangaroo", + "Kitten", + "Koala", + "Lemming", + "Leopard", + "Lion", + "Lizard", + "Llama", + "Marmoset", + "Monkey", + "Moose", + "Mouse", + "Mule", + "Newt", + "Octopus", + "Opposum", + "Ostrich", + "Otter", + "Owl", + "Oyster", + "Panda", + "Panther", + "Parrot", + "Penguin", + "Pig", + "Pigeon", + "Piranha", + "Platypus", + "Pony", + "Possum", + "Puppy", + "Quail", + "Rabbit", + "Raven", + "Salmon", + "Scorpion", + "Seal", + "Shark", + "Sheep", + "Sloth", + "Snail", + "Squid", + "Squirrel", + "Stork", + "Tapir", + "Tiger", + "Tortoise", + "Tuna", + "Turtle", + "Urchin", + "Viper", + "Vulture", + "Walrus", + "Whale", + "Wombat", + "Yak", + "Zebra" + ] + }; +} \ No newline at end of file diff --git a/RecNet.Domain/Entities/Profiles/Names/NameGenConfig.cs b/RecNet.Domain/Entities/Profiles/Names/NameGenConfig.cs new file mode 100644 index 0000000..039923a --- /dev/null +++ b/RecNet.Domain/Entities/Profiles/Names/NameGenConfig.cs @@ -0,0 +1,7 @@ +namespace RecNet.Domain.Entities.Profiles.Names; + +public class NameGenConfig +{ + public required List Adjectives { get; set; } + public required List Nouns { get; set; } +} \ No newline at end of file diff --git a/RecNet.Infrastructure/DependencyInjection.cs b/RecNet.Infrastructure/DependencyInjection.cs index 07f260a..2f9217b 100644 --- a/RecNet.Infrastructure/DependencyInjection.cs +++ b/RecNet.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using RecNet.Domain.Repositories; using RecNet.Infrastructure.Persistence; using RecNet.Infrastructure.Persistence.Repositories; using RecNet.Infrastructure.Services.Configuration; +using RecNet.Infrastructure.Services.Steam; using RecNet.Infrastructure.Services.Tokens; namespace RecNet.Infrastructure; @@ -21,6 +22,17 @@ public static class DependencyInjection .Bind(builder.Configuration.GetSection("Jwt")) .Validate(options => !string.IsNullOrWhiteSpace(options.Secret), "JWT secret is required.") .ValidateOnStart(); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("Steam")); + + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://api.steampowered.com/"); + }); + + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/RecNet.Infrastructure/RecNet.Infrastructure.csproj b/RecNet.Infrastructure/RecNet.Infrastructure.csproj index 65415a4..ac693ad 100644 --- a/RecNet.Infrastructure/RecNet.Infrastructure.csproj +++ b/RecNet.Infrastructure/RecNet.Infrastructure.csproj @@ -8,6 +8,8 @@ + + diff --git a/RecNet.Infrastructure/Services/Configuration/ConfigService.cs b/RecNet.Infrastructure/Services/Configuration/ConfigService.cs index f2536e8..8a998e4 100644 --- a/RecNet.Infrastructure/Services/Configuration/ConfigService.cs +++ b/RecNet.Infrastructure/Services/Configuration/ConfigService.cs @@ -8,16 +8,25 @@ public class ConfigService(IServerConfigRepository repository) : IConfigService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - public async Task GetAsync( + public async Task GetAsync(string key, CancellationToken ct = default) + { + var config = await repository.GetAsync(key, ct); + if (config == null) + return default; + + return JsonSerializer.Deserialize(config.Value, JsonOptions); + } + + public async Task GetAsync( string key, - T? defaultValue = default, + T defaultValue, CancellationToken ct = default) { var config = await repository.GetAsync(key, ct); if (config == null) return defaultValue; - return JsonSerializer.Deserialize(config.Value, JsonOptions); + return JsonSerializer.Deserialize(config.Value, JsonOptions) ?? defaultValue; } public async Task SetAsync( diff --git a/RecNet.Infrastructure/Services/Steam/SteamAuthService.cs b/RecNet.Infrastructure/Services/Steam/SteamAuthService.cs new file mode 100644 index 0000000..efcea91 --- /dev/null +++ b/RecNet.Infrastructure/Services/Steam/SteamAuthService.cs @@ -0,0 +1,89 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using RecNet.Application.Common.Interfaces; +using RecNet.Application.Common.Steam; +using SteamApi.Models.Steam.Player; +using SteamApi.Models.Steam.Responses; + +namespace RecNet.Infrastructure.Services.Steam; + +public class SteamAuthService( + HttpClient httpClient, + IOptions options) : ISteamAuthService +{ + private readonly SteamOptions _options = options.Value; + + public async Task AuthorizeAsync( + string ticket, + uint appId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(ticket)) + return SteamAuthResult.Failure(); + + if (appId != _options.AppId) + return SteamAuthResult.Failure(); + + var steamId = await AuthenticateTicketAsync(ticket, ct); + if (steamId is null) + return SteamAuthResult.Failure(); + + var profile = await GetProfileAsync(steamId, ct); + + return SteamAuthResult.Success( + steamId, + profile?.PersonaName); + } + + private async Task AuthenticateTicketAsync( + string ticket, + CancellationToken ct) + { + var url = + "ISteamUserAuth/AuthenticateUserTicket/v0001/" + + $"?key={Uri.EscapeDataString(_options.ApiKey)}" + + $"&appid={_options.AppId}" + + $"&ticket={Uri.EscapeDataString(ticket)}"; + + var response = await httpClient.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + return null; + + var body = await response.Content.ReadFromJsonAsync>( + cancellationToken: ct); + + return body?.Response?.Params?.SteamId; + } + + private async Task GetProfileAsync( + string steamId, + CancellationToken ct) + { + var url = + "ISteamUser/GetPlayerSummaries/v0002/" + + $"?key={Uri.EscapeDataString(_options.ApiKey)}" + + $"&steamids={Uri.EscapeDataString(steamId)}"; + + var response = await httpClient.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + return null; + + var body = await response.Content.ReadFromJsonAsync>( + cancellationToken: ct); + + return body?.Response?.Players.FirstOrDefault(); + } + + private class SteamAuthResponse + { + [JsonPropertyName("params")] + public SteamAuthParamsResponse? Params { get; set; } + } + + private class SteamAuthParamsResponse + { + [JsonPropertyName("steamid")] + public required string SteamId { get; set; } + } +} \ No newline at end of file diff --git a/RecNet.Infrastructure/Services/Steam/SteamAuthValidator.cs b/RecNet.Infrastructure/Services/Steam/SteamAuthValidator.cs new file mode 100644 index 0000000..5b020da --- /dev/null +++ b/RecNet.Infrastructure/Services/Steam/SteamAuthValidator.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using RecNet.Application.Common.Interfaces; +using RecNet.Application.Common.PlatformAuth; +using RecNet.Domain.Common; + +namespace RecNet.Infrastructure.Services.Steam; + +public class SteamAuthValidator(ISteamAuthService steamAuthService) : IPlatformAuthValidator +{ + public PlatformType PlatformType + => PlatformType.Steamworks; + + public async Task ValidateAsync( + string platformAuthentication, + string platformId, + CancellationToken ct = default) + { + var steamParams = JsonSerializer.Deserialize(platformAuthentication); + if (steamParams == null) + return PlatformAuthResult.Failure("Invalid authentication"); + + var result = await steamAuthService.AuthorizeAsync(steamParams.Ticket, steamParams.AppId.AppId, ct); + + if (!result.Succeeded || result.SteamId is null) + return PlatformAuthResult.Failure("Invalid authentication"); + + if (!string.IsNullOrWhiteSpace(platformId) && + platformId != result.SteamId) + return PlatformAuthResult.Failure("Nice try"); + + return PlatformAuthResult.Success(result.DisplayName); + } + + private sealed class SteamPlatformAuth + { + public required string Ticket { get; set; } + + public required SteamAppIdAuth AppId { get; set; } + } + + // Splooty, why did you set it up like this ;-; + private sealed class SteamAppIdAuth + { + [JsonPropertyName("m_AppId")] + public required uint AppId { get; set; } + } +} \ No newline at end of file diff --git a/RecNet.Infrastructure/Services/Steam/SteamOptions.cs b/RecNet.Infrastructure/Services/Steam/SteamOptions.cs new file mode 100644 index 0000000..7b69553 --- /dev/null +++ b/RecNet.Infrastructure/Services/Steam/SteamOptions.cs @@ -0,0 +1,8 @@ +namespace RecNet.Infrastructure.Services.Steam; + +public class SteamOptions +{ + public string ApiKey { get; set; } = string.Empty; + public uint AppId { get; set; } = 480; // Spacewar + public string Identity { get; set; } = "recnet"; +} diff --git a/RecNet.Infrastructure/Services/Tokens/TokenService.cs b/RecNet.Infrastructure/Services/Tokens/TokenService.cs index 5aa1717..6c972a6 100644 --- a/RecNet.Infrastructure/Services/Tokens/TokenService.cs +++ b/RecNet.Infrastructure/Services/Tokens/TokenService.cs @@ -30,7 +30,7 @@ public class TokenService(IOptions options) : ITokenService }; if (profile.IsModerator) - claims.Add(new Claim(ClaimTypes.Role, "moderator")); + claims.Add(new Claim("role", "moderator")); var token = new JwtSecurityToken( issuer: jwtOptions.Issuer,