diff options
17 files changed, 300 insertions, 45 deletions
diff --git a/ModAS.Server/Attributes/UserAuthAttribute.cs b/ModAS.Server/Attributes/UserAuthAttribute.cs new file mode 100644 index 0000000..ef8295a --- /dev/null +++ b/ModAS.Server/Attributes/UserAuthAttribute.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModAS.Server.Attributes; + +public class UserAuthAttribute : Attribute { + public AuthType AuthType { get; set; } + public AuthRoles AnyRoles { get; set; } + + public string ToJson() => JsonSerializer.Serialize(new { + AuthType, + AnyRoles + }); +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuthType { + User, + Server +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +[Flags] +public enum AuthRoles { + Administrator = 1 << 0, + Developer = 1 << 1, +} \ No newline at end of file diff --git a/ModAS.Server/Authentication/AuthMiddleware.cs b/ModAS.Server/Authentication/AuthMiddleware.cs new file mode 100644 index 0000000..8b7266f --- /dev/null +++ b/ModAS.Server/Authentication/AuthMiddleware.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using LibMatrix; +using LibMatrix.Homeservers; +using LibMatrix.Services; +using ModAS.Server.Attributes; +using MxApiExtensions.Services; + +namespace ModAS.Server.Authentication; + +public class AuthMiddleware(RequestDelegate next, ILogger<AuthMiddleware> logger, ModASConfiguration config, HomeserverProviderService hsProvider, AppServiceRegistration asr) { + public async Task InvokeAsync(HttpContext context) { + context.Request.Query.TryGetValue("access_token", out var queryAccessToken); + var accessToken = queryAccessToken.FirstOrDefault(); + accessToken ??= context.Request.GetTypedHeaders().Get<AuthenticationHeaderValue>("Authorization")?.Parameter; + + //get UserAuth custom attribute + var endpoint = context.GetEndpoint(); + if (endpoint is null) { + Console.WriteLine($"Ignoring authentication, endpoint is null!"); + await next(context); + return; + } + + var authAttribute = endpoint?.Metadata.GetMetadata<UserAuthAttribute>(); + if (authAttribute is not null) + logger.LogInformation($"{nameof(Route)} authorization: {authAttribute.ToJson()}"); + else if (string.IsNullOrWhiteSpace(accessToken)) { + // auth is optional if auth attribute isnt set + Console.WriteLine($"Allowing unauthenticated request, AuthAttribute is not set!"); + await next(context); + return; + } + + if (string.IsNullOrWhiteSpace(accessToken)) + if (authAttribute is not null) { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Missing access token" + }.GetAsObject()); + return; + } + + try { + switch (authAttribute.AuthType) { + case AuthType.User: + var authUser = await GetAuthUser(accessToken); + context.Items.Add("AuthUser", authUser); + break; + case AuthType.Server: + if (asr.HomeserverToken != accessToken) + throw new MatrixException() { + ErrorCode = "M_UNAUTHORIZED", + Error = "Invalid access token" + }; + + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + catch (MatrixException e) { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(e.GetAsObject()); + return; + } + + await next(context); + } + + private async Task<AuthUser> GetAuthUser(string accessToken) { + AuthenticatedHomeserverGeneric? homeserver; + homeserver = await hsProvider.GetAuthenticatedWithToken(config.ServerName, accessToken, config.HomeserverUrl); + + return new AuthUser() { + Homeserver = homeserver, + AccessToken = accessToken, + Roles = config.Roles.Where(r => r.Value.Contains(homeserver.WhoAmI.UserId)).Select(r => r.Key).ToList() + }; + } +} \ No newline at end of file diff --git a/ModAS.Server/Authentication/AuthUser.cs b/ModAS.Server/Authentication/AuthUser.cs new file mode 100644 index 0000000..f91656f --- /dev/null +++ b/ModAS.Server/Authentication/AuthUser.cs @@ -0,0 +1,9 @@ +using LibMatrix.Homeservers; + +namespace ModAS.Server.Authentication; + +public class AuthUser { + public required string AccessToken { get; set; } + public required List<string> Roles { get; set; } + public required AuthenticatedHomeserverGeneric Homeserver { get; set; } +} \ No newline at end of file diff --git a/ModAS.Server/Controllers/RoomQueryController.cs b/ModAS.Server/Controllers/Admin/RoomQueryController.cs index a49e5c0..a49e5c0 100644 --- a/ModAS.Server/Controllers/RoomQueryController.cs +++ b/ModAS.Server/Controllers/Admin/RoomQueryController.cs diff --git a/ModAS.Server/Controllers/AppService/PingController.cs b/ModAS.Server/Controllers/AppService/PingController.cs new file mode 100644 index 0000000..7b073c1 --- /dev/null +++ b/ModAS.Server/Controllers/AppService/PingController.cs @@ -0,0 +1,73 @@ +using System.IO.Pipelines; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using ArcaneLibs; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration.Json; +using ModAS.Server; +using ModAS.Server.Attributes; +using ModAS.Server.Services; +using MxApiExtensions.Services; + +namespace ModAS.Server.Controllers.AppService; + +[ApiController] +public class PingController( + AppServiceRegistration asr, + ModASConfiguration config, + UserProviderService userProvider, + RoomContextService roomContextService, + RoomStateCacheService stateCacheService) : ControllerBase { + private static List<string> _ignoredInvalidationEvents { get; set; } = [ + RoomMessageEventContent.EventId, + RoomMessageReactionEventContent.EventId + ]; + + [HttpPut("/_matrix/app/v1/transactions/{txnId}")] + [UserAuth(AuthType = AuthType.Server)] + public async Task<IActionResult> PutTransactions(string txnId) { + var data = await JsonSerializer.DeserializeAsync<EventList>(Request.Body); + Console.WriteLine( + $"PutTransaction: {txnId}: {data.Events.Count} events, {Util.BytesToString(Request.Headers.ContentLength ?? Request.ContentLength ?? Request.Body.Length)}"); + + if (!Directory.Exists("data")) + Directory.CreateDirectory("data"); + Directory.CreateDirectory($"data/{txnId}"); + // var pipe = PipeReader.Create(Request.Body); + // await using var file = System.IO.File.OpenWrite($"data/{txnId}"); + // await pipe.CopyToAsync(file); + // await pipe.CompleteAsync(); + // + // Console.WriteLine($"PutTransaction: {txnId}: {Util.BytesToString(file.Length)}"); + for (var i = 0; i < data.Events.Count; i++) { + var evt = data.Events[i]; + Console.WriteLine($"PutTransaction: {txnId}/{i}: {evt.Type} {evt.StateKey} {evt.Sender}"); + await System.IO.File.WriteAllTextAsync($"data/{txnId}/{i}-{evt.Type.Replace("/", "")}-{evt.StateKey.Replace("/", "")}-{evt.Sender?.Replace("/", "")}.json", + JsonSerializer.Serialize(evt)); + + if (evt.Sender.EndsWith(':' + config.ServerName)) { + Console.WriteLine("PutTransaction: sender is local user, updating data..."); + try { + var user = await userProvider.GetImpersonatedHomeserver(evt.Sender); + var rooms = await user.GetJoinedRooms(); + foreach (var room in rooms) { + await roomContextService.GetRoomContext(room); + } + } + catch (Exception e) { + Console.WriteLine($"PutTransaction: failed to update data: {e}"); + } + } + else + Console.WriteLine("PutTransaction: sender is remote user"); + + if (!string.IsNullOrWhiteSpace(evt.RoomId) && !_ignoredInvalidationEvents.Contains(evt.Type)) + await stateCacheService.InvalidateRoomState(evt.RoomId); + } + + return Ok(new { }); + } +} \ No newline at end of file diff --git a/ModAS.Server/Controllers/AppService/TransactionsController.cs b/ModAS.Server/Controllers/AppService/TransactionsController.cs index b74e1e1..53bfaf5 100644 --- a/ModAS.Server/Controllers/AppService/TransactionsController.cs +++ b/ModAS.Server/Controllers/AppService/TransactionsController.cs @@ -8,6 +8,7 @@ using LibMatrix.EventTypes.Spec; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration.Json; using ModAS.Server; +using ModAS.Server.Attributes; using ModAS.Server.Services; using MxApiExtensions.Services; @@ -26,16 +27,17 @@ public class TransactionsController( ]; [HttpPut("/_matrix/app/v1/transactions/{txnId}")] + [UserAuth(AuthType = AuthType.Server)] public async Task<IActionResult> PutTransactions(string txnId) { - if (!Request.Headers.ContainsKey("Authorization")) { - Console.WriteLine("PutTransaction: missing authorization header"); - return Unauthorized(); - } - - if (Request.GetTypedHeaders().Get<AuthenticationHeaderValue>("Authorization")?.Parameter != asr.HomeserverToken) { - Console.WriteLine($"PutTransaction: invalid authorization header: {Request.Headers["Authorization"]}"); - return Unauthorized(); - } + // if (!Request.Headers.ContainsKey("Authorization")) { + // Console.WriteLine("PutTransaction: missing authorization header"); + // return Unauthorized(); + // } + // + // if (Request.GetTypedHeaders().Get<AuthenticationHeaderValue>("Authorization")?.Parameter != asr.HomeserverToken) { + // Console.WriteLine($"PutTransaction: invalid authorization header: {Request.Headers["Authorization"]}"); + // return Unauthorized(); + // } var data = await JsonSerializer.DeserializeAsync<EventList>(Request.Body); Console.WriteLine( @@ -53,7 +55,8 @@ public class TransactionsController( for (var i = 0; i < data.Events.Count; i++) { var evt = data.Events[i]; Console.WriteLine($"PutTransaction: {txnId}/{i}: {evt.Type} {evt.StateKey} {evt.Sender}"); - await System.IO.File.WriteAllTextAsync($"data/{txnId}/{i}-{evt.Type}-{evt.StateKey}-{evt.Sender}.json", JsonSerializer.Serialize(evt)); + await System.IO.File.WriteAllTextAsync($"data/{txnId}/{i}-{evt.Type.Replace("/", "")}-{evt.StateKey.Replace("/", "")}-{evt.Sender?.Replace("/", "")}.json", + JsonSerializer.Serialize(evt)); if (evt.Sender.EndsWith(':' + config.ServerName)) { Console.WriteLine("PutTransaction: sender is local user, updating data..."); diff --git a/ModAS.Server/Controllers/DebugController.cs b/ModAS.Server/Controllers/Debug/DebugController.cs index f0fe91e..7bec3e5 100644 --- a/ModAS.Server/Controllers/DebugController.cs +++ b/ModAS.Server/Controllers/Debug/DebugController.cs @@ -2,16 +2,28 @@ using System.Collections.Frozen; using ArcaneLibs.Extensions; using Elastic.Apm; using Elastic.Apm.Api; -using LibMatrix; using LibMatrix.Homeservers; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using ModAS.Server.Attributes; using ModAS.Server.Services; using MxApiExtensions.Services; -namespace ModAS.Server.Controllers; +namespace ModAS.Server.Controllers.Debug; +/// <summary> +/// Provides debugging endpoints. +/// </summary> +/// <param name="config"><inheritdoc cref="ModASConfiguration"/></param> +/// <param name="authHsProvider"><inheritdoc cref="UserProviderService"/></param> +/// <param name="roomContextService"><inheritdoc cref="RoomContextService"/></param> [ApiController] +[UserAuth(AnyRoles = AuthRoles.Developer | AuthRoles.Administrator)] public class DebugController(ModASConfiguration config, UserProviderService authHsProvider, RoomContextService roomContextService) : ControllerBase { + /// <summary> + /// Returns a JSON object containing the request and response headers. + /// </summary> + /// <returns>JSON object with request and partial response headers.</returns> [HttpGet("/_matrix/_modas/debug")] public IActionResult Index() { return Ok(new { @@ -20,6 +32,10 @@ public class DebugController(ModASConfiguration config, UserProviderService auth }); } + /// <summary> + /// Returns a JSON object containing the configuration. + /// </summary> + /// <returns></returns> [HttpGet("/_matrix/_modas/debug/config")] public IActionResult Config() { return Ok(config); @@ -32,17 +48,17 @@ public class DebugController(ModASConfiguration config, UserProviderService auth [HttpGet("/_matrix/_modas/debug/test_locate_users")] public async IAsyncEnumerable<string> TestLocateUsers([FromQuery] string startUser) { - List<AuthenticatedHomeserverGeneric> foundUsers = (await authHsProvider.GetValidUsers()).Select(x=>x.Value).ToList(); - if(!foundUsers.Any(x=>x.WhoAmI.UserId == startUser)) { + List<AuthenticatedHomeserverGeneric> foundUsers = (await authHsProvider.GetValidUsers()).Select(x => x.Value).ToList(); + if (!foundUsers.Any(x => x.WhoAmI.UserId == startUser)) { foundUsers.Add(await authHsProvider.GetImpersonatedHomeserver(startUser)); } - + List<string> processedRooms = [], processedUsers = []; var foundNew = true; while (foundNew) { var span1 = currentTransaction.StartSpan("iterateUsers", ApiConstants.TypeApp); foundNew = false; - var usersToProcess = foundUsers.Where(x => !processedUsers.Any(y=>x.WhoAmI.UserId == y)).ToFrozenSet(); + var usersToProcess = foundUsers.Where(x => !processedUsers.Any(y => x.WhoAmI.UserId == y)).ToFrozenSet(); Console.WriteLine($"Got {usersToProcess.Count} users: {string.Join(", ", usersToProcess)}"); var rooms = usersToProcess.Select(async x => await x.GetJoinedRooms()); @@ -54,7 +70,7 @@ public class DebugController(ModASConfiguration config, UserProviderService auth processedRooms.Add(room.RoomId); var roomMembers = await room.GetMembersListAsync(false); foreach (var roomMember in roomMembers) { - if (roomMember.StateKey.EndsWith(':' + config.ServerName) && !foundUsers.Any(x=>x.WhoAmI.UserId == roomMember.StateKey)) { + if (roomMember.StateKey.EndsWith(':' + config.ServerName) && !foundUsers.Any(x => x.WhoAmI.UserId == roomMember.StateKey)) { foundUsers.Add(await authHsProvider.GetImpersonatedHomeserver(roomMember.StateKey)); foundNew = true; yield return roomMember.StateKey; diff --git a/ModAS.Server/Controllers/HomeController.cs b/ModAS.Server/Controllers/HomeController.cs index 5fd309f..eb17966 100644 --- a/ModAS.Server/Controllers/HomeController.cs +++ b/ModAS.Server/Controllers/HomeController.cs @@ -3,17 +3,14 @@ using Microsoft.AspNetCore.Mvc; namespace ModAS.Server.Controllers; +/// <summary> +/// Manages the visual homepage. +/// </summary> [ApiController] public class HomeController : Controller { - private readonly ILogger<HomeController> _logger; - - public HomeController(ILogger<HomeController> logger) { - _logger = logger; - } - + /// <inheritdoc cref="HomeController"/> [HttpGet("/_matrix/_modas")] public IActionResult Index() { - //return wwwroot/index.html return LocalRedirect("/index.html"); } } \ No newline at end of file diff --git a/ModAS.Server/Extensions/RequestHeaderExtensinos.cs b/ModAS.Server/Extensions/RequestHeaderExtensinos.cs new file mode 100644 index 0000000..e40ed8e --- /dev/null +++ b/ModAS.Server/Extensions/RequestHeaderExtensinos.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http.Headers; + +namespace MxApiExtensions.Extensions; + +public static class RequestHeaderExtensions { + public static bool TryGet<T>(this RequestHeaders headers, string name, out T? value) { + try { + value = headers.Get<T>(name); + return true; + } + catch (Exception) { + value = default; + } + + return false; + } +} \ No newline at end of file diff --git a/ModAS.Server/ModAS.Server.csproj b/ModAS.Server/ModAS.Server.csproj index 07cc67c..5bda8c0 100644 --- a/ModAS.Server/ModAS.Server.csproj +++ b/ModAS.Server/ModAS.Server.csproj @@ -12,6 +12,7 @@ <StripSymbols>true</StripSymbols> <OptimizationPreference>Speed</OptimizationPreference> <TieredPGO>true</TieredPGO> + <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> </PropertyGroup> <ItemGroup> diff --git a/ModAS.Server/Program.cs b/ModAS.Server/Program.cs index 0b3d121..248243f 100644 --- a/ModAS.Server/Program.cs +++ b/ModAS.Server/Program.cs @@ -9,6 +9,7 @@ using Elastic.Apm.AspNetCore; using Elastic.Apm.NetCoreAll; using LibMatrix; using LibMatrix.Services; +using ModAS.Server.Authentication; using ModAS.Server.Services; using MxApiExtensions.Services; @@ -114,13 +115,14 @@ Agent.AddFilter((ISpan span) => { return span; }); -///wwwroot app.UseFileServer(); -// app.UseStaticFiles(); -// app.UseDirectoryBrowser(); + +app.UseRouting(); app.UseCors("Open"); app.MapControllers(); +app.UseMiddleware<AuthMiddleware>(); + app.Run(); \ No newline at end of file diff --git a/ModAS.Server/Services/AuthenticationService.cs b/ModAS.Server/Services/AuthenticationService.cs index 27e12ad..8efc08c 100644 --- a/ModAS.Server/Services/AuthenticationService.cs +++ b/ModAS.Server/Services/AuthenticationService.cs @@ -1,20 +1,28 @@ +using System.Net.Http.Headers; using LibMatrix; using LibMatrix.Services; +using MxApiExtensions.Extensions; using MxApiExtensions.Services; namespace ModAS.Server.Services; -public class AuthenticationService(ILogger<AuthenticationService> logger, ModASConfiguration config, IHttpContextAccessor request, HomeserverProviderService homeserverProviderService) { +public class AuthenticationService( + ILogger<AuthenticationService> logger, + ModASConfiguration config, + IHttpContextAccessor request, + HomeserverProviderService homeserverProviderService) { private readonly HttpRequest _request = request.HttpContext!.Request; private static Dictionary<string, string> _tokenMap = new(); internal string? GetToken(bool fail = true) { - string? token; - if (_request.Headers.TryGetValue("Authorization", out var tokens)) { - token = tokens.FirstOrDefault()?[7..]; + //_request.GetTypedHeaders().Get<AuthenticationHeaderValue>("Authorization")?.Parameter != asr.HomeserverToken + + string? token = null; + if (_request.GetTypedHeaders().TryGet<AuthenticationHeaderValue>("Authorization", out var authHeader) && !string.IsNullOrWhiteSpace(authHeader?.Parameter)) { + token = authHeader.Parameter; } - else { + else if (_request.Query.ContainsKey("access_token")) { token = _request.Query["access_token"]; } @@ -47,18 +55,13 @@ public class AuthenticationService(ILogger<AuthenticationService> logger, ModASC .ToDictionary(l => l[0], l => l[1]); } - if (_tokenMap.TryGetValue(token, out var mxid)) return mxid; - var lookupTasks = new Dictionary<string, Task<string?>>(); - - logger.LogInformation("Looking up mxid for token {}", token); var hs = await homeserverProviderService.GetAuthenticatedWithToken(config.ServerName, token, config.HomeserverUrl); try { var res = hs.WhoAmI.UserId; logger.LogInformation("Got mxid {} for token {}", res, token); - await SaveMxidForToken(token, mxid); return res; } @@ -70,10 +73,4 @@ public class AuthenticationService(ILogger<AuthenticationService> logger, ModASC throw; } } - - - public async Task SaveMxidForToken(string token, string mxid) { - _tokenMap.Add(token, mxid); - await File.AppendAllLinesAsync("token_map", new[] { $"{token}\t{mxid}" }); - } -} +} \ No newline at end of file diff --git a/ModAS.Server/Services/ModASConfiguration.cs b/ModAS.Server/Services/ModASConfiguration.cs index 063e838..90f8e9e 100644 --- a/ModAS.Server/Services/ModASConfiguration.cs +++ b/ModAS.Server/Services/ModASConfiguration.cs @@ -1,5 +1,8 @@ namespace MxApiExtensions.Services; +/// <summary> +/// Configuration for ModAS. +/// </summary> public class ModASConfiguration { public ModASConfiguration(IConfiguration configuration) { configuration.GetRequiredSection("ModAS").Bind(this); @@ -7,4 +10,6 @@ public class ModASConfiguration { public string ServerName { get; set; } public string HomeserverUrl { get; set; } + + public Dictionary<string, List<string>> Roles { get; set; } } \ No newline at end of file diff --git a/ModAS.Server/Services/PingTask.cs b/ModAS.Server/Services/PingTask.cs new file mode 100644 index 0000000..99a8f40 --- /dev/null +++ b/ModAS.Server/Services/PingTask.cs @@ -0,0 +1,9 @@ +namespace ModAS.Server.Services; + +public class PingTask : IHostedService, IDisposable { + public Task StartAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + + public Task StopAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + + public void Dispose() => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/ModAS.Server/Version.cs b/ModAS.Server/Version.cs new file mode 100644 index 0000000..19aa81c --- /dev/null +++ b/ModAS.Server/Version.cs @@ -0,0 +1,5 @@ +namespace Modas.Server; +public static class Version +{ + public const string Text = "master@v0+"; +} diff --git a/ModAS.Server/appsettings.Development.json b/ModAS.Server/appsettings.Development.json index ef51c09..f9b6b0f 100644 --- a/ModAS.Server/appsettings.Development.json +++ b/ModAS.Server/appsettings.Development.json @@ -6,6 +6,7 @@ } }, "ModAS": { - + "ServerName": "rory.gay", + "HomeserverUrl": "https://matrix.rory.gay" } } diff --git a/commit b/commit new file mode 100755 index 0000000..69f5cc8 --- /dev/null +++ b/commit @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +cat <<EOF > ModAS.Server/Version.cs +namespace Modas.Server; +public static class Version +{ + public const string Text = "$(git rev-parse --abbrev-ref HEAD)@$(git describe --tags --abbrev=0)+`git log $(git describe --tags --abbrev=0)..HEAD --oneline`"; +} +EOF + +#git log $(git describe --tags --abbrev=0)..HEAD --oneline \ No newline at end of file |