add a bunch of stuff copied from Foxchat.NET
This commit is contained in:
parent
f4c0a40259
commit
6114f384a0
21 changed files with 1216 additions and 35 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
bin/
|
||||
obj/
|
||||
.version
|
||||
|
|
26
Foxnouns.Backend/BuildInfo.cs
Normal file
26
Foxnouns.Backend/BuildInfo.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
namespace Foxnouns.Backend;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public static string Hash { get; private set; } = "(unknown)";
|
||||
public static string Version { get; private set; } = "(unknown)";
|
||||
|
||||
public static async Task ReadBuildInfo()
|
||||
{
|
||||
await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version");
|
||||
if (stream == null) return;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var data = (await reader.ReadToEndAsync()).Trim().Split("\n");
|
||||
if (data.Length < 3) return;
|
||||
|
||||
Hash = data[0];
|
||||
var dirty = data[2] == "dirty";
|
||||
|
||||
var versionData = data[1].Split("-");
|
||||
if (versionData.Length < 3) return;
|
||||
Version = versionData[0];
|
||||
if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}";
|
||||
if (dirty) Version += ".dirty";
|
||||
}
|
||||
}
|
13
Foxnouns.Backend/Controllers/ApiControllerBase.cs
Normal file
13
Foxnouns.Backend/Controllers/ApiControllerBase.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Foxnouns.Backend.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authenticate]
|
||||
public class ApiControllerBase : ControllerBase
|
||||
{
|
||||
internal Token? Token => HttpContext.GetToken();
|
||||
internal new User? User => HttpContext.GetUser();
|
||||
}
|
32
Foxnouns.Backend/Controllers/UsersController.cs
Normal file
32
Foxnouns.Backend/Controllers/UsersController.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using System.Diagnostics;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Foxnouns.Backend.Controllers;
|
||||
|
||||
[Route("/api/v2/users")]
|
||||
public class UsersController(DatabaseContext db) : ApiControllerBase
|
||||
{
|
||||
[HttpGet("{userRef}")]
|
||||
public async Task<IActionResult> GetUser(string userRef)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef);
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
[HttpGet("@me")]
|
||||
[Authorize("identify")]
|
||||
public Task<IActionResult> GetMe()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[HttpPatch("@me")]
|
||||
public Task<IActionResult> UpdateUser([FromBody] UpdateUserRequest req)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public record UpdateUserRequest(string? Username, string? DisplayName);
|
||||
}
|
|
@ -15,6 +15,7 @@ public class DatabaseContext : DbContext
|
|||
public DbSet<AuthMethod> AuthMethods { get; set; }
|
||||
public DbSet<FediverseApplication> FediverseApplications { get; set; }
|
||||
public DbSet<Token> Tokens { get; set; }
|
||||
public DbSet<Application> Applications { get; set; }
|
||||
|
||||
public DatabaseContext(Config config)
|
||||
{
|
||||
|
|
47
Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
Normal file
47
Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using System.Security.Cryptography;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Foxnouns.Backend.Database;
|
||||
|
||||
public static class DatabaseQueryExtensions
|
||||
{
|
||||
public static async Task<User> ResolveUserAsync(this DatabaseContext context, string userRef)
|
||||
{
|
||||
User? user;
|
||||
if (Snowflake.TryParse(userRef, out var snowflake))
|
||||
{
|
||||
user = await context.Users
|
||||
.Include(u => u.Members)
|
||||
.FirstOrDefaultAsync(u => u.Id == snowflake);
|
||||
if (user != null) return user;
|
||||
}
|
||||
|
||||
user = await context.Users
|
||||
.Include(u => u.Members)
|
||||
.FirstOrDefaultAsync(u => u.Username == userRef);
|
||||
if (user != null) return user;
|
||||
throw new FoxnounsError.UnknownEntityError(typeof(User));
|
||||
}
|
||||
|
||||
public static async Task<Application> GetFrontendApplicationAsync(this DatabaseContext context)
|
||||
{
|
||||
var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0));
|
||||
if (app != null) return app;
|
||||
|
||||
app = new Application
|
||||
{
|
||||
Id = new Snowflake(0),
|
||||
ClientId = RandomNumberGenerator.GetHexString(32, true),
|
||||
ClientSecret = OauthUtils.RandomToken(48),
|
||||
Name = "pronouns.cc",
|
||||
Scopes = ["*"],
|
||||
RedirectUris = [],
|
||||
};
|
||||
|
||||
context.Add(app);
|
||||
await context.SaveChangesAsync();
|
||||
return app;
|
||||
}
|
||||
}
|
470
Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs
generated
Normal file
470
Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs
generated
Normal file
|
@ -0,0 +1,470 @@
|
|||
// <auto-generated />
|
||||
using Foxnouns.Backend.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 Foxnouns.Backend.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(DatabaseContext))]
|
||||
[Migration("20240528125310_AddApplications")]
|
||||
partial class AddApplications
|
||||
{
|
||||
/// <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("Foxnouns.Backend.Database.Models.Application", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_id");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_secret");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string[]>("RedirectUris")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("redirect_uris");
|
||||
|
||||
b.Property<string[]>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("scopes");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_applications");
|
||||
|
||||
b.ToTable("applications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int>("AuthType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("auth_type");
|
||||
|
||||
b.Property<long?>("FediverseApplicationId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("fediverse_application_id");
|
||||
|
||||
b.Property<string>("RemoteId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("remote_id");
|
||||
|
||||
b.Property<string>("RemoteUsername")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("remote_username");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_auth_methods");
|
||||
|
||||
b.HasIndex("FediverseApplicationId")
|
||||
.HasDatabaseName("ix_auth_methods_fediverse_application_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_auth_methods_user_id");
|
||||
|
||||
b.ToTable("auth_methods", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_id");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_secret");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("domain");
|
||||
|
||||
b.Property<int>("InstanceType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_fediverse_applications");
|
||||
|
||||
b.ToTable("fediverse_applications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("avatar");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("bio");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string[]>("Links")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<bool>("Unlisted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("unlisted");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_members");
|
||||
|
||||
b.HasIndex("UserId", "Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_members_user_id_name");
|
||||
|
||||
b.ToTable("members", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ApplicationId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("application_id");
|
||||
|
||||
b.Property<Instant>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("ManuallyExpired")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("manually_expired");
|
||||
|
||||
b.Property<string[]>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("scopes");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tokens");
|
||||
|
||||
b.HasIndex("ApplicationId")
|
||||
.HasDatabaseName("ix_tokens_application_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_tokens_user_id");
|
||||
|
||||
b.ToTable("tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("avatar");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("bio");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string[]>("Links")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("MemberTitle")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("member_title");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_username");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
||||
{
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
|
||||
.WithMany()
|
||||
.HasForeignKey("FediverseApplicationId")
|
||||
.HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
|
||||
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||
.WithMany("AuthMethods")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_auth_methods_users_user_id");
|
||||
|
||||
b.Navigation("FediverseApplication");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
|
||||
{
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_members_users_user_id");
|
||||
|
||||
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
|
||||
{
|
||||
b1.Property<long>("MemberId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("MemberId");
|
||||
|
||||
b1.ToTable("members");
|
||||
|
||||
b1.ToJson("fields");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("MemberId")
|
||||
.HasConstraintName("fk_members_members_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
|
||||
{
|
||||
b1.Property<long>("MemberId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("MemberId");
|
||||
|
||||
b1.ToTable("members");
|
||||
|
||||
b1.ToJson("names");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("MemberId")
|
||||
.HasConstraintName("fk_members_members_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
|
||||
{
|
||||
b1.Property<long>("MemberId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("MemberId");
|
||||
|
||||
b1.ToTable("members");
|
||||
|
||||
b1.ToJson("pronouns");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("MemberId")
|
||||
.HasConstraintName("fk_members_members_id");
|
||||
});
|
||||
|
||||
b.Navigation("Fields")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Names")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Pronouns")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
||||
{
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
|
||||
.WithMany()
|
||||
.HasForeignKey("ApplicationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tokens_applications_application_id");
|
||||
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tokens_users_user_id");
|
||||
|
||||
b.Navigation("Application");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
||||
{
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
|
||||
{
|
||||
b1.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("UserId")
|
||||
.HasName("pk_users");
|
||||
|
||||
b1.ToTable("users");
|
||||
|
||||
b1.ToJson("fields");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("UserId")
|
||||
.HasConstraintName("fk_users_users_user_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
|
||||
{
|
||||
b1.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("UserId")
|
||||
.HasName("pk_users");
|
||||
|
||||
b1.ToTable("users");
|
||||
|
||||
b1.ToJson("names");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("UserId")
|
||||
.HasConstraintName("fk_users_users_user_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
|
||||
{
|
||||
b1.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Capacity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("UserId")
|
||||
.HasName("pk_users");
|
||||
|
||||
b1.ToTable("users");
|
||||
|
||||
b1.ToJson("pronouns");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("UserId")
|
||||
.HasConstraintName("fk_users_users_user_id");
|
||||
});
|
||||
|
||||
b.Navigation("Fields")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Names")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Pronouns")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
||||
{
|
||||
b.Navigation("AuthMethods");
|
||||
|
||||
b.Navigation("Members");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Foxnouns.Backend.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddApplications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "application_id",
|
||||
table: "tokens",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "hash",
|
||||
table: "tokens",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "applications",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false),
|
||||
client_id = table.Column<string>(type: "text", nullable: false),
|
||||
client_secret = table.Column<string>(type: "text", nullable: false),
|
||||
name = table.Column<string>(type: "text", nullable: false),
|
||||
scopes = table.Column<string[]>(type: "text[]", nullable: false),
|
||||
redirect_uris = table.Column<string[]>(type: "text[]", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_applications", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tokens_application_id",
|
||||
table: "tokens",
|
||||
column: "application_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_tokens_applications_application_id",
|
||||
table: "tokens",
|
||||
column: "application_id",
|
||||
principalTable: "applications",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_tokens_applications_application_id",
|
||||
table: "tokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "applications");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_tokens_application_id",
|
||||
table: "tokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "application_id",
|
||||
table: "tokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "hash",
|
||||
table: "tokens");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,43 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_id");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_secret");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string[]>("RedirectUris")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("redirect_uris");
|
||||
|
||||
b.Property<string[]>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("scopes");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_applications");
|
||||
|
||||
b.ToTable("applications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
@ -144,10 +181,19 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ApplicationId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("application_id");
|
||||
|
||||
b.Property<Instant>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("ManuallyExpired")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("manually_expired");
|
||||
|
@ -164,6 +210,9 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
b.HasKey("Id")
|
||||
.HasName("pk_tokens");
|
||||
|
||||
b.HasIndex("ApplicationId")
|
||||
.HasDatabaseName("ix_tokens_application_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_tokens_user_id");
|
||||
|
||||
|
@ -315,6 +364,13 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
||||
{
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
|
||||
.WithMany()
|
||||
.HasForeignKey("ApplicationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tokens_applications_application_id");
|
||||
|
||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
|
@ -322,6 +378,8 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.IsRequired()
|
||||
.HasConstraintName("fk_tokens_users_user_id");
|
||||
|
||||
b.Navigation("Application");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
|
|
40
Foxnouns.Backend/Database/Models/Application.cs
Normal file
40
Foxnouns.Backend/Database/Models/Application.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
using System.Security.Cryptography;
|
||||
using Foxnouns.Backend.Utils;
|
||||
|
||||
namespace Foxnouns.Backend.Database.Models;
|
||||
|
||||
public class Application : BaseModel
|
||||
{
|
||||
public required string ClientId { get; init; }
|
||||
public required string ClientSecret { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string[] Scopes { get; init; }
|
||||
public required string[] RedirectUris { get; set; }
|
||||
|
||||
public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes,
|
||||
string[] redirectUrls)
|
||||
{
|
||||
var clientId = RandomNumberGenerator.GetHexString(32, true);
|
||||
var clientSecret = OauthUtils.RandomToken(48);
|
||||
|
||||
if (scopes.Except(OauthUtils.ApplicationScopes).Any())
|
||||
{
|
||||
throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes));
|
||||
}
|
||||
|
||||
if (redirectUrls.Any(s => !OauthUtils.ValidateRedirectUri(s)))
|
||||
{
|
||||
throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls));
|
||||
}
|
||||
|
||||
return new Application
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
ClientId = clientId,
|
||||
ClientSecret = clientSecret,
|
||||
Name = name,
|
||||
Scopes = scopes,
|
||||
RedirectUris = redirectUrls
|
||||
};
|
||||
}
|
||||
}
|
|
@ -4,12 +4,14 @@ namespace Foxnouns.Backend.Database.Models;
|
|||
|
||||
public class Token : BaseModel
|
||||
{
|
||||
public required byte[] Hash { get; init; }
|
||||
public required Instant ExpiresAt { get; init; }
|
||||
public required string[] Scopes { get; init; }
|
||||
public bool ManuallyExpired { get; set; }
|
||||
|
||||
public bool IsExpired => ManuallyExpired || ExpiresAt < SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
public Snowflake UserId { get; init; }
|
||||
public User User { get; init; } = null!;
|
||||
|
||||
public Snowflake ApplicationId { get; set; }
|
||||
public Application Application { get; set; } = null!;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
|
||||
|
@ -43,6 +44,14 @@ public readonly struct Snowflake(ulong value)
|
|||
public static implicit operator Snowflake(ulong n) => new(n);
|
||||
public static implicit operator Snowflake(long n) => new((ulong)n);
|
||||
|
||||
public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake)
|
||||
{
|
||||
snowflake = null;
|
||||
if (!ulong.TryParse(input, out var res)) return false;
|
||||
snowflake = new Snowflake(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
|
||||
|
|
67
Foxnouns.Backend/ExpectedError.cs
Normal file
67
Foxnouns.Backend/ExpectedError.cs
Normal file
|
@ -0,0 +1,67 @@
|
|||
using System.Net;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Foxnouns.Backend;
|
||||
|
||||
public class FoxnounsError(string message, Exception? inner = null) : Exception(message)
|
||||
{
|
||||
public Exception? Inner => inner;
|
||||
|
||||
public class DatabaseError(string message, Exception? inner = null) : FoxnounsError(message, inner);
|
||||
|
||||
public class UnknownEntityError(Type entityType, Exception? inner = null)
|
||||
: DatabaseError($"Entity of type {entityType.Name} not found", inner);
|
||||
}
|
||||
|
||||
public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxnounsError(message)
|
||||
{
|
||||
public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError;
|
||||
|
||||
public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized);
|
||||
|
||||
public class Forbidden(string message, IEnumerable<string>? scopes = null)
|
||||
: ApiError(message, statusCode: HttpStatusCode.Forbidden)
|
||||
{
|
||||
public readonly string[] Scopes = scopes?.ToArray() ?? [];
|
||||
}
|
||||
|
||||
public class BadRequest(string message, ModelStateDictionary? modelState = null)
|
||||
: ApiError(message, statusCode: HttpStatusCode.BadRequest)
|
||||
{
|
||||
public readonly ModelStateDictionary? Errors = modelState;
|
||||
|
||||
public JObject ToJson()
|
||||
{
|
||||
var o = new JObject
|
||||
{
|
||||
{ "status", (int)HttpStatusCode.BadRequest },
|
||||
{ "code", ErrorCode.BadRequest.ToString() }
|
||||
};
|
||||
if (Errors == null) return o;
|
||||
|
||||
var a = new JArray();
|
||||
foreach (var error in Errors.Where(e => e.Value is { Errors.Count: > 0 }))
|
||||
{
|
||||
var errorObj = new JObject
|
||||
{
|
||||
{ "key", error.Key },
|
||||
{
|
||||
"errors",
|
||||
new JArray(error.Value!.Errors.Select(e => new JObject { { "message", e.ErrorMessage } }))
|
||||
}
|
||||
};
|
||||
|
||||
a.Add(errorObj);
|
||||
}
|
||||
|
||||
o.Add("errors", a);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
public class NotFound(string message) : ApiError(message, statusCode: HttpStatusCode.NotFound);
|
||||
|
||||
public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
|
||||
}
|
|
@ -1,10 +1,13 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
|
||||
namespace Foxnouns.Backend.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls.
|
||||
|
@ -22,7 +25,7 @@ public static class ServiceCollectionExtensions
|
|||
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
||||
.WriteTo.Console(theme: AnsiConsoleTheme.Code);
|
||||
.WriteTo.Console();
|
||||
|
||||
if (config.SeqLogUrl != null)
|
||||
{
|
||||
|
@ -39,7 +42,6 @@ public static class ServiceCollectionExtensions
|
|||
|
||||
public static Config AddConfiguration(this WebApplicationBuilder builder)
|
||||
{
|
||||
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddConfiguration();
|
||||
|
||||
|
@ -57,4 +59,56 @@ public static class ServiceCollectionExtensions
|
|||
.AddIniFile(file, optional: false, reloadOnChange: true)
|
||||
.AddEnvironmentVariables();
|
||||
}
|
||||
|
||||
public static IServiceCollection AddCustomServices(this IServiceCollection services) => services
|
||||
.AddSingleton<IClock>(SystemClock.Instance)
|
||||
.AddSnowflakeGenerator();
|
||||
|
||||
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||
.AddScoped<ErrorHandlerMiddleware>()
|
||||
.AddScoped<AuthenticationMiddleware>()
|
||||
.AddScoped<AuthorizationMiddleware>();
|
||||
|
||||
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app
|
||||
.UseMiddleware<ErrorHandlerMiddleware>()
|
||||
.UseMiddleware<AuthenticationMiddleware>()
|
||||
.UseMiddleware<AuthorizationMiddleware>();
|
||||
|
||||
public static async Task Initialize(this WebApplication app, string[] args)
|
||||
{
|
||||
await BuildInfo.ReadBuildInfo();
|
||||
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
|
||||
logger.Information("Starting Foxnouns.NET {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash);
|
||||
|
||||
var pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList();
|
||||
if (args.Contains("--migrate") || args.Contains("--migrate-and-start"))
|
||||
{
|
||||
if (pendingMigrations.Count == 0)
|
||||
{
|
||||
logger.Information("Migrations requested but no migrations are required");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Information("Migrating database to the latest version");
|
||||
await db.Database.MigrateAsync();
|
||||
logger.Information("Successfully migrated database");
|
||||
}
|
||||
|
||||
if (!args.Contains("--migrate-and-start")) Environment.Exit(0);
|
||||
}
|
||||
else if (pendingMigrations.Count > 0)
|
||||
{
|
||||
logger.Fatal(
|
||||
"There are {Count} pending migrations, run server with --migrate or --migrate-and-start to run migrations.",
|
||||
pendingMigrations.Count);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
logger.Information("Initializing frontend OAuth application");
|
||||
_ = await db.GetFrontendApplicationAsync();
|
||||
}
|
||||
}
|
|
@ -1,29 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NodaTime" Version="3.1.11" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="7.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.5"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
||||
<PackageReference Include="NodaTime" Version="3.1.11"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
|
||||
<PackageReference Include="Serilog" Version="3.1.1"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="7.0.1"/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
|
||||
<Exec Command="../build_info.sh" IgnoreExitCode="false">
|
||||
</Exec>
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
66
Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs
Normal file
66
Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs
Normal file
|
@ -0,0 +1,66 @@
|
|||
using System.Security.Cryptography;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxnouns.Backend.Middleware;
|
||||
|
||||
public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddleware
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
var endpoint = ctx.GetEndpoint();
|
||||
var metadata = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>();
|
||||
|
||||
if (metadata == null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var header = ctx.Request.Headers.Authorization.ToString();
|
||||
if (!OauthUtils.TryFromBase64String(header, out var rawToken))
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var hash = SHA512.HashData(rawToken);
|
||||
var oauthToken = await db.Tokens
|
||||
.Include(t => t.Application)
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired);
|
||||
if (oauthToken == null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.SetToken(oauthToken);
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
private const string Key = "token";
|
||||
|
||||
public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token);
|
||||
public static User? GetUser(this HttpContext ctx) => ctx.GetToken()?.User;
|
||||
|
||||
public static User GetUserOrThrow(this HttpContext ctx) =>
|
||||
ctx.GetUser() ?? throw new ApiError.AuthenticationError("No user in HttpContext");
|
||||
|
||||
public static Token? GetToken(this HttpContext ctx)
|
||||
{
|
||||
if (ctx.Items.TryGetValue(Key, out var token))
|
||||
return token as Token;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AuthenticateAttribute : Attribute;
|
36
Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
Normal file
36
Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
Normal file
|
@ -0,0 +1,36 @@
|
|||
using Foxnouns.Backend.Utils;
|
||||
|
||||
namespace Foxnouns.Backend.Middleware;
|
||||
|
||||
public class AuthorizationMiddleware : IMiddleware
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
var endpoint = ctx.GetEndpoint();
|
||||
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||
|
||||
if (attribute == null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = ctx.GetToken();
|
||||
if (token == null)
|
||||
throw new ApiError.Unauthorized("This endpoint requires an authenticated user.");
|
||||
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any())
|
||||
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.",
|
||||
attribute.Scopes.Except(token.Scopes.ExpandScopes()));
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AuthorizeAttribute(params string[] scopes) : Attribute
|
||||
{
|
||||
public readonly bool RequireAdmin = scopes.Contains(":admin");
|
||||
public readonly bool RequireModerator = scopes.Contains(":moderator");
|
||||
|
||||
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray();
|
||||
}
|
93
Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs
Normal file
93
Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs
Normal file
|
@ -0,0 +1,93 @@
|
|||
using System.Net;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace Foxnouns.Backend.Middleware;
|
||||
|
||||
public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(ctx);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var type = e.TargetSite?.DeclaringType ?? typeof(ErrorHandlerMiddleware);
|
||||
var typeName = e.TargetSite?.DeclaringType?.FullName ?? "<unknown>";
|
||||
var logger = baseLogger.ForContext(type);
|
||||
|
||||
if (ctx.Response.HasStarted)
|
||||
{
|
||||
logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName,
|
||||
ctx.Request.Path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e is ApiError ae)
|
||||
{
|
||||
ctx.Response.StatusCode = (int)ae.StatusCode;
|
||||
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
||||
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||
if (ae is ApiError.Forbidden fe)
|
||||
{
|
||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||
{
|
||||
Status = (int)fe.StatusCode,
|
||||
Code = ErrorCode.Forbidden,
|
||||
Message = fe.Message,
|
||||
Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||
{
|
||||
Status = (int)ae.StatusCode,
|
||||
Code = ErrorCode.GenericApiError,
|
||||
Message = ae.Message,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e is FoxnounsError fce)
|
||||
{
|
||||
logger.Error(fce.Inner ?? fce, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||
}
|
||||
|
||||
ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
||||
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||
{
|
||||
Status = (int)HttpStatusCode.InternalServerError,
|
||||
Code = ErrorCode.InternalServerError,
|
||||
Message = "Internal server error",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record HttpApiError
|
||||
{
|
||||
public required int Status { get; init; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public required ErrorCode Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string[]? Scopes { get; init; }
|
||||
}
|
||||
|
||||
public enum ErrorCode
|
||||
{
|
||||
InternalServerError,
|
||||
Forbidden,
|
||||
BadRequest,
|
||||
AuthenticationError,
|
||||
GenericApiError,
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
using Foxnouns.Backend;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Serilog;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
@ -15,21 +17,31 @@ builder.Services
|
|||
options.SerializerSettings.ContractResolver = new DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new SnakeCaseNamingStrategy()
|
||||
});
|
||||
})
|
||||
.ConfigureApiBehaviorOptions(options =>
|
||||
{
|
||||
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
|
||||
new ApiError.BadRequest("Bad request", actionContext.ModelState).ToJson()
|
||||
);
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddDbContext<DatabaseContext>()
|
||||
.AddSnowflakeGenerator()
|
||||
.AddCustomServices()
|
||||
.AddCustomMiddleware()
|
||||
.AddEndpointsApiExplorer()
|
||||
.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await app.Initialize(args);
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseRouting();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors();
|
||||
app.UseCustomMiddleware();
|
||||
app.MapControllers();
|
||||
|
||||
app.Urls.Clear();
|
||||
|
|
67
Foxnouns.Backend/Utils/OauthUtils.cs
Normal file
67
Foxnouns.Backend/Utils/OauthUtils.cs
Normal file
|
@ -0,0 +1,67 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace Foxnouns.Backend.Utils;
|
||||
|
||||
public static class OauthUtils
|
||||
{
|
||||
public const string ClientCredentials = "client_credentials";
|
||||
public const string AuthorizationCode = "authorization_code";
|
||||
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
|
||||
|
||||
public static readonly string[] UserScopes =
|
||||
["user.read_hidden", "user.read_privileged", "user.update"];
|
||||
|
||||
public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"];
|
||||
|
||||
/// <summary>
|
||||
/// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes.
|
||||
/// </summary>
|
||||
public static readonly string[] Scopes = ["identify", ..UserScopes, ..MemberScopes];
|
||||
|
||||
/// <summary>
|
||||
/// All scopes that can be granted to applications and tokens. Includes the catch-all token scopes,
|
||||
/// except for "*" which is only granted to the frontend.
|
||||
/// </summary>
|
||||
public static readonly string[] ApplicationScopes = [..Scopes, "user", "member"];
|
||||
|
||||
public static string[] ExpandScopes(this string[] scopes)
|
||||
{
|
||||
if (scopes.Contains("*")) return Scopes;
|
||||
List<string> expandedScopes = ["identify"];
|
||||
if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes);
|
||||
if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes);
|
||||
|
||||
return expandedScopes.ToArray();
|
||||
}
|
||||
|
||||
public static bool ValidateRedirectUri(string uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheme = new Uri(uri).Scheme;
|
||||
return !ForbiddenSchemes.Contains(scheme);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static bool TryFromBase64String(string b64, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(b64);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
bytes = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string RandomToken(int bytes = 48) =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
||||
}
|
4
build_info.sh
Executable file
4
build_info.sh
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
(git rev-parse HEAD &&
|
||||
git describe --tags --always --long &&
|
||||
if test -z "$(git ls-files --exclude-standard --modified --deleted --others)"; then echo clean; else echo dirty; fi) > ../.version
|
Loading…
Reference in a new issue