diff --git a/src/RecNet.Application/Common/Security/Hashing.cs b/src/RecNet.Application/Common/Security/Hashing.cs new file mode 100644 index 0000000..3b57204 --- /dev/null +++ b/src/RecNet.Application/Common/Security/Hashing.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using System.Text; + +namespace RecNet.Application.Common.Security; + +public static class Hashing +{ + public static byte[] ComputeSha256(string input) + => SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + public static bool VerifySha256(string input, byte[] expectedHash) + { + var computedHash = ComputeSha256(input); + + return CryptographicOperations.FixedTimeEquals( + computedHash, + expectedHash); + } +} \ No newline at end of file diff --git a/src/RecNet.Application/Profiles/ProfileService.cs b/src/RecNet.Application/Profiles/ProfileService.cs index 1e62ce1..6a1bf16 100644 --- a/src/RecNet.Application/Profiles/ProfileService.cs +++ b/src/RecNet.Application/Profiles/ProfileService.cs @@ -1,5 +1,7 @@ using AutoMapper; using RecNet.Application.Common.Interfaces; +using RecNet.Application.Common.Security; +using RecNet.Domain.Common; using RecNet.Domain.GameVersions; using RecNet.Domain.Profiles; using ProfileEntity = RecNet.Domain.Profiles.Profile; @@ -51,7 +53,7 @@ public class ProfileService( ); await profileRepository.SaveChangesAsync(ct); - + return mapper.Map(profile.Avatar); } @@ -60,13 +62,13 @@ public class ProfileService( CancellationToken ct = default) { var settings = await profileRepository.GetSettingsByProfileIdAsync(profileId, ct); - + return mapper.Map>(settings); } public async Task UpdatePlayerSettingAsync( - Guid profileId, - UpdatePlayerSettingCommand command, + Guid profileId, + UpdatePlayerSettingCommand command, CancellationToken ct = default) { var profile = await profileRepository.GetByIdWithSettingsAsync(profileId, ct); @@ -130,9 +132,15 @@ public class ProfileService( command.PlatformType, command.PlatformId); + if (command.PlatformType == PlatformType.Meta) + profile.SetMetaAuthenticationSecret(Hashing.ComputeSha256(command.PlatformAuthentication)); + await profileRepository.AddAsync(profile, ct); } + if (command.PlatformType == PlatformType.Meta && !Hashing.VerifySha256(command.PlatformAuthentication, profile.MetaAuthenticationSecret)) + return LoginProfileResult.Fail("Platform Auth Failed: Invalid authentication"); + if (profile.IsBanned) return LoginProfileResult.Fail("Profile is banned"); @@ -148,4 +156,4 @@ public class ProfileService( expiresIn: token.ExpiresIn ); } -} +} \ No newline at end of file diff --git a/src/RecNet.Domain/Profiles/Profile.cs b/src/RecNet.Domain/Profiles/Profile.cs index b771bfc..c3d74e8 100644 --- a/src/RecNet.Domain/Profiles/Profile.cs +++ b/src/RecNet.Domain/Profiles/Profile.cs @@ -28,7 +28,8 @@ public class Profile public PlatformType Platform { get; private set; } public string PlatformId { get; private set; } public List DeviceIds { get; private set; } = []; - + public byte[] MetaAuthenticationSecret { get; private set; } = []; + // EZ public bool IsBanned { get; private set; } public bool IsModerator { get; private set; } @@ -63,6 +64,9 @@ public class Profile DeviceIds.Add(deviceId); } + + public void SetMetaAuthenticationSecret(byte[] metaAuthenticationSecret) + => MetaAuthenticationSecret = metaAuthenticationSecret; public void RecordSuccessfulLogin(string deviceId, string? platformName) { diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260621202709_MetaAuthenticationSecret.Designer.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260621202709_MetaAuthenticationSecret.Designer.cs new file mode 100644 index 0000000..242e2ac --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260621202709_MetaAuthenticationSecret.Designer.cs @@ -0,0 +1,168 @@ +// +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("20260621202709_MetaAuthenticationSecret")] + partial class MetaAuthenticationSecret + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Key"); + + b.ToTable("ServerConfigs"); + }); + + modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b => + { + b.Property("Version") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsValid") + .HasColumnType("boolean"); + + b.HasKey("Version"); + + b.ToTable("GameVersions"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "Key"); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Property("ProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsModerator") + .HasColumnType("boolean"); + + b.Property("MetaAuthenticationSecret") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlatformId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("ProfileId"); + + b.HasIndex("Platform", "PlatformId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.HasOne("RecNet.Domain.Profiles.Profile", null) + .WithMany("Settings") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.OwnsOne("RecNet.Domain.Profiles.Avatar", "Avatar", b1 => + { + b1.Property("ProfileId") + .HasColumnType("uuid"); + + b1.Property("HairColor") + .IsRequired() + .HasColumnType("text"); + + b1.Property("OutfitSelections") + .IsRequired() + .HasColumnType("text"); + + b1.Property("SkinColor") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProfileId"); + + b1.ToTable("Profiles"); + + b1.WithOwner() + .HasForeignKey("ProfileId"); + }); + + b.Navigation("Avatar") + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260621202709_MetaAuthenticationSecret.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260621202709_MetaAuthenticationSecret.cs new file mode 100644 index 0000000..e2c28d8 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260621202709_MetaAuthenticationSecret.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class MetaAuthenticationSecret : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MetaAuthenticationSecret", + table: "Profiles", + type: "bytea", + nullable: false, + defaultValue: new byte[0]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MetaAuthenticationSecret", + table: "Profiles"); + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs index 8eb3f0c..099830c 100644 --- a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -88,6 +88,10 @@ namespace RecNet.Infrastructure.Persistence.Migrations b.Property("IsModerator") .HasColumnType("boolean"); + b.Property("MetaAuthenticationSecret") + .IsRequired() + .HasColumnType("bytea"); + b.Property("Name") .IsRequired() .HasMaxLength(32) diff --git a/src/RecNet.Infrastructure/Services/Meta/MetaAuthValidator.cs b/src/RecNet.Infrastructure/Services/Meta/MetaAuthValidator.cs index 33d3106..e154ad6 100644 --- a/src/RecNet.Infrastructure/Services/Meta/MetaAuthValidator.cs +++ b/src/RecNet.Infrastructure/Services/Meta/MetaAuthValidator.cs @@ -9,7 +9,9 @@ public class MetaAuthValidator : IPlatformAuthValidator public PlatformType PlatformType => PlatformType.Meta; - public Task ValidateAsync(string platformAuthentication, string platformId, + public Task ValidateAsync( + string platformAuthentication, + string platformId, CancellationToken ct = default) - => Task.FromResult(PlatformAuthResult.Success(null)); // TODO: Implement + => Task.FromResult(PlatformAuthResult.Success(null)); // We have a custom flow that utilizes platformAuthentication for Meta, so this gets skipped. } \ No newline at end of file