Add JWT auth, token service & Neutrino endpoint

This commit is contained in:
Holden
2026-06-19 12:25:45 -05:00
parent 608def3ac8
commit 3eebfc9ba2
27 changed files with 581 additions and 51 deletions

View File

@@ -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<DatabaseContext>("recnet");
builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.Validate(options => !string.IsNullOrWhiteSpace(options.Secret), "JWT secret is required.")
.ValidateOnStart();
builder.Services.AddScoped<IProfileRepository, ProfileRepository>();
builder.Services.AddScoped<IServerConfigRepository, ServerConfigRepository>();
builder.Services.AddScoped<IConfigService, ConfigService>();
builder.Services.AddScoped<ITokenService, TokenService>();
return builder;
}

View File

@@ -0,0 +1,87 @@
// <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("20260619025601_ProfileToggles")]
partial class ProfileToggles
{
/// <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.Entities.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.Entities.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,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RecNet.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ProfileToggles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBanned",
table: "Profiles",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsModerator",
table: "Profiles",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBanned",
table: "Profiles");
migrationBuilder.DropColumn(
name: "IsModerator",
table: "Profiles");
}
}
}

View File

@@ -50,6 +50,12 @@ namespace RecNet.Infrastructure.Persistence.Migrations
.IsRequired()
.HasColumnType("jsonb");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsModerator")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)

View File

@@ -8,10 +8,10 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.5" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RecNet.Application\RecNet.Application.csproj" />
<ProjectReference Include="..\RecNet.Domain\RecNet.Domain.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,34 @@
using System.Text.Json;
using RecNet.Domain.Repositories;
using RecNet.Domain.Services.Configuration;
namespace RecNet.Infrastructure.Services.Configuration;
public class ConfigService(IServerConfigRepository repository) : IConfigService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<T?> GetAsync<T>(
string key,
T? defaultValue = default,
CancellationToken ct = default)
{
var config = await repository.GetAsync(key, ct);
if (config == null)
return defaultValue;
return JsonSerializer.Deserialize<T>(config.Value, JsonOptions);
}
public async Task SetAsync<T>(
string key,
T value,
CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(value, JsonOptions);
await repository.SetAsync(key, json, ct);
await repository.SaveChangesAsync(ct);
}
}

View File

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

View File

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