diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs new file mode 100644 index 0000000..745ab75 --- /dev/null +++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs @@ -0,0 +1,329 @@ +// +using System; +using Foxchat.Chat.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + [DbContext(typeof(ChatContext))] + [Migration("20240521191115_AddLastFetchedAtToUsers")] + partial class AddLastFetchedAtToUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GuildId") + .HasColumnType("uuid") + .HasColumnName("guild_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Topic") + .HasColumnType("text") + .HasColumnName("topic"); + + b.HasKey("Id") + .HasName("pk_channels"); + + b.HasIndex("GuildId") + .HasDatabaseName("ix_channels_guild_id"); + + b.ToTable("channels", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("pk_guilds"); + + b.HasIndex("OwnerId") + .HasDatabaseName("ix_guilds_owner_id"); + + b.ToTable("guilds", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("base_url"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_identity_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_identity_instances_domain"); + + b.ToTable("identity_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_messages"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_messages_author_id"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_messages_channel_id"); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("LastFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_fetched_at"); + + b.Property("RemoteUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_user_id"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("InstanceId") + .HasDatabaseName("ix_users_instance_id"); + + b.HasIndex("RemoteUserId", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_remote_user_id_instance_id"); + + b.HasIndex("Username", "InstanceId") + .IsUnique() + .HasDatabaseName("ix_users_username_instance_id"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxchat.Core.Database.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("private_key"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.HasKey("Id") + .HasName("pk_instance"); + + b.ToTable("instance", (string)null); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.Property("GuildsId") + .HasColumnType("uuid") + .HasColumnName("guilds_id"); + + b.Property("UsersId") + .HasColumnType("uuid") + .HasColumnName("users_id"); + + b.HasKey("GuildsId", "UsersId") + .HasName("pk_guild_user"); + + b.HasIndex("UsersId") + .HasDatabaseName("ix_guild_user_users_id"); + + b.ToTable("guild_user", (string)null); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild") + .WithMany("Channels") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_channels_guilds_guild_id"); + + b.Navigation("Guild"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Owner") + .WithMany("OwnedGuilds") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guilds_users_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b => + { + b.HasOne("Foxchat.Chat.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_users_author_id"); + + b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_messages_channels_channel_id"); + + b.Navigation("Author"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_users_identity_instances_instance_id"); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("GuildUser", b => + { + b.HasOne("Foxchat.Chat.Database.Models.Guild", null) + .WithMany() + .HasForeignKey("GuildsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_guilds_guilds_id"); + + b.HasOne("Foxchat.Chat.Database.Models.User", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_user_users_users_id"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b => + { + b.Navigation("OwnedGuilds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs new file mode 100644 index 0000000..f4a99ba --- /dev/null +++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxchat.Chat.Migrations +{ + /// + public partial class AddLastFetchedAtToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "last_fetched_at", + table: "users", + type: "timestamp with time zone", + nullable: false, + defaultValue: NodaTime.Instant.FromUnixTimeTicks(0L)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "last_fetched_at", + table: "users"); + } + } +} diff --git a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs index f560113..184e756 100644 --- a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs +++ b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs @@ -162,6 +162,10 @@ namespace Foxchat.Chat.Migrations .HasColumnType("uuid") .HasColumnName("instance_id"); + b.Property("LastFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_fetched_at"); + b.Property("RemoteUserId") .IsRequired() .HasColumnType("text") diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index 796edb9..a4f5080 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity; using Foxchat.Identity.Database.Models; using Foxchat.Core; using System.Diagnostics; +using Foxchat.Identity.Utils; using NodaTime; using Microsoft.EntityFrameworkCore; @@ -24,10 +25,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null + var appScopes = appToken.ExpandScopes(); - if (req.Scopes.Except(appToken.Scopes).Any()) + if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", - req.Scopes.Except(appToken.Scopes)); + req.Scopes.Except(appScopes)); var acct = new Account { @@ -52,10 +54,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c { var app = HttpContext.GetApplicationOrThrow(); var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); + var appScopes = appToken.ExpandScopes(); - if (req.Scopes.Except(appToken.Scopes).Any()) + if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", - req.Scopes.Except(appToken.Scopes)); + req.Scopes.Except(appScopes)); var acct = await db.Accounts.FirstOrDefaultAsync(a => a.Email == req.Email) ?? throw new ApiError.NotFound("No user with that email found, or password is incorrect"); diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index b01ee77..ed7dfc8 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -1,6 +1,7 @@ using Foxchat.Core; using Foxchat.Identity.Database; using Foxchat.Identity.Database.Models; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; using NodaTime; @@ -14,11 +15,12 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : public async Task PostToken([FromBody] PostTokenRequest req) { var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); + var appScopes = app.ExpandScopes(); var scopes = req.Scope.Split(' '); - if (scopes.Except(app.Scopes).Any()) + if (scopes.Except(appScopes).Any()) { - throw new ApiError.BadRequest("Invalid or unauthorized scopes"); + throw new ApiError.Forbidden("Invalid or unauthorized scopes", scopes.Except(appScopes)); } switch (req.GrantType) diff --git a/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs new file mode 100644 index 0000000..9b5a8d4 --- /dev/null +++ b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs @@ -0,0 +1,21 @@ +using Foxchat.Core.Federation; +using Foxchat.Core.Models; +using Foxchat.Core.Models.Http; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Proxy; + +[Route("/_fox/proxy/guilds")] +public class GuildsProxyController( + ILogger logger, + ChatInstanceResolverService chatInstanceResolverService, + RequestSigningService requestSigningService) + : ProxyControllerBase(logger, chatInstanceResolverService, requestSigningService) +{ + [Authorize("chat_client")] + [HttpPost] + public Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) => + Proxy(HttpMethod.Post, req); +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs new file mode 100644 index 0000000..4728ca4 --- /dev/null +++ b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs @@ -0,0 +1,38 @@ +using Foxchat.Core; +using Foxchat.Core.Federation; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Proxy; + +[ApiController] +[ClientAuthenticate] +public class ProxyControllerBase( + ILogger logger, + ChatInstanceResolverService chatInstanceResolverService, + RequestSigningService requestSigningService) : ControllerBase +{ + internal async Task Proxy(HttpMethod method, object? body = null) where TResponse : class + { + var acct = HttpContext.GetAccountOrThrow(); + + var path = HttpContext.Request.Path.ToString(); + if (!path.StartsWith("/_fox/proxy")) + throw new FoxchatError("Proxy used for endpoint that does not start with /_fox/proxy"); + path = $"/_fox/chat/{path[12..]}"; + + if (!HttpContext.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var serverHeader)) + throw new ApiError.BadRequest($"Invalid or missing {RequestSigningService.SERVER_HEADER} header."); + var server = serverHeader.ToString(); + + logger.Debug("Proxying {Method} request to {Domain}{Path}", method, server, path); + + // Identity instances always initiate federation, so we have to make sure the instance knows about us. + // This also serves as a way to make sure the instance being requested actually exists. + await chatInstanceResolverService.ResolveChatInstanceAsync(serverHeader.ToString()); + + var resp = await requestSigningService.RequestAsync(method, server, path, acct.Id.ToString(), body); + return Ok(resp); + } +} \ No newline at end of file diff --git a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs index 92df517..701fb05 100644 --- a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs @@ -1,5 +1,6 @@ using Foxchat.Core; using Foxchat.Identity.Database; +using Foxchat.Identity.Utils; using NodaTime; namespace Foxchat.Identity.Middleware; @@ -21,10 +22,10 @@ public class ClientAuthorizationMiddleware( } var token = ctx.GetToken(); - if (token == null || token.Expires > clock.GetCurrentInstant()) + if (token == null || token.Expires < clock.GetCurrentInstant()) throw new ApiError.Unauthorized("This endpoint requires an authenticated user."); - if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes).Any()) - throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes)); + if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.ExpandScopes()).Any()) + throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes())); await next(ctx); } diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index 26188bb..bf698a2 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -24,4 +24,12 @@ public static class OauthUtils return false; } } -} + + public static string[] ExpandScopes(this Token token) => token.Scopes.Contains("chat_client") + ? Scopes + : token.Scopes; + + public static string[] ExpandScopes(this Application app) => app.Scopes.Contains("chat_client") + ? Scopes + : app.Scopes; +} \ No newline at end of file