identity: add proxy controller

This commit is contained in:
sam 2024-05-21 21:21:34 +02:00
parent 727f2f6ba2
commit b95fb76cd4
9 changed files with 446 additions and 10 deletions

View file

@ -0,0 +1,329 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("GuildId")
.HasColumnType("uuid")
.HasColumnName("guild_id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("base_url");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<string>("PublicKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("public_key");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid")
.HasColumnName("author_id");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid")
.HasColumnName("channel_id");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant?>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<Instant>("LastFetchedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_fetched_at");
b.Property<string>("RemoteUserId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_user_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("PrivateKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("private_key");
b.Property<string>("PublicKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("public_key");
b.HasKey("Id")
.HasName("pk_instance");
b.ToTable("instance", (string)null);
});
modelBuilder.Entity("GuildUser", b =>
{
b.Property<Guid>("GuildsId")
.HasColumnType("uuid")
.HasColumnName("guilds_id");
b.Property<Guid>("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
}
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxchat.Chat.Migrations
{
/// <inheritdoc />
public partial class AddLastFetchedAtToUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Instant>(
name: "last_fetched_at",
table: "users",
type: "timestamp with time zone",
nullable: false,
defaultValue: NodaTime.Instant.FromUnixTimeTicks(0L));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "last_fetched_at",
table: "users");
}
}
}

View file

@ -162,6 +162,10 @@ namespace Foxchat.Chat.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("instance_id"); .HasColumnName("instance_id");
b.Property<Instant>("LastFetchedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_fetched_at");
b.Property<string>("RemoteUserId") b.Property<string>("RemoteUserId")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity;
using Foxchat.Identity.Database.Models; using Foxchat.Identity.Database.Models;
using Foxchat.Core; using Foxchat.Core;
using System.Diagnostics; using System.Diagnostics;
using Foxchat.Identity.Utils;
using NodaTime; using NodaTime;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -24,10 +25,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
var appToken = var appToken =
HttpContext.GetToken() ?? HttpContext.GetToken() ??
throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null 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", 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 var acct = new Account
{ {
@ -52,10 +54,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
{ {
var app = HttpContext.GetApplicationOrThrow(); var app = HttpContext.GetApplicationOrThrow();
var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); 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", 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) 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"); ?? throw new ApiError.NotFound("No user with that email found, or password is incorrect");

View file

@ -1,6 +1,7 @@
using Foxchat.Core; using Foxchat.Core;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Database.Models; using Foxchat.Identity.Database.Models;
using Foxchat.Identity.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime; using NodaTime;
@ -14,11 +15,12 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
public async Task<IActionResult> PostToken([FromBody] PostTokenRequest req) public async Task<IActionResult> PostToken([FromBody] PostTokenRequest req)
{ {
var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret);
var appScopes = app.ExpandScopes();
var scopes = req.Scope.Split(' '); 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) switch (req.GrantType)

View file

@ -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<IActionResult> CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) =>
Proxy<Guilds.Guild>(HttpMethod.Post, req);
}

View file

@ -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<IActionResult> Proxy<TResponse>(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<T> 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<TResponse>(method, server, path, acct.Id.ToString(), body);
return Ok(resp);
}
}

View file

@ -1,5 +1,6 @@
using Foxchat.Core; using Foxchat.Core;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Utils;
using NodaTime; using NodaTime;
namespace Foxchat.Identity.Middleware; namespace Foxchat.Identity.Middleware;
@ -21,10 +22,10 @@ public class ClientAuthorizationMiddleware(
} }
var token = ctx.GetToken(); 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."); throw new ApiError.Unauthorized("This endpoint requires an authenticated user.");
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes).Any()) if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.ExpandScopes()).Any())
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes)); throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes()));
await next(ctx); await next(ctx);
} }

View file

@ -24,4 +24,12 @@ public static class OauthUtils
return false; 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;
} }