Various C# continuations

This commit is contained in:
Rory&
2026-06-30 18:35:29 +02:00
parent 472d1159b1
commit 1dd3825ea0
9 changed files with 237 additions and 19 deletions
@@ -1,6 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using ArcaneLibs.Collections;
using ArcaneLibs.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
@@ -15,9 +17,9 @@ public class SpacebarAuthenticationService(ILogger<SpacebarAuthenticationService
private static bool _isInitialised;
private static readonly JwtSecurityTokenHandler Handler = new();
private static ECDsaSecurityKey _publicKey = null!, _privateKey = null!;
private static readonly TokenValidationParameters TokenValidationParameters = new() {
// IssuerSigningKey = new ECDsaSecurityKey(key),
ValidAlgorithms = ["ES512"],
LogValidationExceptions = true,
// These are required to be false for the token to be valid as they aren't provided by the token
@@ -29,10 +31,18 @@ public class SpacebarAuthenticationService(ILogger<SpacebarAuthenticationService
public async Task InitializeAsync() {
if (_isInitialised) return;
var secretFile = await File.ReadAllTextAsync(config.PublicKeyPath);
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
key.ImportFromPem(secretFile);
TokenValidationParameters.IssuerSigningKey = new ECDsaSecurityKey(key);
var publicKeyFile = await File.ReadAllTextAsync(config.PublicKeyPath);
var rawPublicKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
rawPublicKey.ImportFromPem(publicKeyFile);
_publicKey = new ECDsaSecurityKey(rawPublicKey);
var privateKeyFile = await File.ReadAllTextAsync(config.PrivateKeyPath);
var rawPrivateKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
rawPrivateKey.ImportFromPem(privateKeyFile);
_privateKey = new ECDsaSecurityKey(rawPrivateKey);
TokenValidationParameters.IssuerSigningKey = _publicKey;
_isInitialised = true;
}
@@ -72,9 +82,30 @@ public class SpacebarAuthenticationService(ILogger<SpacebarAuthenticationService
config.AuthCacheExpiry);
}
// public async Task<string> GenerateAccessTokenAsync(string userId) {
// // await db.Sessions.AddAsync(new() {
//
// // })
// }
public async Task<string> GenerateAccessTokenAsync(long userId, bool isAdminSession = false) {
if (!_isInitialised) await InitializeAsync();
// TODO: check for duplicate session IDs
var sess = db.Sessions.Add(new() {
UserId = userId,
SessionId = Random.Shared.GetString("ABCDEFGHIJKLMNOPQRSTUVEXYZ", 10),
IsAdminSession = isAdminSession,
Status = "unknown",
ClientStatus = "{}",
ClientInfo = "{}"
});
await db.SaveChangesAsync();
var res = Handler.CreateJwtSecurityToken(new() {
Claims = new Dictionary<string, object>() {
{ "id", userId.ToString() },
{ "iat", new DateTimeOffset(sess.Entity.CreatedAt).ToUnixTimeSeconds() },
{ "kid", SHA256.Create().ComputeHash(_publicKey.ECDsa.ExportSubjectPublicKeyInfoPem().AsBytes().ToArray()) },
{ "ver", 3 },
{ "did", sess.Entity.SessionId },
},
SigningCredentials = new SigningCredentials(_privateKey, "ES512")
});
return Handler.WriteToken(res);
}
}
@@ -22,7 +22,7 @@ public class RegisterRequest {
public string? Invite { get; set; }
[JsonPropertyName("date_of_birth")]
public DateTimeOffset? DateOfBirth { get; set; }
public DateOnly? DateOfBirth { get; set; }
[JsonPropertyName("gift_code_sku_id")]
public string? GiftCodeSkuId { get; set; }
+28 -4
View File
@@ -1,7 +1,10 @@
using System.Diagnostics.Metrics;
using System.Text.Json;
using System.Text.Json.Serialization;
using ArcaneLibs.Extensions;
using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using Spacebar.Interop.Authentication;
using Spacebar.Interop.Authentication.AspNetCore;
using Spacebar.Models.Db.Contexts;
@@ -12,11 +15,28 @@ if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("APPSETTINGS_P
builder.Configuration.AddJsonFile(Environment.GetEnvironmentVariable("APPSETTINGS_PATH")!);
// Add services to the container.
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource=> {
resource.AddService(serviceName: builder.Environment.ApplicationName, serviceNamespace: builder.Environment.ApplicationName);
})
.WithMetrics(builder =>
{
builder.AddPrometheusExporter();
builder.AddMeter("Microsoft.AspNetCore.Hosting", "Microsoft.AspNetCore.Server.Kestrel", "System.Runtime", "Microsoft.Extensions.Diagnostics.ResourceMonitoring", "Microsoft.AspNetCore.Routing", "Microsoft.AspNetCore.Diagnostics", "Microsoft.AspNetCore.RateLimiting", "Microsoft.AspNetCore.HeaderParsing", "Microsoft.AspNetCore.Server.Kestrel");
builder.AddView("http.server.request.duration",
new ExplicitBucketHistogramConfiguration
{
Boundaries = [0.0, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 10]
});
});
builder.Services.AddControllers(options => {
builder.Services.AddControllers(options =>
{
options.MaxValidationDepth = null;
// options.MaxIAsyncEnumerableBufferLimit = 1;
}).AddJsonOptions(options => {
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
options.JsonSerializerOptions.WriteIndented = true;
options.JsonSerializerOptions.MaxDepth = 100;
@@ -25,7 +45,8 @@ builder.Services.AddControllers(options => {
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddDbContextPool<SpacebarDbContext>(options => {
builder.Services.AddDbContextPool<SpacebarDbContext>(options =>
{
options
.UseNpgsql(builder.Configuration.GetConnectionString("Spacebar"))
.EnableDetailedErrors();
@@ -37,8 +58,11 @@ builder.Services.AddScoped<SpacebarAspNetAuthenticationService>();
var app = builder.Build();
app.MapPrometheusScrapingEndpoint();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) {
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
@@ -12,6 +12,8 @@
<ItemGroup>
<ProjectReference Include="..\DataMappings\Spacebar.DataMappings.Generic\Spacebar.DataMappings.Generic.csproj" Condition="'$(ContinuousIntegrationBuild)'!='true'"/>
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.16.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="Spacebar.DataMappings.Generic" Version="*-preview*" Condition="'$(ContinuousIntegrationBuild)'=='true'"/>
<ProjectReference Include="..\Interop\Spacebar.Interop.Authentication.AspNetCore\Spacebar.Interop.Authentication.AspNetCore.csproj" Condition="'$(ContinuousIntegrationBuild)'!='true'"/>
<PackageReference Include="Spacebar.Interop.Authentication.AspNetCore" Version="*-preview*" Condition="'$(ContinuousIntegrationBuild)'=='true'"/>
@@ -0,0 +1,148 @@
using System.Diagnostics;
using System.Text.Json.Nodes;
using ArcaneLibs.Collections;
using ArcaneLibs.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Spacebar.Interop.Authentication.AspNetCore;
using Spacebar.Models.Db.Contexts;
using Spacebar.DataMappings.Generic;
using Spacebar.Interop.Authentication;
using Spacebar.Models.Api;
using Spacebar.Models.Generic;
namespace Spacebar.UApi.Controllers;
[Route("/api/v{_}/auth/register")]
[Route("/api/auth/register")]
[ApiController]
public class RegisterController(ILogger<RegisterController> logger, SpacebarDbContext db, SpacebarAuthenticationService authService) : ControllerBase {
private static ExpiringSemaphoreCache<RegisterResponse> _sem = new();
[HttpPost]
public async Task<RegisterResponse> GetMemberAsync(RegisterRequest req) {
Task<RegisterResponse> task;
lock (_sem)
task = _sem.GetOrAdd($"{req.Email}{req.DateOfBirth}{req.Username}", async () => {
var sw = Stopwatch.StartNew();
// TODO: reg tokens - do we even want to continue supporting referrer-based tokens?
// TODO: config: register.allowNewRegistration
if (!req.Consent)
throw new SpacebarApiException("Consent is required to continue") {
Code = 0, Request = Request.Path, Errors = new() {
{
"consent",
new JsonObject() {
{ "code", "CONSENT_REQUIRED" },
{ "message", "You must consent to register on this instance." }
}
}
}
};
// TODO: config reg.disabled
// TODO: captchas
// TODO: do we even want to support multiaccounts on a single email?
// TODO: ip checks
// TODO: gift_code_sku_id?
// TODO: check password strength
// TODO: ability to config DoB checks
if (!req.DateOfBirth.HasValue || (DateTime.UtcNow - req.DateOfBirth.Value.ToDateTime(new TimeOnly(0, 0, 0))).TotalDays < (13 * 365))
throw new SpacebarApiException("Invalid date of birth") {
Code = 0, Request = Request.Path, Errors = new() {
{
"date_of_birth",
new JsonObject() {
{ "code", "DATE_OF_BIRTH_INVALID" },
{ "message", "You must enter a valid date of birth to continue" }
}
}
}
};
// TODO: password min length config, optional password
if (string.IsNullOrWhiteSpace(req.Password) || req.Password!.Length < 4)
throw new SpacebarApiException("Invalid password") {
Code = 0, Request = Request.Path, Errors = new() {
{
"password",
new JsonObject() {
{ "code", "PASSWORD_REQUIREMENTS_MIN_LENGTH" },
{ "message", "Your password must be at least 4 characters." }
}
}
}
};
// TODO: require invite
// TODO: global register ratelimit
// TODO: configurable username length limits
if (string.IsNullOrWhiteSpace(req.Username) || req.Username.Length < 4 || req.Username.Length > 255)
throw new SpacebarApiException("Invalid username length") {
Code = 0, Request = Request.Path, Errors = new() {
{
"username",
new JsonObject() {
{ "code", "BASE_TYPE_BAD_LENGTH" },
{ "message", "Username must be between 4 and 255 characters." }
}
}
}
};
var existingDiscrims = await db.Users.Where(x => x.Username == req.Username).ToListAsync();
// TODO: throw error if out of slots
string newDiscrim;
do {
newDiscrim = Random.Shared.Next(0, 1000).ToString("0000");
} while (existingDiscrims.Any(x => x.Discriminator == newDiscrim));
var user = db.Users.Add(new() {
Username = req.Username,
Discriminator = newDiscrim,
Email = req.Email,
Id = GenerateSnowflake(),
Data = new JsonObject() {
{ "hash", BCrypt.Net.BCrypt.HashPassword(req.Password, 12) },
{ "valid_tokens_since", DateTime.Now }
}.ToJson(),
// settings?
Premium = true,
PremiumSince = DateTime.Now,
PremiumType = 2,
Verified = true,
CreatedAt = DateTime.Now,
Bot = false,
Fingerprints = new(),
Bio = ""
});
await db.SaveChangesAsync();
// TODO: verification email
// TODO: autojoin
// TODO: handle invite key
return new RegisterResponse() {
Token = await authService.GenerateAccessTokenAsync(user.Entity.Id)
};
},
TimeSpan.FromMinutes(1));
return await task;
}
// TODO: move
private static long _snowflakeIdx = 0;
public static long GenerateSnowflake() {
_snowflakeIdx %= 4095;
return
((DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds()) << 22)
| ((long)(Environment.CurrentManagedThreadId % 31) << 17) //worker ID
| ((long)(Environment.ProcessId % 31) << 12) // process ID
| ((long)_snowflakeIdx++)
;
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ builder.Services.AddDbContextPool<SpacebarDbContext>(options => {
options
.UseNpgsql(builder.Configuration.GetConnectionString("Spacebar"))
.EnableDetailedErrors();
});
}, 64);
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CONFIG_PATH")))
builder.Services.AddSingleton<Config>(sp => {
@@ -20,6 +20,7 @@
<ProjectReference Include="..\Models\Spacebar.Models.Config\Spacebar.Models.Config.csproj"/>
<ProjectReference Include="..\Models\Spacebar.Models.Db\Spacebar.Models.Db.csproj"/>
<ProjectReference Include="..\Models\Spacebar.Models.Generic\Spacebar.Models.Generic.csproj"/>
<ProjectReference Include="..\Models\Spacebar.Models.Api\Spacebar.Models.Api.csproj" />
</ItemGroup>
<!-- For nix... -->
@@ -30,5 +31,6 @@
<PackageReference Include="Spacebar.Models.Config" Version="*-preview*"/>
<PackageReference Include="Spacebar.Models.Db" Version="*-preview*"/>
<PackageReference Include="Spacebar.Models.Generic" Version="*-preview*"/>
<PackageReference Include="Spacebar.Models.Api" Version="*-preview*"/>
</ItemGroup>
</Project>
@@ -1,12 +1,23 @@
{
"Logging": {
// "LogLevel": {
// "Default": "Information",
// "Microsoft.AspNetCore": "Warning"
// }
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Trace",
"Microsoft.AspNetCore.Server.Kestrel.Connections": "Information",
"Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets": "Information",
"Microsoft.AspNetCore.Mvc": "Warning",
"Microsoft.AspNetCore.HostFiltering": "Warning",
"Microsoft.AspNetCore.Cors": "Warning",
// "Microsoft.EntityFrameworkCore": "Warning"
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"ConnectionStrings": {
"Spacebar": "Host=127.0.0.1; Username=postgres; Database=spacebar; Port=5433; Include Error Detail=true; Maximum Pool Size=1000; Command Timeout=6000; Timeout=600;"
"Spacebar": "Host=127.0.0.1; Username=postgres; Database=spacebar; Port=5432; Include Error Detail=true; Maximum Pool Size=1000; Command Timeout=6000; Timeout=600;"
},
"Spacebar": {
"Authentication": {
@@ -14,7 +25,7 @@
"PrivateKeyPath": "../../../jwt.key"
},
"UApi":{
"FallbackApiEndpoint": "http://localhost:3113"
"FallbackApiEndpoint": "http://localhost:3001"
}
}
}