diff --git a/.gitignore b/.gitignore index cd1b080..56d5d08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ obj/ .version +config.ini diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 39a417f..bb2add8 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -4,21 +4,28 @@ namespace Foxnouns.Backend; public class Config { - public string Host { get; set; } = "localhost"; - public int Port { get; set; } = 3000; - public string BaseUrl { get; set; } = null!; + public string Host { get; init; } = "localhost"; + public int Port { get; init; } = 3000; + public string BaseUrl { get; init; } = null!; public string Address => $"http://{Host}:{Port}"; - public string? SeqLogUrl { get; set; } - public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; + public string? SeqLogUrl { get; init; } + public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug; - public DatabaseConfig Database { get; set; } = new(); + public DatabaseConfig Database { get; init; } = new(); + public DiscordAuthConfig DiscordAuth { get; init; } = new(); public class DatabaseConfig { - public string Url { get; set; } = string.Empty; - public int? Timeout { get; set; } - public int? MaxPoolSize { get; set; } + public string Url { get; init; } = string.Empty; + public int? Timeout { get; init; } + public int? MaxPoolSize { get; init; } + } + + public class DiscordAuthConfig + { + public string? ClientId { get; init; } + public string? ClientSecret { get; init; } } } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs new file mode 100644 index 0000000..96b10c3 --- /dev/null +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -0,0 +1,38 @@ +using System.Web; +using Foxnouns.Backend.Services; +using Microsoft.AspNetCore.Mvc; +using NodaTime; + +namespace Foxnouns.Backend.Controllers.Authentication; + +[Route("/api/v2/auth")] +public class AuthController(Config config, KeyCacheService keyCacheSvc) : ApiControllerBase +{ + [HttpPost("urls")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UrlsResponse))] + public async Task UrlsAsync() + { + var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync()); + string? discord = null; + if (config.DiscordAuth.ClientId != null && config.DiscordAuth.ClientSecret != null) + discord = + $"https://discord.com/oauth2/authorize?response_type=code" + + $"&client_id={config.DiscordAuth.ClientId}&scope=identify" + + $"&prompt=none&state={state}" + + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/login/discord")}"; + + return Ok(new UrlsResponse(discord, null, null)); + } + + private record UrlsResponse( + string? Discord, + string? Google, + string? Tumblr + ); + + internal record AuthResponse( + UserRendererService.UserResponse User, + string Token, + Instant ExpiresAt + ); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 4934ff5..2fb8c54 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -6,9 +6,11 @@ namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/discord")] public class DiscordAuthController(Config config, DatabaseContext db) : ApiControllerBase { - [HttpPost("url")] - public async Task AuthenticationUrl() + private void CheckRequirements() { - throw new NotImplementedException(); + if (config.DiscordAuth.ClientId == null || config.DiscordAuth.ClientSecret == null) + { + throw new ApiError.BadRequest("Discord authentication is not enabled on this instance."); + } } } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs new file mode 100644 index 0000000..3ba92ba --- /dev/null +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -0,0 +1,31 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Services; +using Microsoft.AspNetCore.Mvc; +using NodaTime; + +namespace Foxnouns.Backend.Controllers.Authentication; + +[Route("/api/v2/auth/email")] +public class EmailAuthController(DatabaseContext db, AuthService authSvc, UserRendererService userRendererSvc, IClock clock, ILogger logger) : ApiControllerBase +{ + [HttpPost("login")] + public async Task LoginAsync([FromBody] LoginRequest req) + { + var user = await authSvc.AuthenticateUserAsync(req.Email, req.Password); + var frontendApp = await db.GetFrontendApplicationAsync(); + + var (tokenStr, token) = + authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + db.Add(token); + + await db.SaveChangesAsync(); + + return Ok(new AuthController.AuthResponse( + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + tokenStr, + token.ExpiresAt + )); + } + + public record LoginRequest(string Email, string Password); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/DebugController.cs b/Foxnouns.Backend/Controllers/DebugController.cs index 4746d95..3ef189b 100644 --- a/Foxnouns.Backend/Controllers/DebugController.cs +++ b/Foxnouns.Backend/Controllers/DebugController.cs @@ -10,7 +10,7 @@ public class DebugController(DatabaseContext db, AuthService authSvc, IClock clo { [HttpPost("users")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] - public async Task CreateUser([FromBody] CreateUserRequest req) + public async Task CreateUserAsync([FromBody] CreateUserRequest req) { logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email); diff --git a/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.Designer.cs new file mode 100644 index 0000000..af4f52a --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.Designer.cs @@ -0,0 +1,511 @@ +// +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("20240611225328_AddTemporaryKeyCache")] + partial class AddTemporaryKeyCache + { + /// + 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.TemporaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_temporary_keys"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_temporary_keys_key"); + + b.ToTable("temporary_keys", (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("ListHidden") + .HasColumnType("boolean") + .HasColumnName("list_hidden"); + + b.Property("MemberTitle") + .HasColumnType("text") + .HasColumnName("member_title"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + 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/20240611225328_AddTemporaryKeyCache.cs b/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.cs new file mode 100644 index 0000000..ccf736b --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddTemporaryKeyCache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "temporary_keys", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + key = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: false), + expires = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_temporary_keys", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_temporary_keys_key", + table: "temporary_keys", + column: "key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "temporary_keys"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index d0cb607..13c10dd 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -175,6 +175,39 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("members", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_temporary_keys"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_temporary_keys_key"); + + b.ToTable("temporary_keys", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.Property("Id") diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 84381c4..158eb10 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -66,7 +66,8 @@ public static class WebApplicationExtensions .AddSnowflakeGenerator() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services .AddScoped() diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 5025f70..b3c623b 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -2,9 +2,11 @@ using Foxnouns.Backend; using Foxnouns.Backend.Database; using Serilog; using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +using NodaTime; // Read version information from .version in the repository root await BuildInfo.ReadBuildInfo(); @@ -60,6 +62,23 @@ app.MapControllers(); app.Urls.Clear(); app.Urls.Add(config.Address); -app.Run(); +// Fire off the periodic tasks loop in the background +_ = new Timer(_ => +{ + var __ = RunPeriodicTasksAsync(); +}, null, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1)); -Log.CloseAndFlush(); \ No newline at end of file +app.Run(); +Log.CloseAndFlush(); + +return; + +async Task RunPeriodicTasksAsync() +{ + await using var scope = app.Services.CreateAsyncScope(); + var logger = scope.ServiceProvider.GetRequiredService(); + logger.Debug("Running periodic tasks"); + + var keyCacheSvc = scope.ServiceProvider.GetRequiredService(); + await keyCacheSvc.DeleteExpiredKeysAsync(); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 87494ed..0949a6f 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -3,6 +3,7 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using NodaTime; namespace Foxnouns.Backend.Services; @@ -30,6 +31,23 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator return user; } + public async Task AuthenticateUserAsync(string email, string password) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email)); + if (user == null) throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); + + var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password)); + if (pwResult == PasswordVerificationResult.Failed) + throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); + if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) + { + user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password)); + await db.SaveChangesAsync(); + } + + return user; + } + public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires) { if (!OauthUtils.ValidateScopes(application, scopes)) diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs new file mode 100644 index 0000000..253cc9f --- /dev/null +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -0,0 +1,51 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Utils; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace Foxnouns.Backend.Services; + +public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) +{ + public Task SetKeyAsync(string key, string value, Duration expireAfter) => + db.SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter); + + public async Task SetKeyAsync(string key, string value, Instant expires) + { + db.TemporaryKeys.Add(new TemporaryKey + { + Expires = expires, + Key = key, + Value = value, + }); + await db.SaveChangesAsync(); + } + + public async Task GetKeyAsync(string key, bool delete = false) + { + var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key); + if (value == null) return null; + + if (delete) + { + await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(); + await db.SaveChangesAsync(); + } + + return value.Value; + } + + public async Task DeleteExpiredKeysAsync() + { + var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(); + if (count != 0) logger.Information("Removed {Count} expired keys from the database", count); + } + + public async Task GenerateAuthStateAsync() + { + var state = OauthUtils.RandomToken(); + await SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); + return state; + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 0f22021..0ac7f90 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -7,7 +7,7 @@ namespace Foxnouns.Backend.Services; public class UserRendererService(DatabaseContext db, MemberRendererService memberRendererService) { - public async Task RenderUserAsync(User user, User? selfUser = null, bool renderMembers = true) + public async Task RenderUserAsync(User user, User? selfUser = null, bool renderMembers = true) { renderMembers = renderMembers && (!user.ListHidden || selfUser?.Id == user.Id); diff --git a/Foxnouns.Backend/config.ini b/Foxnouns.Backend/config.example.ini similarity index 88% rename from Foxnouns.Backend/config.ini rename to Foxnouns.Backend/config.example.ini index e0e13c4..d361d33 100644 --- a/Foxnouns.Backend/config.ini +++ b/Foxnouns.Backend/config.example.ini @@ -18,3 +18,7 @@ Url = "Host=localhost;Database=foxnouns_net;Username=pronouns;Password=pronouns" Timeout = 5 ; The maximum number of open connections. Defaults to 50. MaxPoolSize = 50 + +[DiscordAuth] +ClientId = +ClientSecret =