From fbc6f562e362707061266f7ee322ff334594c7b6 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 18 May 2025 19:11:43 +0200 Subject: [PATCH] add create signup code --- Femto.Api/Controllers/Auth/AuthController.cs | 2 +- .../20250518163039_InitSignupCode.sql | 14 +++++++ .../CreateSignupCodeCommand.cs | 5 +++ .../CreateSignupCodeCommandHandler.cs | 15 +++++++ .../Commands/Register/RegisterCommand.cs | 2 +- .../Register/RegisterCommandHandler.cs | 13 ++++++ Femto.Modules.Auth/Data/AuthContext.cs | 1 + .../Configurations/SignupCodeConfiguration.cs | 14 +++++++ Femto.Modules.Auth/Models/SignupCode.cs | 41 +++++++++++++++++++ 9 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 Femto.Database/Migrations/20250518163039_InitSignupCode.sql create mode 100644 Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommand.cs create mode 100644 Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommandHandler.cs create mode 100644 Femto.Modules.Auth/Data/Configurations/SignupCodeConfiguration.cs create mode 100644 Femto.Modules.Auth/Models/SignupCode.cs diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 4228628..2021692 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -28,7 +28,7 @@ public class AuthController(IAuthModule authModule, IOptions coo public async Task> Register([FromBody] RegisterRequest request) { var result = await authModule.PostCommand( - new RegisterCommand(request.Username, request.Password) + new RegisterCommand(request.Username, request.Password, request.SignupCode) ); HttpContext.SetSession(result.Session, cookieSettings.Value); diff --git a/Femto.Database/Migrations/20250518163039_InitSignupCode.sql b/Femto.Database/Migrations/20250518163039_InitSignupCode.sql new file mode 100644 index 0000000..4b5304b --- /dev/null +++ b/Femto.Database/Migrations/20250518163039_InitSignupCode.sql @@ -0,0 +1,14 @@ +-- Migration: InitSignupCode +-- Created at: 18/05/2025 16:30:39 + +CREATE TABLE authn.signup_code +( + code varchar(32) PRIMARY KEY, + recipient_email TEXT NOT NULL, + recipient_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + -- we don't make this a foreign key as we don't really need it for lookups, + -- and should the redeeming user delete be deleted, it's better that we keep the ID here + redeeming_user_id UUID +); diff --git a/Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommand.cs b/Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommand.cs new file mode 100644 index 0000000..5b40c6a --- /dev/null +++ b/Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommand.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Application.Commands.CreateSignupCode; + +public record CreateSignupCodeCommand(string Code, string RecipientEmail, string RecipientName): ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommandHandler.cs b/Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommandHandler.cs new file mode 100644 index 0000000..ca08bdc --- /dev/null +++ b/Femto.Modules.Auth/Application/Commands/CreateSignupCode/CreateSignupCodeCommandHandler.cs @@ -0,0 +1,15 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Models; + +namespace Femto.Modules.Auth.Application.Commands.CreateSignupCode; + +internal class CreateSignupCodeCommandHandler(AuthContext context) : ICommandHandler +{ + public async Task Handle(CreateSignupCodeCommand command, CancellationToken cancellationToken) + { + var code = new SignupCode(command.RecipientEmail, command.RecipientName, command.Code); + + await context.SignupCodes.AddAsync(code, cancellationToken); + } +} diff --git a/Femto.Modules.Auth/Application/Commands/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Commands/Register/RegisterCommand.cs index e3ecf5f..ac76f00 100644 --- a/Femto.Modules.Auth/Application/Commands/Register/RegisterCommand.cs +++ b/Femto.Modules.Auth/Application/Commands/Register/RegisterCommand.cs @@ -3,4 +3,4 @@ using Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Commands.Register; -public record RegisterCommand(string Username, string Password) : ICommand; \ No newline at end of file +public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs index dad1330..2a7d88d 100644 --- a/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs @@ -2,6 +2,7 @@ using Femto.Common.Domain; using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Models; +using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Application.Commands.Register; @@ -9,6 +10,16 @@ internal class RegisterCommandHandler(AuthContext context) : ICommandHandler Handle(RegisterCommand request, CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; + var code = await context.SignupCodes + .Where(c => c.Code == request.SignupCode) + .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) + .Where(c => c.RedeemingUserId == null) + .SingleOrDefaultAsync(cancellationToken); + + if (code is null) + throw new DomainError("invalid signup code"); + var user = new UserIdentity(request.Username); user.SetPassword(request.Password); @@ -17,6 +28,8 @@ internal class RegisterCommandHandler(AuthContext context) : ICommandHandler options) : DbContext(options), IOutboxContext { public virtual DbSet Users { get; set; } + public virtual DbSet SignupCodes { get; set; } public virtual DbSet Outbox { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/Femto.Modules.Auth/Data/Configurations/SignupCodeConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/SignupCodeConfiguration.cs new file mode 100644 index 0000000..036a198 --- /dev/null +++ b/Femto.Modules.Auth/Data/Configurations/SignupCodeConfiguration.cs @@ -0,0 +1,14 @@ +using Femto.Modules.Auth.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Femto.Modules.Auth.Data.Configurations; + +internal class SignupCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("signup_code"); + builder.HasKey(t => t.Code); + } +} diff --git a/Femto.Modules.Auth/Models/SignupCode.cs b/Femto.Modules.Auth/Models/SignupCode.cs new file mode 100644 index 0000000..02cb98f --- /dev/null +++ b/Femto.Modules.Auth/Models/SignupCode.cs @@ -0,0 +1,41 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Models; + +public class SignupCode +{ + private static readonly TimeSpan ExpiryTime = TimeSpan.FromDays(14); + public string Code { get; private set; } + + /// + /// The email of the intended recipient + /// + public string RecipientEmail { get; private set; } + + /// + /// The name of the intended recipient + /// + public string RecipientName { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? ExpiresAt { get; private set; } + public Guid? RedeemingUserId { get; private set; } + + private SignupCode() { } + + public SignupCode(string recipientEmail, string recipientName, string code) + { + this.Code = code; + this.RecipientEmail = recipientEmail; + this.RecipientName = recipientName; + this.CreatedAt = DateTimeOffset.UtcNow; + this.ExpiresAt = this.CreatedAt + ExpiryTime; + } + + public void Redeem(Guid userGuid) + { + if (this.RedeemingUserId is not null) + throw new DomainError("invalid signup code"); + + this.RedeemingUserId = userGuid; + } +}