From c0290cc5af05dede0f84d2c830da8da8fa561d5e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 29 Dec 2025 11:20:23 -0500 Subject: [PATCH] [FA-55] User Service backend initial setup --- ...onArchive.Service.UserService.Tests.csproj | 30 ++ .../UserManagementServiceTests.cs | 326 ++++++++++++++++++ .../FictionArchive.Service.UserService.csproj | 5 + .../GraphQL/Mutation.cs | 61 ++-- .../GraphQL/Query.cs | 50 ++- ...1229151921_AddAvailableInvites.Designer.cs | 83 +++++ .../20251229151921_AddAvailableInvites.cs | 29 ++ .../UserServiceDbContextModelSnapshot.cs | 3 + .../Models/DTOs/UserDto.cs | 5 +- .../Models/Database/User.cs | 16 +- .../IntegrationEvents/AuthUserAddedEvent.cs | 16 - FictionArchive.Service.UserService/Program.cs | 32 +- .../Authentik/AuthentikAddUserRequest.cs | 21 ++ .../Authentik/AuthentikClient.cs | 78 +++++ .../Authentik/AuthentikConfiguration.cs | 7 + .../Authentik/AuthentikUserResponse.cs | 27 ++ .../IAuthenticationServiceClient.cs | 22 ++ .../AuthUserAddedEventHandler.cs | 23 -- .../Services/UserManagementService.cs | 110 +++++- .../Services/UserServiceDbContext.cs | 2 +- .../appsettings.json | 4 + FictionArchive.sln | 13 +- 22 files changed, 843 insertions(+), 120 deletions(-) create mode 100644 FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj create mode 100644 FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs create mode 100644 FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs create mode 100644 FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs delete mode 100644 FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs delete mode 100644 FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs diff --git a/FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj b/FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj new file mode 100644 index 0000000..f14c4ed --- /dev/null +++ b/FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs new file mode 100644 index 0000000..e9f8dfd --- /dev/null +++ b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs @@ -0,0 +1,326 @@ +using FictionArchive.Service.UserService.Models.Database; +using FictionArchive.Service.UserService.Services; +using FictionArchive.Service.UserService.Services.AuthenticationClient; +using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace FictionArchive.Service.UserService.Tests; + +public class UserManagementServiceTests +{ + #region Helper Methods + + private static UserServiceDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"UserManagementServiceTests-{Guid.NewGuid()}") + .Options; + + return new UserServiceDbContext(options, NullLogger.Instance); + } + + private static UserManagementService CreateService( + UserServiceDbContext dbContext, + IAuthenticationServiceClient authClient) + { + return new UserManagementService( + dbContext, + NullLogger.Instance, + authClient); + } + + private static User CreateTestUser(string username, string email, int availableInvites = 5) + { + return new User + { + Username = username, + Email = email, + OAuthProviderId = Guid.NewGuid().ToString(), + Disabled = false, + AvailableInvites = availableInvites + }; + } + + #endregion + + #region InviteUserAsync Tests + + [Fact] + public async Task InviteUserAsync_WithValidInviter_CreatesUserAndDecrementsInvites() + { + // Arrange + using var dbContext = CreateDbContext(); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3); + dbContext.Users.Add(inviter); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new AuthentikUserResponse { Pk = 123, Uid = "authentik-uid-456" }); + authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(true); + + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser"); + + // Assert + result.Should().NotBeNull(); + result!.Username.Should().Be("newuser"); + result.Email.Should().Be("new@test.com"); + result.InviterId.Should().Be(inviter.Id); + result.AvailableInvites.Should().Be(0); + inviter.AvailableInvites.Should().Be(2); + + await authClient.Received(1).CreateUserAsync("newuser", "new@test.com", "newuser"); + await authClient.Received(1).SendRecoveryEmailAsync(123); + } + + [Fact] + public async Task InviteUserAsync_WithNoAvailableInvites_ReturnsNull() + { + // Arrange + using var dbContext = CreateDbContext(); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 0); + dbContext.Users.Add(inviter); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser"); + + // Assert + result.Should().BeNull(); + await authClient.DidNotReceive().CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InviteUserAsync_WithDuplicateEmail_ReturnsNull() + { + // Arrange + using var dbContext = CreateDbContext(); + var existingUser = CreateTestUser("existing", "existing@test.com"); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3); + dbContext.Users.AddRange(existingUser, inviter); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "existing@test.com", "newuser"); + + // Assert + result.Should().BeNull(); + await authClient.DidNotReceive().CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + inviter.AvailableInvites.Should().Be(3); // Not decremented + } + + [Fact] + public async Task InviteUserAsync_WithDuplicateUsername_ReturnsNull() + { + // Arrange + using var dbContext = CreateDbContext(); + var existingUser = CreateTestUser("existinguser", "existing@test.com"); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3); + dbContext.Users.AddRange(existingUser, inviter); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "new@test.com", "existinguser"); + + // Assert + result.Should().BeNull(); + await authClient.DidNotReceive().CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + inviter.AvailableInvites.Should().Be(3); // Not decremented + } + + [Fact] + public async Task InviteUserAsync_WhenAuthentikFails_ReturnsNull() + { + // Arrange + using var dbContext = CreateDbContext(); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3); + dbContext.Users.Add(inviter); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((AuthentikUserResponse?)null); + + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser"); + + // Assert + result.Should().BeNull(); + await authClient.DidNotReceive().SendRecoveryEmailAsync(Arg.Any()); + + // Verify no user was added to the database + var usersInDb = await dbContext.Users.ToListAsync(); + usersInDb.Should().HaveCount(1); // Only the inviter + inviter.AvailableInvites.Should().Be(3); // Not decremented + } + + [Fact] + public async Task InviteUserAsync_WhenRecoveryEmailFails_StillCreatesUser() + { + // Arrange + using var dbContext = CreateDbContext(); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3); + dbContext.Users.Add(inviter); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new AuthentikUserResponse { Pk = 123, Uid = "authentik-uid-456" }); + authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(false); // Email fails + + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser"); + + // Assert - User should still be created despite email failure + result.Should().NotBeNull(); + result!.Username.Should().Be("newuser"); + inviter.AvailableInvites.Should().Be(2); + + // Verify user was added to database + var usersInDb = await dbContext.Users.ToListAsync(); + usersInDb.Should().HaveCount(2); + } + + [Fact] + public async Task InviteUserAsync_SetsCorrectUserProperties() + { + // Arrange + using var dbContext = CreateDbContext(); + var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 5); + dbContext.Users.Add(inviter); + await dbContext.SaveChangesAsync(); + + var authentikUid = "authentik-uid-789"; + var authClient = Substitute.For(); + authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new AuthentikUserResponse { Pk = 456, Uid = authentikUid }); + authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(true); + + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.InviteUserAsync(inviter, "newuser@test.com", "newusername"); + + // Assert + result.Should().NotBeNull(); + result!.Username.Should().Be("newusername"); + result.Email.Should().Be("newuser@test.com"); + result.OAuthProviderId.Should().Be(authentikUid); + result.InviterId.Should().Be(inviter.Id); + result.AvailableInvites.Should().Be(0); + result.Disabled.Should().BeFalse(); + result.Id.Should().NotBeEmpty(); + } + + #endregion + + #region GetUserByOAuthProviderIdAsync Tests + + [Fact] + public async Task GetUserByOAuthProviderIdAsync_WithExistingUser_ReturnsUser() + { + // Arrange + using var dbContext = CreateDbContext(); + var oAuthProviderId = "oauth-provider-123"; + var user = new User + { + Username = "testuser", + Email = "test@test.com", + OAuthProviderId = oAuthProviderId, + Disabled = false, + AvailableInvites = 5 + }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.GetUserByOAuthProviderIdAsync(oAuthProviderId); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(user.Id); + result.Username.Should().Be("testuser"); + result.OAuthProviderId.Should().Be(oAuthProviderId); + } + + [Fact] + public async Task GetUserByOAuthProviderIdAsync_WithNonExistingUser_ReturnsNull() + { + // Arrange + using var dbContext = CreateDbContext(); + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.GetUserByOAuthProviderIdAsync("non-existing-id"); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetUsers Tests + + [Fact] + public async Task GetUsers_ReturnsAllUsers() + { + // Arrange + using var dbContext = CreateDbContext(); + var user1 = CreateTestUser("user1", "user1@test.com"); + var user2 = CreateTestUser("user2", "user2@test.com"); + var user3 = CreateTestUser("user3", "user3@test.com"); + dbContext.Users.AddRange(user1, user2, user3); + await dbContext.SaveChangesAsync(); + + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.GetUsers().ToListAsync(); + + // Assert + result.Should().HaveCount(3); + result.Select(u => u.Username).Should().BeEquivalentTo(new[] { "user1", "user2", "user3" }); + } + + [Fact] + public async Task GetUsers_WithEmptyDb_ReturnsEmptyQueryable() + { + // Arrange + using var dbContext = CreateDbContext(); + var authClient = Substitute.For(); + var service = CreateService(dbContext, authClient); + + // Act + var result = await service.GetUsers().ToListAsync(); + + // Assert + result.Should().BeEmpty(); + } + + #endregion +} diff --git a/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj b/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj index 633eb80..ee73c28 100644 --- a/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj +++ b/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj @@ -24,4 +24,9 @@ + + + + + diff --git a/FictionArchive.Service.UserService/GraphQL/Mutation.cs b/FictionArchive.Service.UserService/GraphQL/Mutation.cs index 0d6528d..83afc5d 100644 --- a/FictionArchive.Service.UserService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.UserService/GraphQL/Mutation.cs @@ -1,38 +1,53 @@ -using FictionArchive.Service.Shared.Constants; +using System.Security.Claims; using FictionArchive.Service.UserService.Models.DTOs; using FictionArchive.Service.UserService.Services; using HotChocolate.Authorization; +using HotChocolate.Types; namespace FictionArchive.Service.UserService.GraphQL; public class Mutation { - [Authorize(Roles = [AuthorizationConstants.Roles.Admin])] - public async Task RegisterUser(string username, string email, string oAuthProviderId, - string? inviterOAuthProviderId, UserManagementService userManagementService) + [Authorize] + [Error] + public async Task InviteUser( + string email, + string username, + UserManagementService userManagementService, + ClaimsPrincipal claimsPrincipal) { - var user = await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId); + // Get the current user's OAuth provider ID from claims + var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + // Get the inviter from the database + var inviter = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId); + if (inviter == null) + { + throw new InvalidOperationException("Current user not found in the system"); + } + + // Invite the new user + var newUser = await userManagementService.InviteUserAsync(inviter, email, username); + if (newUser == null) + { + throw new InvalidOperationException( + "Failed to invite user. Either you have no available invites, or the email/username is already in use."); + } return new UserDto { - Id = user.Id, - CreatedTime = user.CreatedTime, - LastUpdatedTime = user.LastUpdatedTime, - Username = user.Username, - Email = user.Email, - Disabled = user.Disabled, - Inviter = user.Inviter != null - ? new UserDto - { - Id = user.Inviter.Id, - CreatedTime = user.Inviter.CreatedTime, - LastUpdatedTime = user.Inviter.LastUpdatedTime, - Username = user.Inviter.Username, - Email = user.Inviter.Email, - Disabled = user.Inviter.Disabled, - Inviter = null // Limit recursion to one level - } - : null + Id = newUser.Id, + CreatedTime = newUser.CreatedTime, + LastUpdatedTime = newUser.LastUpdatedTime, + Username = newUser.Username, + Email = newUser.Email, + Disabled = newUser.Disabled, + AvailableInvites = newUser.AvailableInvites, + InviterId = newUser.InviterId }; } } diff --git a/FictionArchive.Service.UserService/GraphQL/Query.cs b/FictionArchive.Service.UserService/GraphQL/Query.cs index eac54b3..bf7ba30 100644 --- a/FictionArchive.Service.UserService/GraphQL/Query.cs +++ b/FictionArchive.Service.UserService/GraphQL/Query.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using FictionArchive.Service.UserService.Models.DTOs; using FictionArchive.Service.UserService.Services; using HotChocolate.Authorization; @@ -7,9 +8,38 @@ namespace FictionArchive.Service.UserService.GraphQL; public class Query { [Authorize] - public IQueryable GetUsers(UserManagementService userManagementService) + public async Task GetAvailableInvites( + UserManagementService userManagementService, + ClaimsPrincipal claimsPrincipal) { - return userManagementService.GetUsers().Select(user => new UserDto + var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + return 0; + } + + var user = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId); + return user?.AvailableInvites ?? 0; + } + + [Authorize] + public async Task GetCurrentUser( + UserManagementService userManagementService, + ClaimsPrincipal claimsPrincipal) + { + var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + return null; + } + + var user = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId); + if (user == null) + { + return null; + } + + return new UserDto { Id = user.Id, CreatedTime = user.CreatedTime, @@ -17,18 +47,8 @@ public class Query Username = user.Username, Email = user.Email, Disabled = user.Disabled, - Inviter = user.Inviter != null - ? new UserDto - { - Id = user.Inviter.Id, - CreatedTime = user.Inviter.CreatedTime, - LastUpdatedTime = user.Inviter.LastUpdatedTime, - Username = user.Inviter.Username, - Email = user.Inviter.Email, - Disabled = user.Inviter.Disabled, - Inviter = null // Limit recursion to one level - } - : null - }); + AvailableInvites = user.AvailableInvites, + InviterId = user.InviterId + }; } } diff --git a/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs new file mode 100644 index 0000000..7933d4f --- /dev/null +++ b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs @@ -0,0 +1,83 @@ +// +using System; +using FictionArchive.Service.UserService.Services; +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 FictionArchive.Service.UserService.Migrations +{ + [DbContext(typeof(UserServiceDbContext))] + [Migration("20251229151921_AddAvailableInvites")] + partial class AddAvailableInvites + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FictionArchive.Service.UserService.Models.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvailableInvites") + .HasColumnType("integer"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("InviterId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OAuthProviderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InviterId"); + + b.HasIndex("OAuthProviderId") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserService.Models.Database.User", b => + { + b.HasOne("FictionArchive.Service.UserService.Models.Database.User", "Inviter") + .WithMany() + .HasForeignKey("InviterId"); + + b.Navigation("Inviter"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs new file mode 100644 index 0000000..c8331e8 --- /dev/null +++ b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FictionArchive.Service.UserService.Migrations +{ + /// + public partial class AddAvailableInvites : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvailableInvites", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AvailableInvites", + table: "Users"); + } + } +} diff --git a/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs b/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs index ffc0bd4..6b76e15 100644 --- a/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs @@ -29,6 +29,9 @@ namespace FictionArchive.Service.UserService.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AvailableInvites") + .HasColumnType("integer"); + b.Property("CreatedTime") .HasColumnType("timestamp with time zone"); diff --git a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs index 787b27e..d5948ca 100644 --- a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs +++ b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs @@ -7,9 +7,10 @@ public class UserDto public Guid Id { get; init; } public Instant CreatedTime { get; init; } public Instant LastUpdatedTime { get; init; } + public required string Username { get; init; } public required string Email { get; init; } - // OAuthProviderId intentionally omitted for security public bool Disabled { get; init; } - public UserDto? Inviter { get; init; } + public int AvailableInvites { get; init; } + public Guid? InviterId { get; init; } } diff --git a/FictionArchive.Service.UserService/Models/Database/User.cs b/FictionArchive.Service.UserService/Models/Database/User.cs index aa7b4e3..02a100c 100644 --- a/FictionArchive.Service.UserService/Models/Database/User.cs +++ b/FictionArchive.Service.UserService/Models/Database/User.cs @@ -6,15 +6,13 @@ namespace FictionArchive.Service.UserService.Models.Database; [Index(nameof(OAuthProviderId), IsUnique = true)] public class User : BaseEntity { - public string Username { get; set; } - public string Email { get; set; } - public string OAuthProviderId { get; set; } - - + public required string Username { get; set; } + public required string Email { get; set; } + public required string OAuthProviderId { get; set; } public bool Disabled { get; set; } - - /// - /// The user that generated an invite used by this user. - /// + public int AvailableInvites { get; set; } = 0; + + // Navigation properties + public Guid? InviterId { get; set; } public User? Inviter { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs b/FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs deleted file mode 100644 index 13a832c..0000000 --- a/FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; - -namespace FictionArchive.Service.UserService.Models.IntegrationEvents; - -public class AuthUserAddedEvent : IIntegrationEvent -{ - public string OAuthProviderId { get; set; } - - public string InviterOAuthProviderId { get; set; } - - // The email of the user that created the event - public string EventUserEmail { get; set; } - - // The username of the user that created the event - public string EventUserUsername { get; set; } -} \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Program.cs b/FictionArchive.Service.UserService/Program.cs index 1398ce9..419c199 100644 --- a/FictionArchive.Service.UserService/Program.cs +++ b/FictionArchive.Service.UserService/Program.cs @@ -1,11 +1,12 @@ +using System.Net.Http.Headers; using FictionArchive.Common.Extensions; using FictionArchive.Service.Shared; using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.UserService.GraphQL; -using FictionArchive.Service.UserService.Models.IntegrationEvents; using FictionArchive.Service.UserService.Services; -using FictionArchive.Service.UserService.Services.EventHandlers; +using FictionArchive.Service.UserService.Services.AuthenticationClient; +using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; namespace FictionArchive.Service.UserService; @@ -25,19 +26,34 @@ public class Program builder.Services.AddRabbitMQ(opt => { builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }) - .Subscribe(); + }); } #endregion - + #region GraphQL - + builder.Services.AddDefaultGraphQl() .AddAuthorization(); - + #endregion - + + #region Authentik Client + + builder.Services.Configure( + builder.Configuration.GetSection("Authentik")); + + var authentikConfig = builder.Configuration.GetSection("Authentik").Get(); + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri(authentikConfig?.BaseUrl ?? "https://localhost"); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", authentikConfig?.ApiToken ?? ""); + }) + .AddStandardResilienceHandler(); + + #endregion + builder.Services.RegisterDbContext( builder.Configuration.GetConnectionString("DefaultConnection"), skipInfrastructure: isSchemaExport); diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs new file mode 100644 index 0000000..5e885e4 --- /dev/null +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; + +public class AuthentikAddUserRequest +{ + [JsonPropertyName("username")] + public required string Username { get; set; } + + [JsonPropertyName("name")] + public required string DisplayName { get; set; } + + [JsonPropertyName("email")] + public required string Email { get; set; } + + [JsonPropertyName("is_active")] + public bool IsActive { get; set; } = true; + + [JsonPropertyName("type")] + public string Type { get; } = "external"; +} \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs new file mode 100644 index 0000000..4f3400b --- /dev/null +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Json; + +namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; + +public class AuthentikClient : IAuthenticationServiceClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public AuthentikClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task CreateUserAsync(string username, string email, string displayName) + { + var request = new AuthentikAddUserRequest + { + Username = username, + Email = email, + DisplayName = displayName, + IsActive = true + }; + + try + { + var response = await _httpClient.PostAsJsonAsync("/api/v3/core/users/", request); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError( + "Failed to create user in Authentik. Status: {StatusCode}, Error: {Error}", + response.StatusCode, errorContent); + return null; + } + + var userResponse = await response.Content.ReadFromJsonAsync(); + _logger.LogInformation("Successfully created user {Username} in Authentik with pk {Pk}", + username, userResponse?.Pk); + + return userResponse; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception while creating user {Username} in Authentik", username); + return null; + } + } + + public async Task SendRecoveryEmailAsync(int authentikUserId) + { + try + { + var response = await _httpClient.PostAsync( + $"/api/v3/core/users/{authentikUserId}/recovery_email/", + null); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError( + "Failed to send recovery email for user {UserId}. Status: {StatusCode}, Error: {Error}", + authentikUserId, response.StatusCode, errorContent); + return false; + } + + _logger.LogInformation("Successfully sent recovery email to Authentik user {UserId}", authentikUserId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception while sending recovery email to Authentik user {UserId}", authentikUserId); + return false; + } + } +} \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs new file mode 100644 index 0000000..00d5afc --- /dev/null +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; + +public class AuthentikConfiguration +{ + public string BaseUrl { get; set; } = string.Empty; + public string ApiToken { get; set; } = string.Empty; +} diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs new file mode 100644 index 0000000..fb945be --- /dev/null +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; + +public class AuthentikUserResponse +{ + [JsonPropertyName("pk")] + public int Pk { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("is_active")] + public bool IsActive { get; set; } + + [JsonPropertyName("is_superuser")] + public bool IsSuperuser { get; set; } + + [JsonPropertyName("uid")] + public string Uid { get; set; } = string.Empty; +} diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs new file mode 100644 index 0000000..4e5b9a5 --- /dev/null +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs @@ -0,0 +1,22 @@ +using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; + +namespace FictionArchive.Service.UserService.Services.AuthenticationClient; + +public interface IAuthenticationServiceClient +{ + /// + /// Creates a new user in the authentication provider. + /// + /// The username for the new user + /// The email address for the new user + /// The display name for the new user + /// The created user response, or null if creation failed + Task CreateUserAsync(string username, string email, string displayName); + + /// + /// Sends a password recovery email to the user. + /// + /// The Authentik user ID (pk) + /// True if the email was sent successfully, false otherwise + Task SendRecoveryEmailAsync(int authentikUserId); +} \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs b/FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs deleted file mode 100644 index 120380b..0000000 --- a/FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FictionArchive.Service.Shared.Services.EventBus; -using FictionArchive.Service.UserService.Models.IntegrationEvents; -using FictionArchive.Service.UserService.Models.Database; -using Microsoft.EntityFrameworkCore; // Add this line to include the UserModel - -namespace FictionArchive.Service.UserService.Services.EventHandlers; - -public class AuthUserAddedEventHandler : IIntegrationEventHandler -{ - private readonly UserManagementService _userManagementService; - private readonly ILogger _logger; - - public AuthUserAddedEventHandler(UserServiceDbContext dbContext, ILogger logger, UserManagementService userManagementService) - { - _logger = logger; - _userManagementService = userManagementService; - } - - public async Task Handle(AuthUserAddedEvent @event) - { - await _userManagementService.RegisterUser(@event.EventUserUsername, @event.EventUserEmail, @event.OAuthProviderId, @event.InviterOAuthProviderId); - } -} \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Services/UserManagementService.cs b/FictionArchive.Service.UserService/Services/UserManagementService.cs index b1de631..cdddcb7 100644 --- a/FictionArchive.Service.UserService/Services/UserManagementService.cs +++ b/FictionArchive.Service.UserService/Services/UserManagementService.cs @@ -1,4 +1,5 @@ using FictionArchive.Service.UserService.Models.Database; +using FictionArchive.Service.UserService.Services.AuthenticationClient; using Microsoft.EntityFrameworkCore; namespace FictionArchive.Service.UserService.Services; @@ -7,37 +8,112 @@ public class UserManagementService { private readonly ILogger _logger; private readonly UserServiceDbContext _dbContext; + private readonly IAuthenticationServiceClient _authClient; - public UserManagementService(UserServiceDbContext dbContext, ILogger logger) + public UserManagementService( + UserServiceDbContext dbContext, + ILogger logger, + IAuthenticationServiceClient authClient) { _dbContext = dbContext; _logger = logger; + _authClient = authClient; } - public async Task RegisterUser(string username, string email, string oAuthProviderId, - string? inviterOAuthProviderId) + /// + /// Invites a new user by creating them in Authentik, saving to the database, and sending a recovery email. + /// + /// The user sending the invite + /// Email address of the invitee + /// Username for the invitee + /// The created user, or null if the invite failed + public async Task InviteUserAsync(User inviter, string email, string username) { - var newUser = new User(); - User? inviter = - await _dbContext.Users.FirstOrDefaultAsync(user => user.OAuthProviderId == inviterOAuthProviderId); - if (inviter == null && inviterOAuthProviderId != null) + // Check if inviter has available invites + if (inviter.AvailableInvites <= 0) { - _logger.LogCritical( - "A user with OAuthProviderId {OAuthProviderId} was marked as having inviter with OAuthProviderId {inviterOAuthProviderId}, but no user was found with that value.", - inviterOAuthProviderId, inviterOAuthProviderId); - newUser.Disabled = true; + _logger.LogWarning("User {InviterId} has no available invites", inviter.Id); + return null; } - newUser.Username = username; - newUser.Email = email; - newUser.OAuthProviderId = oAuthProviderId; + // Check if email is already in use + var existingUser = await _dbContext.Users + .AsQueryable() + .FirstOrDefaultAsync(u => u.Email == email); + + if (existingUser != null) + { + _logger.LogWarning("Email {Email} is already in use", email); + return null; + } + + // Check if username is already in use + var existingUsername = await _dbContext.Users + .AsQueryable() + .FirstOrDefaultAsync(u => u.Username == username); + + if (existingUsername != null) + { + _logger.LogWarning("Username {Username} is already in use", username); + return null; + } + + // Create user in Authentik + var authentikUser = await _authClient.CreateUserAsync(username, email, username); + if (authentikUser == null) + { + _logger.LogError("Failed to create user {Username} in Authentik", username); + return null; + } + + // Send recovery email via Authentik + var emailSent = await _authClient.SendRecoveryEmailAsync(authentikUser.Pk); + if (!emailSent) + { + _logger.LogWarning( + "User {Username} was created in Authentik but recovery email failed to send. Authentik pk: {Pk}", + username, authentikUser.Pk); + // Continue anyway - the user is created, admin can resend email manually + } + + // Create user in local database + var newUser = new User + { + Username = username, + Email = email, + OAuthProviderId = authentikUser.Uid, + Disabled = false, + AvailableInvites = 0, + InviterId = inviter.Id + }; + + _dbContext.Users.Add(newUser); + + // Decrement inviter's available invites + inviter.AvailableInvites--; + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation( + "User {Username} was successfully invited by {InviterId}. New user id: {NewUserId}", + username, inviter.Id, newUser.Id); - _dbContext.Users.Add(newUser); // Add the new user to the DbContext - await _dbContext.SaveChangesAsync(); // Save changes to the database - return newUser; } + /// + /// Gets a user by their OAuth provider ID (Authentik UID). + /// + public async Task GetUserByOAuthProviderIdAsync(string oAuthProviderId) + { + return await _dbContext.Users + .AsQueryable() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + } + + /// + /// Gets all users as a queryable for GraphQL. + /// public IQueryable GetUsers() { return _dbContext.Users.AsQueryable(); diff --git a/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs b/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs index d743f68..a053a3e 100644 --- a/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs +++ b/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs @@ -7,7 +7,7 @@ namespace FictionArchive.Service.UserService.Services; public class UserServiceDbContext : FictionArchiveDbContext { public DbSet Users { get; set; } - + public UserServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger) { } diff --git a/FictionArchive.Service.UserService/appsettings.json b/FictionArchive.Service.UserService/appsettings.json index 11f3af1..3eeb9d8 100644 --- a/FictionArchive.Service.UserService/appsettings.json +++ b/FictionArchive.Service.UserService/appsettings.json @@ -12,6 +12,10 @@ "ConnectionString": "amqp://localhost", "ClientIdentifier": "UserService" }, + "Authentik": { + "BaseUrl": "https://auth.orfl.xyz", + "ApiToken": "REPLACE_ME" + }, "AllowedHosts": "*", "OIDC": { "Authority": "https://auth.orfl.xyz/application/o/fiction-archive/", diff --git a/FictionArchive.sln b/FictionArchive.sln index 81af8d3..6a0e6ef 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -1,5 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Common", "FictionArchive.Common\FictionArchive.Common.csproj", "{ABF1BA10-9E76-45BE-9947-E20445A68147}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.API", "FictionArchive.API\FictionArchive.API.csproj", "{420CC1A1-9DBC-40EC-B9E3-D4B25D71B9A9}" @@ -14,12 +15,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Sche EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService", "FictionArchive.Service.UserService\FictionArchive.Service.UserService.csproj", "{EE4D4795-2F79-4614-886D-AF8DA77120AC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.AuthenticationService", "FictionArchive.Service.AuthenticationService\FictionArchive.Service.AuthenticationService.csproj", "{70C4AE82-B01E-421D-B590-C0F47E63CD0C}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.FileService", "FictionArchive.Service.FileService\FictionArchive.Service.FileService.csproj", "{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.NovelService.Tests", "FictionArchive.Service.NovelService.Tests\FictionArchive.Service.NovelService.Tests.csproj", "{166E645E-9DFB-44E8-8CC8-FA249A11679F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService.Tests", "FictionArchive.Service.UserService.Tests\FictionArchive.Service.UserService.Tests.csproj", "{10C38C89-983D-4544-8911-F03099F66AB8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,10 +55,6 @@ Global {EE4D4795-2F79-4614-886D-AF8DA77120AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.Build.0 = Release|Any CPU - {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Release|Any CPU.Build.0 = Release|Any CPU {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.Build.0 = Debug|Any CPU {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -66,5 +63,9 @@ Global {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Debug|Any CPU.Build.0 = Debug|Any CPU {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.ActiveCfg = Release|Any CPU {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.Build.0 = Release|Any CPU + {10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal