From 3eebfc9ba20e9c0c8c53b14f1a7c38e9f7655af2 Mon Sep 17 00:00:00 2001 From: Holden <122419606+midozen@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:25:45 -0500 Subject: [PATCH] Add JWT auth, token service & Neutrino endpoint --- API/API.csproj | 3 + ...ecNetConfiguration.cs => RecNetOptions.cs} | 2 +- .../Enums/AuthenticationResultCode.cs | 25 ++++++ .../Requests/NeutrinoAuthenticateRequest.cs | 15 ++++ .../Responses/NeutrinoAuthenticateResponse.cs | 28 ++++++ .../Responses/NeutrinoResultCodeResponse.cs | 31 +++++++ .../Profiles/Responses/LoginResponse.cs | 10 ++- API/Controllers/Config/V1/ConfigController.cs | 8 +- .../Neutrino/NeutrinoController.cs | 82 +++++++++++++++++ .../Profiles/V1/ProfilesController.cs | 41 ++++++--- API/Program.cs | 38 ++++++-- .../Security/ClaimsPrincipalExtensions.cs | 19 ++++ RecNet.Application/DependencyInjection.cs | 8 +- RecNet.Application/Profiles/ProfileDto.cs | 8 -- RecNet.Domain/Entities/Profiles/Profile.cs | 6 ++ .../Services/Configuration/IConfigService.cs | 4 +- .../Services/Tokens/ITokenService.cs | 9 ++ RecNet.Domain/Services/Tokens/TokenResult.cs | 5 ++ .../Services/Tokens/TokenVerifyResult.cs | 21 +++++ RecNet.Infrastructure/DependencyInjection.cs | 13 +++ .../20260619025601_ProfileToggles.Designer.cs | 87 ++++++++++++++++++ .../20260619025601_ProfileToggles.cs | 40 +++++++++ .../DatabaseContextModelSnapshot.cs | 6 ++ .../RecNet.Infrastructure.csproj | 2 +- .../Services/Configuration/ConfigService.cs | 23 ++--- .../Services/Tokens/JwtOptions.cs | 9 ++ .../Services/Tokens/TokenService.cs | 89 +++++++++++++++++++ 27 files changed, 581 insertions(+), 51 deletions(-) rename API/Configurations/{RecNetConfiguration.cs => RecNetOptions.cs} (74%) create mode 100644 API/Contracts/Neutrino/Enums/AuthenticationResultCode.cs create mode 100644 API/Contracts/Neutrino/Requests/NeutrinoAuthenticateRequest.cs create mode 100644 API/Contracts/Neutrino/Responses/NeutrinoAuthenticateResponse.cs create mode 100644 API/Contracts/Neutrino/Responses/NeutrinoResultCodeResponse.cs create mode 100644 API/Controllers/Neutrino/NeutrinoController.cs create mode 100644 RecNet.Application/Common/Security/ClaimsPrincipalExtensions.cs rename {RecNet.Application => RecNet.Domain}/Services/Configuration/IConfigService.cs (78%) create mode 100644 RecNet.Domain/Services/Tokens/ITokenService.cs create mode 100644 RecNet.Domain/Services/Tokens/TokenResult.cs create mode 100644 RecNet.Domain/Services/Tokens/TokenVerifyResult.cs create mode 100644 RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.Designer.cs create mode 100644 RecNet.Infrastructure/Persistence/Migrations/20260619025601_ProfileToggles.cs rename {RecNet.Application => RecNet.Infrastructure}/Services/Configuration/ConfigService.cs (77%) create mode 100644 RecNet.Infrastructure/Services/Tokens/JwtOptions.cs create mode 100644 RecNet.Infrastructure/Services/Tokens/TokenService.cs 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)); +}