Add GameVersion domain and login validation

This commit is contained in:
Holden
2026-06-19 15:11:08 -05:00
parent 076fed09f2
commit 2cfc5e7369
31 changed files with 291 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
namespace API.Configurations;
namespace API.Configuration;
public class RecNetOptions
{
public bool UseForwardedHeaders { get; set; } = true;
public bool UseForwardedHeaders { get; set; }
}

View File

@@ -34,12 +34,12 @@ public class ProfilesController(IProfileService profileService) : ControllerBase
{
var result = await profileService.LoginAsync(
new LoginProfileCommand(
request.AppVersion,
request.DeviceId,
request.PlatformType,
request.PlatformId,
request.PlatformAuthentication),
ct);
request.PlatformAuthentication
), ct);
if (!result.Succeeded)
return BadRequest(result.Message);

View File

@@ -1,5 +1,5 @@
using System.Text;
using API.Configurations;
using API.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Tokens;

View File

@@ -1,5 +1,5 @@
using RecNet.Application.Common.Tokens;
using RecNet.Domain.Entities.Profiles;
using RecNet.Domain.Profiles;
namespace RecNet.Application.Common.Interfaces;

View File

@@ -7,6 +7,6 @@ public class ApplicationMappingProfile : Profile
{
public ApplicationMappingProfile()
{
CreateMap<Domain.Entities.Profiles.Profile, ProfileDTO>();
CreateMap<Domain.Profiles.Profile, ProfileDTO>();
}
}

View File

@@ -1,5 +1,5 @@
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Repositories;
using RecNet.Domain.Profiles;
namespace RecNet.Application.Neutrino;

View File

@@ -3,6 +3,7 @@ using RecNet.Domain.Common;
namespace RecNet.Application.Profiles;
public sealed record LoginProfileCommand(
string AppVersion,
string DeviceId,
PlatformType PlatformType,
string PlatformId,

View File

@@ -1,5 +1,5 @@
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Entities.Profiles.Names;
using RecNet.Domain.Profiles.Names;
namespace RecNet.Application.Profiles;

View File

@@ -1,16 +1,18 @@
using AutoMapper;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Repositories;
using ProfileEntity = RecNet.Domain.Entities.Profiles.Profile;
using RecNet.Domain.GameVersions;
using RecNet.Domain.Profiles;
using ProfileEntity = RecNet.Domain.Profiles.Profile;
namespace RecNet.Application.Profiles;
public class ProfileService(
NameGenerator nameGenerator,
IProfileRepository profileRepository,
ITokenService tokenService,
IConfigService configService,
IEnumerable<IPlatformAuthValidator> validators,
IGameVersionRepository gameVersionRepository,
IProfileRepository profileRepository,
IConfigService configService,
NameGenerator nameGenerator,
ITokenService tokenService,
IMapper mapper) : IProfileService
{
public async Task<ProfileDTO?> GetProfileAsync(Guid profileId, CancellationToken ct = default)
@@ -35,6 +37,15 @@ public class ProfileService(
LoginProfileCommand command,
CancellationToken ct = default)
{
// 1. Validate Game Version
if (string.IsNullOrWhiteSpace(command.AppVersion))
return LoginProfileResult.Fail("App version is required.");
var isValidGameVersion = await gameVersionRepository.IsValidAsync(command.AppVersion, ct);
if (!isValidGameVersion)
return LoginProfileResult.Fail("Invalid app version.");
// 2. Validate Platform Authentication
var validator = validators.FirstOrDefault(x => x.PlatformType == command.PlatformType);
if (validator is null)
return LoginProfileResult.Fail("Invalid platform type");
@@ -48,6 +59,7 @@ public class ProfileService(
if (!auth.Succeeded && !ignoreAuthValidation)
return LoginProfileResult.Fail($"Platform Auth Failed: {auth.Message}");
// 3. Get or Create Profile
var profile = await profileRepository.GetByPlatform(
command.PlatformType,
command.PlatformId,
@@ -67,7 +79,7 @@ public class ProfileService(
if (profile.IsBanned)
return LoginProfileResult.Fail("Profile is banned");
profile.RecordSuccessfulLogin(command.DeviceId, auth.Name);
await profileRepository.SaveChangesAsync(ct);

View File

@@ -1,6 +1,4 @@
using RecNet.Domain.Entities.Configuration;
namespace RecNet.Domain.Repositories;
namespace RecNet.Domain.Configuration;
public interface IServerConfigRepository
{

View File

@@ -1,6 +1,6 @@
using RecNet.Domain.Exceptions;
namespace RecNet.Domain.Entities.Configuration;
namespace RecNet.Domain.Configuration;
public class ServerConfig
{

View File

@@ -0,0 +1,31 @@
using RecNet.Domain.Exceptions;
namespace RecNet.Domain.GameVersions;
public class GameVersion
{
private GameVersion()
{
}
private GameVersion(string version, bool isValid)
{
Version = RequireValue(version, nameof(version));
IsValid = isValid;
}
public string Version { get; private set; } = string.Empty;
public bool IsValid { get; private set; }
public static GameVersion Create(string version, bool isValid)
=> new(version, isValid);
public void MarkValid()
=> IsValid = true;
public void MarkInvalid()
=> IsValid = false;
private static string RequireValue(string value, string parameterName)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required.");
}

View File

@@ -0,0 +1,12 @@
namespace RecNet.Domain.GameVersions;
public interface IGameVersionRepository
{
Task<GameVersion?> GetAsync(string version, CancellationToken ct = default);
async Task<bool> IsValidAsync(string version, CancellationToken ct = default)
{
var gameVersion = await GetAsync(version, ct);
return gameVersion?.IsValid == true;
}
}

View File

@@ -1,7 +1,6 @@
using RecNet.Domain.Common;
using RecNet.Domain.Entities.Profiles;
namespace RecNet.Domain.Repositories;
namespace RecNet.Domain.Profiles;
public interface IProfileRepository
{

View File

@@ -1,4 +1,4 @@
namespace RecNet.Domain.Entities.Profiles.Names;
namespace RecNet.Domain.Profiles.Names;
public static class DefaultNameGenConfig
{

View File

@@ -1,4 +1,4 @@
namespace RecNet.Domain.Entities.Profiles.Names;
namespace RecNet.Domain.Profiles.Names;
public class NameGenConfig
{

View File

@@ -1,7 +1,7 @@
using RecNet.Domain.Common;
using RecNet.Domain.Exceptions;
namespace RecNet.Domain.Entities.Profiles;
namespace RecNet.Domain.Profiles;
public class Profile
{

View File

@@ -1,10 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Repositories;
using RecNet.Domain.Configuration;
using RecNet.Domain.GameVersions;
using RecNet.Domain.Profiles;
using RecNet.Infrastructure.Persistence;
using RecNet.Infrastructure.Persistence.Repositories;
using RecNet.Infrastructure.Services.Configuration;
using RecNet.Infrastructure.Services.Meta;
using RecNet.Infrastructure.Services.Steam;
using RecNet.Infrastructure.Services.Tokens;
@@ -33,7 +36,9 @@ public static class DependencyInjection
});
builder.Services.AddScoped<IPlatformAuthValidator, SteamAuthValidator>();
builder.Services.AddScoped<IPlatformAuthValidator, MetaAuthValidator>();
builder.Services.AddScoped<IGameVersionRepository, IGameVersionRepository>();
builder.Services.AddScoped<IProfileRepository, ProfileRepository>();
builder.Services.AddScoped<IServerConfigRepository, ServerConfigRepository>();

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.GameVersions;
namespace RecNet.Infrastructure.Persistence.Configurations;
public class GameVersionConfiguration : IEntityTypeConfiguration<GameVersion>
{
public void Configure(EntityTypeBuilder<GameVersion> builder)
{
builder.HasKey(x => x.Version);
builder.Property(x => x.Version)
.HasMaxLength(32)
.IsRequired();
builder.Property(x => x.IsValid)
.IsRequired();
}
}

View File

@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.Entities.Profiles;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence.Configurations;

View File

@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RecNet.Domain.Entities.Configuration;
using RecNet.Domain.Configuration;
namespace RecNet.Infrastructure.Persistence.Configurations;

View File

@@ -1,12 +1,14 @@
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.Entities.Configuration;
using RecNet.Domain.Entities.Profiles;
using RecNet.Domain.Configuration;
using RecNet.Domain.GameVersions;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options)
{
public DbSet<Profile> Profiles => Set<Profile>();
public DbSet<GameVersion> GameVersions => Set<GameVersion>();
public DbSet<ServerConfig> ServerConfigs => Set<ServerConfig>();
protected override void OnModelCreating(ModelBuilder modelBuilder)

View File

@@ -0,0 +1,101 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RecNet.Infrastructure.Persistence;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20260619201140_GameVersion")]
partial class GameVersion
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Key");
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsValid")
.HasColumnType("boolean");
b.HasKey("Version");
b.ToTable("GameVersions");
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string>("DeviceIds")
.IsRequired()
.HasColumnType("jsonb");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsModerator")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PlatformId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("ProfileId");
b.HasIndex("Platform", "PlatformId")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class GameVersion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GameVersions",
columns: table => new
{
Version = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
IsValid = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GameVersions", x => x.Version);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GameVersions");
}
}
}

View File

@@ -22,7 +22,7 @@ namespace RecNet.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RecNet.Domain.Entities.Configuration.ServerConfig", b =>
modelBuilder.Entity("RecNet.Domain.Configuration.ServerConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
@@ -37,7 +37,21 @@ namespace RecNet.Infrastructure.Persistence.Migrations
b.ToTable("ServerConfigs");
});
modelBuilder.Entity("RecNet.Domain.Entities.Profiles.Profile", b =>
modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b =>
{
b.Property<string>("Version")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("IsValid")
.HasColumnType("boolean");
b.HasKey("Version");
b.ToTable("GameVersions");
});
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
{
b.Property<Guid>("ProfileId")
.ValueGeneratedOnAdd()

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.GameVersions;
namespace RecNet.Infrastructure.Persistence.Repositories;
public class GameVersionRepository(DatabaseContext dbContext) : IGameVersionRepository
{
public Task<GameVersion?> GetAsync(string version, CancellationToken ct = default)
=> dbContext.GameVersions
.FirstOrDefaultAsync(x => x.Version == version, ct);
}

View File

@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore;
using RecNet.Domain.Common;
using RecNet.Domain.Entities.Profiles;
using RecNet.Domain.Repositories;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Persistence.Repositories;

View File

@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using RecNet.Domain.Entities.Configuration;
using RecNet.Domain.Repositories;
using RecNet.Domain.Configuration;
namespace RecNet.Infrastructure.Persistence.Repositories;

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using RecNet.Application.Common.Interfaces;
using RecNet.Domain.Repositories;
using RecNet.Domain.Configuration;
namespace RecNet.Infrastructure.Services.Configuration;

View File

@@ -0,0 +1,15 @@
using RecNet.Application.Common.Interfaces;
using RecNet.Application.Common.PlatformAuth;
using RecNet.Domain.Common;
namespace RecNet.Infrastructure.Services.Meta;
public class MetaAuthValidator : IPlatformAuthValidator
{
public PlatformType PlatformType
=> PlatformType.Meta;
public Task<PlatformAuthResult> ValidateAsync(string platformAuthentication, string platformId,
CancellationToken ct = default)
=> Task.FromResult(PlatformAuthResult.Success(null)); // TODO: Implement
}

View File

@@ -5,7 +5,7 @@ using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using RecNet.Application.Common.Interfaces;
using RecNet.Application.Common.Tokens;
using RecNet.Domain.Entities.Profiles;
using RecNet.Domain.Profiles;
namespace RecNet.Infrastructure.Services.Tokens;