Add Steam auth, platform validators & name gen

This commit is contained in:
Holden
2026-06-19 14:48:19 -05:00
parent 0b81cb046a
commit 63730dbba0
24 changed files with 592 additions and 17 deletions

View File

@@ -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; }
}

View File

@@ -2,6 +2,7 @@ namespace RecNet.Application.Common.Interfaces;
public interface IConfigService
{
Task<T?> GetAsync<T>(string key, T? defaultValue = default, CancellationToken ct = default);
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task<T> GetAsync<T>(string key, T defaultValue, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, CancellationToken ct = default);
}

View File

@@ -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<PlatformAuthResult> ValidateAsync(
string platformAuthentication,
string platformId,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,11 @@
using RecNet.Application.Common.Steam;
namespace RecNet.Application.Common.Interfaces;
public interface ISteamAuthService
{
Task<SteamAuthResult> AuthorizeAsync(
string ticket,
uint appId,
CancellationToken ct = default);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -9,6 +9,9 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(_ => { }, typeof(DependencyInjection).Assembly);
services.AddScoped<NameGenerator>();
services.AddScoped<IProfileService, ProfileService>();
services.AddScoped<INeutrinoAuthorizationService, NeutrinoAuthorizationService>();

View File

@@ -22,7 +22,7 @@ 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);

View File

@@ -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<string> 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}";
}
}

View File

@@ -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; }
}

View File

@@ -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<IPlatformAuthValidator> validators,
IMapper mapper) : IProfileService
{
public async Task<ProfileDTO?> 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));
}

View File

@@ -14,4 +14,8 @@
<PackageReference Include="AutoMapper" Version="16.1.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Authentication\" />
</ItemGroup>
</Project>

View File

@@ -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"
]
};
}

View File

@@ -0,0 +1,7 @@
namespace RecNet.Domain.Entities.Profiles.Names;
public class NameGenConfig
{
public required List<string> Adjectives { get; set; }
public required List<string> Nouns { get; set; }
}

View File

@@ -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;
@@ -22,6 +23,17 @@ public static class DependencyInjection
.Validate(options => !string.IsNullOrWhiteSpace(options.Secret), "JWT secret is required.")
.ValidateOnStart();
builder.Services
.AddOptions<SteamOptions>()
.Bind(builder.Configuration.GetSection("Steam"));
builder.Services.AddHttpClient<ISteamAuthService, SteamAuthService>(client =>
{
client.BaseAddress = new Uri("https://api.steampowered.com/");
});
builder.Services.AddScoped<IPlatformAuthValidator, SteamAuthValidator>();
builder.Services.AddScoped<IProfileRepository, ProfileRepository>();
builder.Services.AddScoped<IServerConfigRepository, ServerConfigRepository>();

View File

@@ -8,6 +8,8 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="SteamApi.Models" Version="1.1.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>

View File

@@ -8,16 +8,25 @@ public class ConfigService(IServerConfigRepository repository) : IConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<T?> GetAsync<T>(
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
var config = await repository.GetAsync(key, ct);
if (config == null)
return default;
return JsonSerializer.Deserialize<T>(config.Value, JsonOptions);
}
public async Task<T> GetAsync<T>(
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<T>(config.Value, JsonOptions);
return JsonSerializer.Deserialize<T>(config.Value, JsonOptions) ?? defaultValue;
}
public async Task SetAsync<T>(

View File

@@ -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<SteamOptions> options) : ISteamAuthService
{
private readonly SteamOptions _options = options.Value;
public async Task<SteamAuthResult> 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<string?> 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<SteamResponse<SteamAuthResponse>>(
cancellationToken: ct);
return body?.Response?.Params?.SteamId;
}
private async Task<PlayerSummary?> 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<SteamResponse<PlayerSummariesResponse>>(
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; }
}
}

View File

@@ -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<PlatformAuthResult> ValidateAsync(
string platformAuthentication,
string platformId,
CancellationToken ct = default)
{
var steamParams = JsonSerializer.Deserialize<SteamPlatformAuth>(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; }
}
}

View File

@@ -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";
}

View File

@@ -30,7 +30,7 @@ public class TokenService(IOptions<JwtOptions> 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,