diff --git a/API/API.csproj b/API/API.csproj
index baef52d..e407afb 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -4,13 +4,16 @@
net10.0
enable
enable
+ cfcf35c1-8c26-473a-a8e9-44ba009adea8
+
+
diff --git a/API/Configurations/RecNetConfiguration.cs b/API/Configurations/RecNetOptions.cs
similarity index 74%
rename from API/Configurations/RecNetConfiguration.cs
rename to API/Configurations/RecNetOptions.cs
index ae6fb9e..a05ecd8 100644
--- a/API/Configurations/RecNetConfiguration.cs
+++ b/API/Configurations/RecNetOptions.cs
@@ -1,6 +1,6 @@
namespace API.Configurations;
-public class RecNetConfiguration
+public class RecNetOptions
{
public bool UseForwardedHeaders { get; set; } = true;
}
\ No newline at end of file
diff --git a/API/Contracts/Neutrino/Enums/AuthenticationResultCode.cs b/API/Contracts/Neutrino/Enums/AuthenticationResultCode.cs
new file mode 100644
index 0000000..3958bbf
--- /dev/null
+++ b/API/Contracts/Neutrino/Enums/AuthenticationResultCode.cs
@@ -0,0 +1,25 @@
+namespace API.Contracts.Neutrino.Enums;
+
+public enum AuthenticationResultCode
+{
+ ///
+ /// Indicates that authentication is incomplete and only the associated data is returned.
+ ///
+ /// This value is typically used when launching the game with no account, in which data to authenticate is not provided.
+ AuthenticationIncomplete,
+
+ ///
+ /// Indicates that authentication was successful.
+ ///
+ AuthenticationSuccessful,
+
+ ///
+ /// Indicates that authentication failed due to incorrect credentials.
+ ///
+ AuthenticationFailedWrongCredentials,
+
+ ///
+ /// Indicates that the parameters provided to the operation are invalid.
+ ///
+ InvalidParameters
+}
\ No newline at end of file
diff --git a/API/Contracts/Neutrino/Requests/NeutrinoAuthenticateRequest.cs b/API/Contracts/Neutrino/Requests/NeutrinoAuthenticateRequest.cs
new file mode 100644
index 0000000..d8ceb9c
--- /dev/null
+++ b/API/Contracts/Neutrino/Requests/NeutrinoAuthenticateRequest.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace API.Contracts.Neutrino.Requests;
+
+public class NeutrinoAuthenticateRequest
+{
+ [JsonPropertyName("profileId")]
+ public Guid ProfileId;
+
+ [JsonPropertyName("accessToken")]
+ public required string AccessToken;
+
+ [JsonPropertyName("appVersion")]
+ public required string AppVersion;
+}
\ No newline at end of file
diff --git a/API/Contracts/Neutrino/Responses/NeutrinoAuthenticateResponse.cs b/API/Contracts/Neutrino/Responses/NeutrinoAuthenticateResponse.cs
new file mode 100644
index 0000000..d25c034
--- /dev/null
+++ b/API/Contracts/Neutrino/Responses/NeutrinoAuthenticateResponse.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+using API.Contracts.Neutrino.Enums;
+
+namespace API.Contracts.Neutrino.Responses;
+
+// {"ResultCode":1,"UserId":"their user id","Nickname":"splotybean"}
+public class NeutrinoAuthenticateResponse : NeutrinoResultCodeResponse
+{
+ public static NeutrinoAuthenticateResponse Success(string userId, string nickname) => new()
+ {
+ ResultCode = (byte)AuthenticationResultCode.AuthenticationSuccessful,
+ UserId = userId,
+ Nickname = nickname
+ };
+
+ public static NeutrinoAuthenticateResponse Failure(AuthenticationResultCode error) => new()
+ {
+ ResultCode = (byte)error
+ };
+
+ [JsonPropertyName(name: "UserId")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? UserId { get; set; }
+
+ [JsonPropertyName(name: "Nickname")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Nickname { get; set; }
+}
\ No newline at end of file
diff --git a/API/Contracts/Neutrino/Responses/NeutrinoResultCodeResponse.cs b/API/Contracts/Neutrino/Responses/NeutrinoResultCodeResponse.cs
new file mode 100644
index 0000000..23607eb
--- /dev/null
+++ b/API/Contracts/Neutrino/Responses/NeutrinoResultCodeResponse.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace API.Contracts.Neutrino.Responses;
+
+public class NeutrinoResultCodeResponse
+{
+ public static NeutrinoResultCodeResponse Success() => new NeutrinoResultCodeResponse
+ {
+ ResultCode = 0
+ };
+
+ public static NeutrinoResultCodeResponse Failure(byte resultCode, string? message = null)
+ {
+ return new NeutrinoResultCodeResponse
+ {
+ Message = message,
+ ResultCode = resultCode
+ };
+ }
+
+ [JsonPropertyName(name: "Data")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Data { get; set; }
+
+ [JsonPropertyName(name: "Message")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Message { get; set; }
+
+ [JsonPropertyName(name: "ResultCode")]
+ public byte ResultCode { get; set; }
+}
\ No newline at end of file
diff --git a/API/Contracts/Profiles/Responses/LoginResponse.cs b/API/Contracts/Profiles/Responses/LoginResponse.cs
index a90745a..76d0d3b 100644
--- a/API/Contracts/Profiles/Responses/LoginResponse.cs
+++ b/API/Contracts/Profiles/Responses/LoginResponse.cs
@@ -1,8 +1,16 @@
-using RecNet.Application.Profiles;
+using System.Text.Json.Serialization;
+using RecNet.Application.Profiles;
namespace API.Contracts.Profiles.Responses;
public class LoginResponse
{
+ [JsonPropertyName("Profile")]
public required ProfileDTO Profile { get; set; }
+
+ [JsonPropertyName("AccessToken")]
+ public required string AccessToken { get; set; }
+
+ [JsonPropertyName("ExpiresIn")]
+ public required int ExpiresIn { get; set; }
}
\ No newline at end of file
diff --git a/API/Controllers/Config/V1/ConfigController.cs b/API/Controllers/Config/V1/ConfigController.cs
index 2a44e0a..dfdc90b 100644
--- a/API/Controllers/Config/V1/ConfigController.cs
+++ b/API/Controllers/Config/V1/ConfigController.cs
@@ -1,5 +1,5 @@
-using Microsoft.AspNetCore.Mvc;
-using RecNet.Application.Services.Configuration;
+using Microsoft.AspNetCore.Mvc;
+using RecNet.Domain.Services.Configuration;
namespace API.Controllers.Config.V1;
@@ -11,7 +11,7 @@ public class ConfigController(IConfigService configService) : ControllerBase
public async Task> GetMotd(CancellationToken ct = default)
{
var motd = await configService.GetAsync("Config:MOTD", "Ten Whole Years!", ct);
-
+
return Ok(motd);
}
-}
\ No newline at end of file
+}
diff --git a/API/Controllers/Neutrino/NeutrinoController.cs b/API/Controllers/Neutrino/NeutrinoController.cs
new file mode 100644
index 0000000..111148f
--- /dev/null
+++ b/API/Controllers/Neutrino/NeutrinoController.cs
@@ -0,0 +1,82 @@
+using System.Security.Claims;
+using System.Text.Json;
+using API.Contracts.Neutrino.Enums;
+using API.Contracts.Neutrino.Requests;
+using API.Contracts.Neutrino.Responses;
+using Microsoft.AspNetCore.Mvc;
+using RecNet.Domain.Repositories;
+using RecNet.Domain.Services.Tokens;
+
+namespace API.Controllers.Neutrino;
+
+[Route("[controller]")]
+[ApiController]
+public class NeutrinoController(
+ IProfileRepository profileRepository,
+ ITokenService tokenService) : ControllerBase
+{
+ // This route will be kind of abysmal so bare with it.
+ [Route("authorize")]
+ public async Task> AuthorizeNeutrinoAsync()
+ {
+ // Photon sends the request with Content-Type: text/plain instead of application/json
+ // This is a really janky workaround since we can't use [FromBody] to automatically parse the data.
+ using var reader = new StreamReader(Request.Body);
+ string rawText = await reader.ReadToEndAsync();
+
+ NeutrinoAuthenticateRequest? request;
+
+ try
+ {
+ request = JsonSerializer.Deserialize(rawText, new JsonSerializerOptions
+ {
+ IncludeFields = true,
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ catch
+ {
+ return Ok(
+ NeutrinoAuthenticateResponse.Failure(
+ AuthenticationResultCode.InvalidParameters));
+ }
+
+ // 1. Check if request parameters aren't empty
+ if (request is null ||
+ request.ProfileId == Guid.Empty ||
+ string.IsNullOrWhiteSpace(request.AccessToken))
+ return Ok(
+ NeutrinoAuthenticateResponse.Failure(
+ AuthenticationResultCode.InvalidParameters));
+
+ // 2. Verify accessToken is valid
+ var tokenVerifyResult = tokenService.VerifyToken(request.AccessToken);
+ if (tokenVerifyResult.IsError || !TryGetProfileId(tokenVerifyResult.Claims, out var tokenAccountId))
+ return Ok(
+ NeutrinoAuthenticateResponse.Failure(
+ AuthenticationResultCode.AuthenticationFailedWrongCredentials));
+
+ // 3. Verify that the accountId from the request matches the one from the token
+ if (tokenAccountId != request.ProfileId)
+ return Ok(
+ NeutrinoAuthenticateResponse.Failure(
+ AuthenticationResultCode.AuthenticationFailedWrongCredentials));
+
+ // 4. Get the profile to check if it exists, and for the name
+ var profile = await profileRepository.GetByIdAsync(request.ProfileId);
+ if (profile is null)
+ return Ok(
+ NeutrinoAuthenticateResponse.Failure(
+ AuthenticationResultCode.AuthenticationFailedWrongCredentials));
+
+ return Ok(
+ NeutrinoAuthenticateResponse.Success(
+ userId: request.ProfileId.ToString(),
+ nickname: profile.Name
+ )
+ );
+ }
+
+ private static bool TryGetProfileId(IEnumerable claims, out Guid accountId)
+ => Guid.TryParse(claims.FirstOrDefault(c => c.Type == "sub")?.Value, out accountId);
+}
\ No newline at end of file
diff --git a/API/Controllers/Profiles/V1/ProfilesController.cs b/API/Controllers/Profiles/V1/ProfilesController.cs
index 1c7e272..39e3a22 100644
--- a/API/Controllers/Profiles/V1/ProfilesController.cs
+++ b/API/Controllers/Profiles/V1/ProfilesController.cs
@@ -1,8 +1,10 @@
-using API.Contracts.Profiles.Responses;
+using API.Contracts.Profiles.Responses;
using AutoMapper;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RecNet.Application.Profiles;
using RecNet.Domain.Repositories;
+using RecNet.Domain.Services.Tokens;
using LoginRequest = API.Contracts.Profiles.Requests.LoginRequest;
using Profile = RecNet.Domain.Entities.Profiles.Profile;
@@ -10,9 +12,11 @@ namespace API.Controllers.Profiles.V1;
[Route("api/[controller]/v1")]
[ApiController]
+[Authorize]
public class ProfilesController(
- IProfileRepository profileRepository,
- IMapper mapper) : ControllerBase
+ IProfileRepository profileRepository,
+ IMapper mapper,
+ ITokenService tokenService) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task> GetProfile(
@@ -22,7 +26,7 @@ public class ProfilesController(
var profile = await profileRepository.GetByIdAsync(id, ct);
if (profile == null)
return NotFound();
-
+
return mapper.Map(profile);
}
@@ -32,33 +36,44 @@ public class ProfilesController(
CancellationToken ct)
{
var profiles = await profileRepository.GetByIdsAsync(ids, ct);
-
+
return mapper.Map>(profiles);
}
// TODO: Implement
+ [AllowAnonymous]
[HttpPost("login")]
public async Task> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
+ var name = GenerateRandomName();
+
var profile = await profileRepository.GetByPlatform(request.PlatformType, request.PlatformId, ct);
if (profile is null)
{
- profile = Profile.Create(request.Username, request.PlatformType, request.PlatformId);
+ profile = Profile.Create(name, request.PlatformType, request.PlatformId);
await profileRepository.AddAsync(profile, ct);
}
-
+
profile.AddDeviceId(request.DeviceId);
-
- if (request.Username != profile.Name)
- profile.SetName(request.Username);
-
+
await profileRepository.SaveChangesAsync(ct);
+
+ if (profile.IsBanned)
+ return BadRequest();
+
+ var token = tokenService.GenerateProfileToken(profile);
return new LoginResponse
{
- Profile = mapper.Map(profile)
+ Profile = mapper.Map(profile),
+ AccessToken = token.AccessToken,
+ ExpiresIn = token.ExpiresIn
};
}
-}
\ No newline at end of file
+
+ // TODO: REPLACE WITH BETTER GENERATOR, THIS IS TEMP.
+ private static string GenerateRandomName()
+ => string.Concat("rr", Guid.NewGuid().ToString("N").AsSpan(0, 18));
+}
diff --git a/API/Program.cs b/API/Program.cs
index 5b2a0f0..37b7d5b 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -1,7 +1,11 @@
+using System.Text;
using API.Configurations;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.IdentityModel.Tokens;
using RecNet.Application;
using RecNet.Infrastructure;
+using RecNet.Infrastructure.Services.Tokens;
using RecNet.ServiceDefaults;
namespace API;
@@ -11,12 +15,15 @@ public class Program
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
+
+ var recNetOptions = builder.Configuration.GetSection("RecNet").Get()
+ ?? new RecNetOptions();
- var recNetOptions = builder.Configuration.GetSection("RecNet").Get()
- ?? new RecNetConfiguration();
+ var jwtOptions = builder.Configuration.GetSection("Jwt").Get()
+ ?? new JwtOptions();
builder.AddServiceDefaults();
-
+
builder.Services.Configure(options =>
{
options.ForwardedHeaders =
@@ -26,18 +33,37 @@ public class Program
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
-
+
builder.Services.AddApplication();
builder.AddInfrastructure();
+ builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
+ ValidateIssuer = true,
+ ValidIssuer = jwtOptions.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwtOptions.Audience,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(1)
+ };
+ });
+
+ builder.Services.AddAuthorization();
+
builder.Services.AddControllers();
var app = builder.Build();
-
+
if (recNetOptions.UseForwardedHeaders)
app.UseForwardedHeaders();
- // app.UseAuthentication();
+ app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
diff --git a/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs b/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs
new file mode 100644
index 0000000..1995521
--- /dev/null
+++ b/RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,19 @@
+using System.Security.Claims;
+
+namespace RecNet.Application.Common.Security;
+
+public static class ClaimsPrincipalExtensions
+{
+ public static Guid GetProfileId(this ClaimsPrincipal user)
+ {
+ var value =
+ user.FindFirst("sub")?.Value ??
+ user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+
+ if (!Guid.TryParse(value, out var profileId))
+ throw new UnauthorizedAccessException(
+ "Account id claim is missing or invalid.");
+
+ return profileId;
+ }
+}
\ No newline at end of file
diff --git a/RecNet.Application/DependencyInjection.cs b/RecNet.Application/DependencyInjection.cs
index 9984404..5b7ee97 100644
--- a/RecNet.Application/DependencyInjection.cs
+++ b/RecNet.Application/DependencyInjection.cs
@@ -1,5 +1,4 @@
-using Microsoft.Extensions.DependencyInjection;
-using RecNet.Application.Services.Configuration;
+using Microsoft.Extensions.DependencyInjection;
namespace RecNet.Application;
@@ -9,8 +8,9 @@ public static class DependencyInjection
{
services.AddAutoMapper(_ => { }, typeof(DependencyInjection).Assembly);
- services.AddScoped();
+ // Usually there would be CQRS, but I'm rushing out this server, so I didn't feel like adding it.
+ // Enjoy a VERY barebones application layer :D
return services;
}
-}
\ No newline at end of file
+}
diff --git a/RecNet.Application/Profiles/ProfileDto.cs b/RecNet.Application/Profiles/ProfileDto.cs
index 47368a4..30b276e 100644
--- a/RecNet.Application/Profiles/ProfileDto.cs
+++ b/RecNet.Application/Profiles/ProfileDto.cs
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
-using RecNet.Domain.Common;
namespace RecNet.Application.Profiles;
@@ -10,11 +9,4 @@ public class ProfileDTO
[JsonPropertyName("Name")]
public required string Name { get; init; }
-
- [JsonPropertyName("Platform")]
-
- public PlatformType Platform { get; init; }
-
- [JsonPropertyName("CreatedAt")]
- public DateTimeOffset CreatedAt { get; init; }
}
\ No newline at end of file
diff --git a/RecNet.Domain/Entities/Profiles/Profile.cs b/RecNet.Domain/Entities/Profiles/Profile.cs
index 9832b47..d3a758d 100644
--- a/RecNet.Domain/Entities/Profiles/Profile.cs
+++ b/RecNet.Domain/Entities/Profiles/Profile.cs
@@ -28,6 +28,10 @@ public class Profile
public PlatformType Platform { get; private set; }
public string PlatformId { get; private set; }
public List DeviceIds { get; private set; } = [];
+
+ // EZ
+ public bool IsBanned { get; private set; }
+ public bool IsModerator { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
@@ -57,6 +61,8 @@ public class Profile
DeviceIds.Add(deviceId);
}
+
+
private static string RequireValue(string value, string parameterName)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required.");
}
diff --git a/RecNet.Application/Services/Configuration/IConfigService.cs b/RecNet.Domain/Services/Configuration/IConfigService.cs
similarity index 78%
rename from RecNet.Application/Services/Configuration/IConfigService.cs
rename to RecNet.Domain/Services/Configuration/IConfigService.cs
index 6a036b3..df62373 100644
--- a/RecNet.Application/Services/Configuration/IConfigService.cs
+++ b/RecNet.Domain/Services/Configuration/IConfigService.cs
@@ -1,7 +1,7 @@
-namespace RecNet.Application.Services.Configuration;
+namespace RecNet.Domain.Services.Configuration;
public interface IConfigService
{
Task GetAsync(string key, T? defaultValue = default, CancellationToken ct = default);
Task SetAsync(string key, T value, CancellationToken ct = default);
-}
\ No newline at end of file
+}
diff --git a/RecNet.Domain/Services/Tokens/ITokenService.cs b/RecNet.Domain/Services/Tokens/ITokenService.cs
new file mode 100644
index 0000000..43f7834
--- /dev/null
+++ b/RecNet.Domain/Services/Tokens/ITokenService.cs
@@ -0,0 +1,9 @@
+using RecNet.Domain.Entities.Profiles;
+
+namespace RecNet.Domain.Services.Tokens;
+
+public interface ITokenService
+{
+ TokenResult GenerateProfileToken(Profile profile);
+ TokenVerifyResult VerifyToken(string token);
+}
diff --git a/RecNet.Domain/Services/Tokens/TokenResult.cs b/RecNet.Domain/Services/Tokens/TokenResult.cs
new file mode 100644
index 0000000..47a40e2
--- /dev/null
+++ b/RecNet.Domain/Services/Tokens/TokenResult.cs
@@ -0,0 +1,5 @@
+namespace RecNet.Domain.Services.Tokens;
+
+public sealed record TokenResult(
+ string AccessToken,
+ int ExpiresIn);
diff --git a/RecNet.Domain/Services/Tokens/TokenVerifyResult.cs b/RecNet.Domain/Services/Tokens/TokenVerifyResult.cs
new file mode 100644
index 0000000..0013b8e
--- /dev/null
+++ b/RecNet.Domain/Services/Tokens/TokenVerifyResult.cs
@@ -0,0 +1,21 @@
+using System.Security.Claims;
+
+namespace RecNet.Domain.Services.Tokens;
+
+public class TokenVerifyResult
+{
+ public bool IsError { get; set; }
+ public IEnumerable Claims { get; set; }
+
+ private TokenVerifyResult(bool isError, IEnumerable? claims)
+ {
+ IsError = isError;
+ Claims = claims ?? [];
+ }
+
+ public static TokenVerifyResult Success(IEnumerable claims)
+ => new(false, claims);
+
+ public static TokenVerifyResult Failure()
+ => new(true, null);
+}
\ No newline at end of file
diff --git a/RecNet.Infrastructure/DependencyInjection.cs b/RecNet.Infrastructure/DependencyInjection.cs
index 5e74219..7d0aaf0 100644
--- a/RecNet.Infrastructure/DependencyInjection.cs
+++ b/RecNet.Infrastructure/DependencyInjection.cs
@@ -1,8 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RecNet.Domain.Repositories;
+using RecNet.Domain.Services.Configuration;
+using RecNet.Domain.Services.Tokens;
using RecNet.Infrastructure.Persistence;
using RecNet.Infrastructure.Persistence.Repositories;
+using RecNet.Infrastructure.Services.Configuration;
+using RecNet.Infrastructure.Services.Tokens;
namespace RecNet.Infrastructure;
@@ -13,8 +17,17 @@ public static class DependencyInjection
{
builder.AddNpgsqlDbContext("recnet");
+ builder.Services
+ .AddOptions()
+ .Bind(builder.Configuration.GetSection("Jwt"))
+ .Validate(options => !string.IsNullOrWhiteSpace(options.Secret), "JWT secret is required.")
+ .ValidateOnStart();
+
builder.Services.AddScoped();
builder.Services.AddScoped();
+
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
return builder;
}
diff --git a/RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.Designer.cs b/RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.Designer.cs
new file mode 100644
index 0000000..35f2564
--- /dev/null
+++ b/RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.Designer.cs
@@ -0,0 +1,87 @@
+//
+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("20260619025601_ProfileToggles")]
+ partial class ProfileToggles
+ {
+ ///
+ 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.Entities.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.Entities.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("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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.cs b/RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.cs
new file mode 100644
index 0000000..19b683e
--- /dev/null
+++ b/RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RecNet.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class ProfileToggles : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "IsBanned",
+ table: "Profiles",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "IsModerator",
+ table: "Profiles",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "IsBanned",
+ table: "Profiles");
+
+ migrationBuilder.DropColumn(
+ name: "IsModerator",
+ table: "Profiles");
+ }
+ }
+}
diff --git a/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs
index 782eb17..3f4ef1c 100644
--- a/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs
+++ b/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs
@@ -50,6 +50,12 @@ namespace RecNet.Infrastructure.Persistence.Migrations
.IsRequired()
.HasColumnType("jsonb");
+ b.Property("IsBanned")
+ .HasColumnType("boolean");
+
+ b.Property("IsModerator")
+ .HasColumnType("boolean");
+
b.Property("Name")
.IsRequired()
.HasMaxLength(32)
diff --git a/RecNet.Infrastructure/RecNet.Infrastructure.csproj b/RecNet.Infrastructure/RecNet.Infrastructure.csproj
index f75a011..f717ea8 100644
--- a/RecNet.Infrastructure/RecNet.Infrastructure.csproj
+++ b/RecNet.Infrastructure/RecNet.Infrastructure.csproj
@@ -8,10 +8,10 @@
+
-
diff --git a/RecNet.Application/Services/Configuration/ConfigService.cs b/RecNet.Infrastructure/Services/Configuration/ConfigService.cs
similarity index 77%
rename from RecNet.Application/Services/Configuration/ConfigService.cs
rename to RecNet.Infrastructure/Services/Configuration/ConfigService.cs
index a7130b1..383d0c4 100644
--- a/RecNet.Application/Services/Configuration/ConfigService.cs
+++ b/RecNet.Infrastructure/Services/Configuration/ConfigService.cs
@@ -1,33 +1,34 @@
-using System.Text.Json;
+using System.Text.Json;
using RecNet.Domain.Repositories;
+using RecNet.Domain.Services.Configuration;
-namespace RecNet.Application.Services.Configuration;
+namespace RecNet.Infrastructure.Services.Configuration;
public class ConfigService(IServerConfigRepository repository) : IConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
-
+
public async Task GetAsync(
- string key,
- T? defaultValue = default,
+ string key,
+ T? defaultValue = default,
CancellationToken ct = default)
{
var config = await repository.GetAsync(key, ct);
if (config == null)
return defaultValue;
-
+
return JsonSerializer.Deserialize(config.Value, JsonOptions);
}
public async Task SetAsync(
- string key,
- T value,
+ string key,
+ T value,
CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(value, JsonOptions);
-
+
await repository.SetAsync(key, json, ct);
-
+
await repository.SaveChangesAsync(ct);
}
-}
\ No newline at end of file
+}
diff --git a/RecNet.Infrastructure/Services/Tokens/JwtOptions.cs b/RecNet.Infrastructure/Services/Tokens/JwtOptions.cs
new file mode 100644
index 0000000..e5990c9
--- /dev/null
+++ b/RecNet.Infrastructure/Services/Tokens/JwtOptions.cs
@@ -0,0 +1,9 @@
+namespace RecNet.Infrastructure.Services.Tokens;
+
+public class JwtOptions
+{
+ public string Secret { get; set; } = string.Empty;
+ public string Issuer { get; set; } = "http://localhost:5155";
+ public string Audience { get; set; } = "TenWholeYears";
+ public int ExpiryMinutes { get; set; } = 60;
+}
diff --git a/RecNet.Infrastructure/Services/Tokens/TokenService.cs b/RecNet.Infrastructure/Services/Tokens/TokenService.cs
new file mode 100644
index 0000000..2a7d66c
--- /dev/null
+++ b/RecNet.Infrastructure/Services/Tokens/TokenService.cs
@@ -0,0 +1,89 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using RecNet.Domain.Entities.Profiles;
+using RecNet.Domain.Services.Tokens;
+
+namespace RecNet.Infrastructure.Services.Tokens;
+
+public class TokenService(IOptions options) : ITokenService
+{
+ public TokenResult GenerateProfileToken(Profile profile)
+ {
+ var jwtOptions = options.Value;
+ if (string.IsNullOrWhiteSpace(jwtOptions.Secret))
+ throw new InvalidOperationException("JWT secret is not configured.");
+
+ var expiresAt = DateTimeOffset.UtcNow.AddMinutes(jwtOptions.ExpiryMinutes);
+ var signingCredentials = new SigningCredentials(
+ CreateSecurityKey(jwtOptions),
+ SecurityAlgorithms.HmacSha256);
+
+ var claims = new List
+ {
+ new(JwtRegisteredClaimNames.Sub, profile.ProfileId.ToString()),
+ new("rn.plat", ((int)profile.Platform).ToString()),
+ new("rn.platid", profile.PlatformId)
+ };
+
+ if (profile.IsModerator)
+ claims.Add(new Claim(ClaimTypes.Role, "moderator"));
+
+ var token = new JwtSecurityToken(
+ issuer: jwtOptions.Issuer,
+ audience: jwtOptions.Audience,
+ claims: claims,
+ expires: expiresAt.UtcDateTime,
+ signingCredentials: signingCredentials);
+
+ return new TokenResult(
+ new JwtSecurityTokenHandler().WriteToken(token),
+ jwtOptions.ExpiryMinutes * 60);
+ }
+
+ public TokenVerifyResult VerifyToken(string token)
+ {
+ var jwtOptions = options.Value;
+ if (string.IsNullOrWhiteSpace(jwtOptions.Secret))
+ throw new InvalidOperationException("JWT secret is not configured.");
+
+ if (string.IsNullOrWhiteSpace(token))
+ return TokenVerifyResult.Failure();
+
+ var tokenHandler = new JwtSecurityTokenHandler();
+ if (!tokenHandler.CanReadToken(token))
+ return TokenVerifyResult.Failure();
+
+ var validationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = CreateSecurityKey(jwtOptions),
+ ValidateIssuer = true,
+ ValidIssuer = jwtOptions.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwtOptions.Audience,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(1)
+ };
+
+ try
+ {
+ var principal = tokenHandler.ValidateToken(token, validationParameters, out _);
+
+ return TokenVerifyResult.Success(principal.Claims);
+ }
+ catch (SecurityTokenException)
+ {
+ return TokenVerifyResult.Failure();
+ }
+ catch (ArgumentException)
+ {
+ return TokenVerifyResult.Failure();
+ }
+ }
+
+ private static SymmetricSecurityKey CreateSecurityKey(JwtOptions jwtOptions)
+ => new(Encoding.UTF8.GetBytes(jwtOptions.Secret));
+}