From 5366cfbc60792614c63fad64a96d0ced93ae2d93 Mon Sep 17 00:00:00 2001 From: SirConstance Date: Thu, 11 Sep 2025 15:43:11 +0200 Subject: [PATCH] Support AutoDetect --- .../Controllers/DiscoveryController.cs | 115 ++++++++++++++ .../Controllers/JwtController.cs | 8 +- .../Controllers/WellKnownController.cs | 25 +++ .../MareSynchronosAuthService.csproj | 28 +--- .../Services/DiscoveryPresenceService.cs | 88 +++++++++++ .../Services/DiscoveryWellKnownProvider.cs | 149 ++++++++++++++++++ .../MareSynchronosAuthService/Startup.cs | 21 ++- .../Controllers/DiscoveryNotifyController.cs | 40 +++++ .../MareSynchronosServer/Startup.cs | 22 +++ 9 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs create mode 100644 MareSynchronosServer/MareSynchronosAuthService/Controllers/WellKnownController.cs create mode 100644 MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs create mode 100644 MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs create mode 100644 MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs new file mode 100644 index 0000000..8fb1fbf --- /dev/null +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/DiscoveryController.cs @@ -0,0 +1,115 @@ +using System.Text.Json.Serialization; +using MareSynchronosAuthService.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MareSynchronosAuthService.Controllers; + +[Authorize] +[ApiController] +[Route("discovery")] +public class DiscoveryController : Controller +{ + private readonly DiscoveryWellKnownProvider _provider; + private readonly DiscoveryPresenceService _presence; + + public DiscoveryController(DiscoveryWellKnownProvider provider, DiscoveryPresenceService presence) + { + _provider = provider; + _presence = presence; + } + + public sealed class QueryRequest + { + [JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty(); + } + + public sealed class QueryResponseEntry + { + [JsonPropertyName("hash")] public string Hash { get; set; } = string.Empty; + [JsonPropertyName("token")] public string Token { get; set; } = string.Empty; + [JsonPropertyName("displayName")] public string? DisplayName { get; set; } + } + + [HttpPost("query")] + public IActionResult Query([FromBody] QueryRequest req) + { + if (_provider.IsExpired()) + { + return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" }); + } + + var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty; + if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0) + return Json(Array.Empty()); + + List matches = new(); + foreach (var h in req.Hashes.Distinct(StringComparer.Ordinal)) + { + var (found, token, displayName) = _presence.TryMatchAndIssueToken(uid, h); + if (found) + { + matches.Add(new QueryResponseEntry { Hash = h, Token = token, DisplayName = displayName }); + } + } + + return Json(matches); + } + + public sealed class RequestDto + { + [JsonPropertyName("token")] public string Token { get; set; } = string.Empty; + } + + [HttpPost("request")] + public async Task RequestPair([FromBody] RequestDto req) + { + if (string.IsNullOrEmpty(req.Token)) return BadRequest(); + if (_presence.ValidateToken(req.Token, out var targetUid)) + { + // Phase 3 (minimal): notify target via mare-server internal controller + try + { + var fromUid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty; + var fromAlias = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Alias)?.Value ?? string.Empty; + + using var http = new HttpClient(); + // Use same host as public (goes through nginx) + var baseUrl = $"{Request.Scheme}://{Request.Host.Value}"; + var url = new Uri(new Uri(baseUrl), "/main/discovery/notifyRequest"); + + // Generate internal JWT + var serverToken = HttpContext.RequestServices.GetRequiredService().Token; + http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", serverToken); + var payload = System.Text.Json.JsonSerializer.Serialize(new { targetUid, fromUid, fromAlias }); + var resp = await http.PostAsync(url, new StringContent(payload, System.Text.Encoding.UTF8, "application/json")); + // ignore response content, just return Accepted to caller + } + catch { /* ignore */ } + + return Accepted(); + } + return BadRequest(new { code = "INVALID_TOKEN" }); + } + + public sealed class PublishRequest + { + [JsonPropertyName("hashes")] public string[] Hashes { get; set; } = Array.Empty(); + [JsonPropertyName("displayName")] public string? DisplayName { get; set; } + } + + [HttpPost("publish")] + public IActionResult Publish([FromBody] PublishRequest req) + { + if (_provider.IsExpired()) + { + return BadRequest(new { code = "DISCOVERY_SALT_EXPIRED" }); + } + var uid = User?.Claims?.FirstOrDefault(c => c.Type == MareSynchronosShared.Utils.MareClaimTypes.Uid)?.Value ?? string.Empty; + if (string.IsNullOrEmpty(uid) || req?.Hashes == null || req.Hashes.Length == 0) + return Accepted(); + + _presence.Publish(uid, req.Hashes, req.DisplayName); + return Accepted(); + } +} diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs index 11323db..58dc171 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs @@ -134,10 +134,13 @@ public class JwtController : Controller var tokenContent = tokenResponse as ContentResult; if (tokenContent == null) return tokenResponse; + var provider = HttpContext.RequestServices.GetService(); + var wk = provider?.GetWellKnownJson(Request.Scheme, Request.Host.Value) + ?? _configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.WellKnown), string.Empty); return Json(new AuthReplyDto { Token = tokenContent.Content, - WellKnown = _configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.WellKnown), string.Empty), + WellKnown = wk, }); } @@ -189,5 +192,4 @@ public class JwtController : Controller var handler = new JwtSecurityTokenHandler(); return handler.CreateJwtSecurityToken(token); } -} - +} \ No newline at end of file diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/WellKnownController.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/WellKnownController.cs new file mode 100644 index 0000000..060eeaf --- /dev/null +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/WellKnownController.cs @@ -0,0 +1,25 @@ +using MareSynchronosAuthService.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MareSynchronosAuthService.Controllers; + +[AllowAnonymous] +[ApiController] +public class WellKnownController : Controller +{ + private readonly DiscoveryWellKnownProvider _provider; + + public WellKnownController(DiscoveryWellKnownProvider provider) + { + _provider = provider; + } + + [HttpGet("/.well-known/Umbra/client")] + public IActionResult Get() + { + var json = _provider.GetWellKnownJson(Request.Scheme, Request.Host.Value); + return Content(json, "application/json"); + } +} + diff --git a/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj b/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj index 184a178..15e6abd 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj +++ b/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj @@ -4,30 +4,16 @@ net9.0 enable enable + false - - - - - - - - Never - - - - - - - - - - - - Never - + + Always + + + Always + diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs new file mode 100644 index 0000000..116a44e --- /dev/null +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryPresenceService.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MareSynchronosAuthService.Services; + +public class DiscoveryPresenceService : IHostedService +{ + private readonly ILogger _logger; + + // hash -> (uid, expiresAt, displayName?) + private readonly ConcurrentDictionary _presence = new(StringComparer.Ordinal); + + // token -> (targetUid, expiresAt) + private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal); + + private Timer? _cleanupTimer; + private readonly TimeSpan _presenceTtl = TimeSpan.FromMinutes(5); + private readonly TimeSpan _tokenTtl = TimeSpan.FromMinutes(2); + + public DiscoveryPresenceService(ILogger logger) + { + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _cleanupTimer = new Timer(_ => Cleanup(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _cleanupTimer?.Dispose(); + return Task.CompletedTask; + } + + private void Cleanup() + { + var now = DateTimeOffset.UtcNow; + foreach (var kv in _presence.ToArray()) + { + if (kv.Value.ExpiresAt <= now) _presence.TryRemove(kv.Key, out _); + } + foreach (var kv in _tokens.ToArray()) + { + if (kv.Value.ExpiresAt <= now) _tokens.TryRemove(kv.Key, out _); + } + } + + public void Publish(string uid, IEnumerable hashes, string? displayName = null) + { + var exp = DateTimeOffset.UtcNow.Add(_presenceTtl); + foreach (var h in hashes.Distinct(StringComparer.Ordinal)) + { + _presence[h] = (uid, exp, displayName); + } + _logger.LogDebug("Discovery presence published for {uid} with {n} hashes", uid, hashes.Count()); + } + + public (bool Found, string Token, string? DisplayName) TryMatchAndIssueToken(string requesterUid, string hash) + { + if (_presence.TryGetValue(hash, out var entry)) + { + if (string.Equals(entry.Uid, requesterUid, StringComparison.Ordinal)) return (false, string.Empty, null); + var token = Guid.NewGuid().ToString("N"); + _tokens[token] = (entry.Uid, DateTimeOffset.UtcNow.Add(_tokenTtl)); + return (true, token, entry.DisplayName); + } + return (false, string.Empty, null); + } + + public bool ValidateToken(string token, out string targetUid) + { + targetUid = string.Empty; + if (_tokens.TryGetValue(token, out var info)) + { + if (info.ExpiresAt > DateTimeOffset.UtcNow) + { + targetUid = info.TargetUid; + return true; + } + _tokens.TryRemove(token, out _); + } + return false; + } +} + diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs new file mode 100644 index 0000000..6989e45 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/DiscoveryWellKnownProvider.cs @@ -0,0 +1,149 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MareSynchronosAuthService.Services; + +public class DiscoveryWellKnownProvider : IHostedService +{ + private readonly ILogger _logger; + private readonly object _lock = new(); + private byte[] _currentSalt = Array.Empty(); + private DateTimeOffset _currentSaltExpiresAt; + private Timer? _rotationTimer; + private readonly TimeSpan _saltTtl = TimeSpan.FromDays(30 * 6); + private readonly int _refreshSec = 86400; // 24h + + public DiscoveryWellKnownProvider(ILogger logger) + { + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + RotateSalt(); + var period = _saltTtl; + if (period.TotalMilliseconds > uint.MaxValue - 1) + { + _logger.LogInformation("DiscoveryWellKnownProvider: salt TTL {ttl} exceeds timer limit, skipping rotation timer in beta", period); + _rotationTimer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + else + { + _rotationTimer = new Timer(_ => RotateSalt(), null, period, period); + } + _logger.LogInformation("DiscoveryWellKnownProvider started. Salt expires at {exp}", _currentSaltExpiresAt); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _rotationTimer?.Dispose(); + return Task.CompletedTask; + } + + private void RotateSalt() + { + lock (_lock) + { + _currentSalt = RandomNumberGenerator.GetBytes(32); + _currentSaltExpiresAt = DateTimeOffset.UtcNow.Add(_saltTtl); + } + } + + public bool IsExpired() + { + lock (_lock) + { + return DateTimeOffset.UtcNow > _currentSaltExpiresAt; + } + } + + public string GetWellKnownJson(string scheme, string host) + { + var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase); + var wsScheme = isHttps ? "wss" : "ws"; + var httpScheme = isHttps ? "https" : "http"; + + byte[] salt; + DateTimeOffset exp; + lock (_lock) + { + salt = _currentSalt.ToArray(); + exp = _currentSaltExpiresAt; + } + + var root = new WellKnownRoot + { + ApiUrl = $"{wsScheme}://{host}", + HubUrl = $"{wsScheme}://{host}/mare", + Features = new() { NearbyDiscovery = true }, + NearbyDiscovery = new() + { + Enabled = true, + HashAlgo = "sha256", + SaltB64 = Convert.ToBase64String(salt), + SaltExpiresAt = exp, + RefreshSec = _refreshSec, + Endpoints = new() + { + Publish = $"{httpScheme}://{host}/discovery/publish", + Query = $"{httpScheme}://{host}/discovery/query", + Request = $"{httpScheme}://{host}/discovery/request" + }, + Policies = new() + { + MaxQueryBatch = 100, + MinQueryIntervalMs = 2000, + RateLimitPerMin = 30, + TokenTtlSec = 120 + } + } + }; + + return JsonSerializer.Serialize(root, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); + } + + private sealed class WellKnownRoot + { + [JsonPropertyName("api_url")] public string ApiUrl { get; set; } = string.Empty; + [JsonPropertyName("hub_url")] public string HubUrl { get; set; } = string.Empty; + [JsonPropertyName("skip_negotiation")] public bool SkipNegotiation { get; set; } = true; + [JsonPropertyName("transports")] public string[] Transports { get; set; } = new[] { "websockets" }; + [JsonPropertyName("features")] public Features Features { get; set; } = new(); + [JsonPropertyName("nearby_discovery")] public Nearby NearbyDiscovery { get; set; } = new(); + } + + private sealed class Features + { + [JsonPropertyName("nearby_discovery")] public bool NearbyDiscovery { get; set; } + } + + private sealed class Nearby + { + [JsonPropertyName("enabled")] public bool Enabled { get; set; } + [JsonPropertyName("hash_algo")] public string HashAlgo { get; set; } = "sha256"; + [JsonPropertyName("salt_b64")] public string SaltB64 { get; set; } = string.Empty; + [JsonPropertyName("salt_expires_at")] public DateTimeOffset SaltExpiresAt { get; set; } + [JsonPropertyName("refresh_sec")] public int RefreshSec { get; set; } + [JsonPropertyName("endpoints")] public Endpoints Endpoints { get; set; } = new(); + [JsonPropertyName("policies")] public Policies Policies { get; set; } = new(); + } + + private sealed class Endpoints + { + [JsonPropertyName("publish")] public string? Publish { get; set; } + [JsonPropertyName("query")] public string? Query { get; set; } + [JsonPropertyName("request")] public string? Request { get; set; } + } + + private sealed class Policies + { + [JsonPropertyName("max_query_batch")] public int MaxQueryBatch { get; set; } + [JsonPropertyName("min_query_interval_ms")] public int MinQueryIntervalMs { get; set; } + [JsonPropertyName("rate_limit_per_min")] public int RateLimitPerMin { get; set; } + [JsonPropertyName("token_ttl_sec")] public int TokenTtlSec { get; set; } + } +} diff --git a/MareSynchronosServer/MareSynchronosAuthService/Startup.cs b/MareSynchronosServer/MareSynchronosAuthService/Startup.cs index bd06ef5..6d1c8c2 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Startup.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Startup.cs @@ -16,6 +16,7 @@ using System.Text; using MareSynchronosShared.Data; using Microsoft.EntityFrameworkCore; using Prometheus; +using Microsoft.AspNetCore.HttpOverrides; using MareSynchronosShared.Utils.Configuration; namespace MareSynchronosAuthService; @@ -35,6 +36,12 @@ public class Startup { var config = app.ApplicationServices.GetRequiredService>(); + // Respect X-Forwarded-* headers from the reverse proxy so generated links use the public scheme/host + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedFor + }); + app.UseRouting(); app.UseHttpMetrics(); @@ -65,8 +72,8 @@ public class Startup ConfigureRedis(services, mareConfig); - services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddHostedService(provider => provider.GetRequiredService()); @@ -75,6 +82,11 @@ public class Startup services.Configure(_configuration.GetRequiredSection("MareSynchronos")); services.AddSingleton(); + // Nearby discovery services (well-known + presence) + services.AddSingleton(); + services.AddHostedService(p => p.GetRequiredService()); + services.AddSingleton(); + services.AddHostedService(p => p.GetRequiredService()); ConfigureAuthorization(services); @@ -88,8 +100,11 @@ public class Startup services.AddControllers().ConfigureApplicationPartManager(a => { a.FeatureProviders.Remove(a.FeatureProviders.OfType().First()); - a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController))); + a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController), typeof(WellKnownController), typeof(DiscoveryController))); }); + + services.AddSingleton(); + services.AddHostedService(p => p.GetRequiredService()); } private static void ConfigureAuthorization(IServiceCollection services) diff --git a/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs b/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs new file mode 100644 index 0000000..863ec16 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Controllers/DiscoveryNotifyController.cs @@ -0,0 +1,40 @@ +using MareSynchronos.API.SignalR; +using MareSynchronosShared.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using System.Text.Json.Serialization; + +namespace MareSynchronosServer.Controllers; + +[Route("/main/discovery")] +[Authorize(Policy = "Internal")] +public class DiscoveryNotifyController : Controller +{ + private readonly ILogger _logger; + private readonly IHubContext _hub; + + public DiscoveryNotifyController(ILogger logger, IHubContext hub) + { + _logger = logger; + _hub = hub; + } + + public sealed class NotifyRequestDto + { + [JsonPropertyName("targetUid")] public string TargetUid { get; set; } = string.Empty; + [JsonPropertyName("fromUid")] public string FromUid { get; set; } = string.Empty; + [JsonPropertyName("fromAlias")] public string? FromAlias { get; set; } + } + + [HttpPost("notifyRequest")] + public async Task NotifyRequest([FromBody] NotifyRequestDto dto) + { + if (string.IsNullOrEmpty(dto.TargetUid)) return BadRequest(); + var name = string.IsNullOrEmpty(dto.FromAlias) ? dto.FromUid : dto.FromAlias; + var msg = $"{name} wants to pair with you (Nearby)"; + _logger.LogInformation("Discovery notify request to {target} from {from}", dto.TargetUid, name); + await _hub.Clients.User(dto.TargetUid).Client_ReceiveServerMessage(MareSynchronos.API.Data.Enum.MessageSeverity.Information, msg); + return Accepted(); + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/Startup.cs b/MareSynchronosServer/MareSynchronosServer/Startup.cs index da4ec70..20c837f 100644 --- a/MareSynchronosServer/MareSynchronosServer/Startup.cs +++ b/MareSynchronosServer/MareSynchronosServer/Startup.cs @@ -13,6 +13,7 @@ using Prometheus; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; +using Microsoft.AspNetCore.HttpOverrides; using StackExchange.Redis; using StackExchange.Redis.Extensions.Core.Configuration; using System.Net; @@ -198,6 +199,21 @@ public class Startup ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.GetValue(nameof(MareConfigurationBase.Jwt)))), }; + + // Allow SignalR WebSocket connections to authenticate via access_token query on the hub path + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"].ToString(); + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(IMareHub.Path)) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; }); services.AddAuthentication(o => @@ -307,6 +323,12 @@ public class Startup var config = app.ApplicationServices.GetRequiredService>(); + // Respect X-Forwarded-* headers from reverse proxies (required for correct scheme/host) + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedFor + }); + app.UseIpRateLimiting(); app.UseRouting();