diff --git a/.gitignore b/.gitignore index cd42ee3..cd1b080 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ obj/ +.version diff --git a/Foxnouns.Backend/BuildInfo.cs b/Foxnouns.Backend/BuildInfo.cs new file mode 100644 index 0000000..fe39640 --- /dev/null +++ b/Foxnouns.Backend/BuildInfo.cs @@ -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"; + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/ApiControllerBase.cs b/Foxnouns.Backend/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000..b52ca2c --- /dev/null +++ b/Foxnouns.Backend/Controllers/ApiControllerBase.cs @@ -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(); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs new file mode 100644 index 0000000..9c5ecea --- /dev/null +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -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 GetUser(string userRef) + { + var user = await db.ResolveUserAsync(userRef); + return Ok(user); + } + + [HttpGet("@me")] + [Authorize("identify")] + public Task GetMe() + { + throw new NotImplementedException(); + } + + [HttpPatch("@me")] + public Task UpdateUser([FromBody] UpdateUserRequest req) + { + throw new NotImplementedException(); + } + + public record UpdateUserRequest(string? Username, string? DisplayName); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 4caa31c..30223b5 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -15,6 +15,7 @@ public class DatabaseContext : DbContext public DbSet AuthMethods { get; set; } public DbSet FediverseApplications { get; set; } public DbSet Tokens { get; set; } + public DbSet Applications { get; set; } public DatabaseContext(Config config) { diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs new file mode 100644 index 0000000..d3411ba --- /dev/null +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -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 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 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; + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs new file mode 100644 index 0000000..2b660a0 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs @@ -0,0 +1,470 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AuthType") + .HasColumnType("integer") + .HasColumnName("auth_type"); + + b.Property("FediverseApplicationId") + .HasColumnType("bigint") + .HasColumnName("fediverse_application_id"); + + b.Property("RemoteId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_id"); + + b.Property("RemoteUsername") + .HasColumnType("text") + .HasColumnName("remote_username"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Unlisted") + .HasColumnType("boolean") + .HasColumnName("unlisted"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ApplicationId") + .HasColumnType("bigint") + .HasColumnName("application_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("ManuallyExpired") + .HasColumnType("boolean") + .HasColumnName("manually_expired"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("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("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("MemberTitle") + .HasColumnType("text") + .HasColumnName("member_title"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("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", "Fields", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("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", "Names", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("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", "Pronouns", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("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("UserId") + .HasColumnType("bigint"); + + b1.Property("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("UserId") + .HasColumnType("bigint"); + + b1.Property("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("UserId") + .HasColumnType("bigint"); + + b1.Property("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 + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs b/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs new file mode 100644 index 0000000..d6694cd --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddApplications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "application_id", + table: "tokens", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "hash", + table: "tokens", + type: "bytea", + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.CreateTable( + name: "applications", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + client_id = table.Column(type: "text", nullable: false), + client_secret = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + scopes = table.Column(type: "text[]", nullable: false), + redirect_uris = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 92f7fdb..8d69ff0 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -22,6 +22,43 @@ namespace Foxnouns.Backend.Database.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + + b.Property("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("Id") @@ -144,10 +181,19 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("bigint") .HasColumnName("id"); + b.Property("ApplicationId") + .HasColumnType("bigint") + .HasColumnName("application_id"); + b.Property("ExpiresAt") .HasColumnType("timestamp with time zone") .HasColumnName("expires_at"); + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash"); + b.Property("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"); }); diff --git a/Foxnouns.Backend/Database/Models/Application.cs b/Foxnouns.Backend/Database/Models/Application.cs new file mode 100644 index 0000000..f4e2ecb --- /dev/null +++ b/Foxnouns.Backend/Database/Models/Application.cs @@ -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 + }; + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Models/Token.cs b/Foxnouns.Backend/Database/Models/Token.cs index 89a3c4c..1078cf1 100644 --- a/Foxnouns.Backend/Database/Models/Token.cs +++ b/Foxnouns.Backend/Database/Models/Token.cs @@ -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!; } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Snowflake.cs b/Foxnouns.Backend/Database/Snowflake.cs index 56a6097..5037d89 100644 --- a/Foxnouns.Backend/Database/Snowflake.cs +++ b/Foxnouns.Backend/Database/Snowflake.cs @@ -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(); diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs new file mode 100644 index 0000000..9a1cc32 --- /dev/null +++ b/Foxnouns.Backend/ExpectedError.cs @@ -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? 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); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 4fd06db..a457f45 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -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 { /// /// 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(SystemClock.Instance) + .AddSnowflakeGenerator(); + + public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services + .AddScoped() + .AddScoped() + .AddScoped(); + + public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app + .UseMiddleware() + .UseMiddleware() + .UseMiddleware(); + + 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().ForContext(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 765047f..8438390 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -1,29 +1,32 @@ + + net8.0 + enable + enable + - - net8.0 - enable - enable - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs new file mode 100644 index 0000000..4435fa6 --- /dev/null +++ b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs @@ -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(); + + 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; \ No newline at end of file diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs new file mode 100644 index 0000000..570d917 --- /dev/null +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -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(); + + 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(); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs new file mode 100644 index 0000000..e9c46ec --- /dev/null +++ b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs @@ -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 ?? ""; + 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, +} \ No newline at end of file diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 8a30f9d..c63f8bc 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -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() - .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(); @@ -37,4 +49,4 @@ app.Urls.Add(config.Address); app.Run(); -Log.CloseAndFlush(); +Log.CloseAndFlush(); \ No newline at end of file diff --git a/Foxnouns.Backend/Utils/OauthUtils.cs b/Foxnouns.Backend/Utils/OauthUtils.cs new file mode 100644 index 0000000..964d820 --- /dev/null +++ b/Foxnouns.Backend/Utils/OauthUtils.cs @@ -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"]; + + /// + /// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes. + /// + public static readonly string[] Scopes = ["identify", ..UserScopes, ..MemberScopes]; + + /// + /// 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. + /// + public static readonly string[] ApplicationScopes = [..Scopes, "user", "member"]; + + public static string[] ExpandScopes(this string[] scopes) + { + if (scopes.Contains("*")) return Scopes; + List 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('='); +} \ No newline at end of file diff --git a/build_info.sh b/build_info.sh new file mode 100755 index 0000000..e643b51 --- /dev/null +++ b/build_info.sh @@ -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 \ No newline at end of file