diff --git a/Directory.Build.props b/Directory.Build.props
index 60ef2bc..0751e1d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 0.1.27
+ 0.1.31
diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs
index f98ae8d..9c44b04 100644
--- a/Femto.Api/Auth/SessionAuthenticationHandler.cs
+++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs
@@ -3,7 +3,9 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
-using Femto.Modules.Auth.Application.Services;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Contracts;
+using Femto.Modules.Auth.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
@@ -20,42 +22,15 @@ internal class SessionAuthenticationHandler(
protected override async Task HandleAuthenticateAsync()
{
Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
-
- var sessionId = this.Context.GetSessionId();
-
- if (sessionId is null)
- {
- Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
- return AuthenticateResult.NoResult();
- }
-
- var session = await authService.GetSession(sessionId);
- if (session is null)
- {
- Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
- return await FailAndDeleteSession(sessionId);
- }
+ var user = await this.TryAuthenticateWithSession();
- if (session.IsExpired)
- {
- Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier);
- return await FailAndDeleteSession(sessionId);
- }
-
- var user = await authService.GetUserWithId(session.UserId);
-
if (user is null)
- {
- return await FailAndDeleteSession(sessionId);
- }
-
- if (session.ExpiresSoon)
- {
- session = await authService.CreateWeakSession(session.UserId);
- this.Context.SetSession(session, user);
- }
-
+ user = await this.TryAuthenticateWithRememberMeToken();
+
+ if (user is null)
+ return AuthenticateResult.NoResult();
+
var claims = new List
{
new(ClaimTypes.Name, user.Username),
@@ -67,15 +42,88 @@ internal class SessionAuthenticationHandler(
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
- currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username);
+ currentUserContext.CurrentUser = new CurrentUser(
+ user.Id,
+ user.Username,
+ user.Roles.Contains(Role.SuperUser)
+ );
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
}
- private async Task FailAndDeleteSession(string sessionId)
+ private async Task TryAuthenticateWithSession()
{
- await authService.DeleteSession(sessionId);
- this.Context.DeleteSession();
- return AuthenticateResult.Fail("invalid session");
+ var sessionId = this.Context.GetSessionId();
+
+ if (sessionId is null)
+ {
+ Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
+ return null;
+ }
+
+ var session = await authService.GetSession(sessionId);
+
+ if (session is null)
+ {
+ Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
+ return null;
+ }
+
+ if (session.IsExpired)
+ {
+ Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier);
+ await authService.DeleteSession(sessionId);
+ this.Context.DeleteSession();
+ return null;
+ }
+
+ var user = await authService.GetUserWithId(session.UserId);
+
+ if (user is null)
+ {
+ await authService.DeleteSession(sessionId);
+ this.Context.DeleteSession();
+ return null;
+ }
+
+ if (session.ExpiresSoon)
+ {
+ session = await authService.CreateWeakSession(session.UserId);
+ this.Context.SetSession(session, user);
+ }
+
+ return user;
+ }
+
+ private async Task TryAuthenticateWithRememberMeToken()
+ {
+ /*
+ * load remember me from token
+ * if it is null, return null
+ * if it exists, validate it
+ * if it is valid, create a new weak session, return the user
+ * if it is almost expired, refresh it
+ */
+
+ var rememberMeToken = this.Context.GetRememberMeToken();
+
+ if (rememberMeToken is null)
+ return null;
+
+ var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(
+ rememberMeToken
+ );
+
+ if (user is null)
+ return null;
+
+ var session = await authService.CreateWeakSession(user.Id);
+
+ this.Context.SetSession(session, user);
+
+ if (newRememberMeToken is not null)
+ this.Context.SetRememberMeToken(newRememberMeToken);
+
+ return user;
}
}
diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs
index e45e73c..c9108da 100644
--- a/Femto.Api/Controllers/Auth/AuthController.cs
+++ b/Femto.Api/Controllers/Auth/AuthController.cs
@@ -1,26 +1,16 @@
-using Femto.Api.Auth;
using Femto.Api.Sessions;
using Femto.Common;
-using Femto.Modules.Auth.Application.Interface.CreateSignupCode;
-using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
-using Femto.Modules.Auth.Application.Interface.Register;
-using Femto.Modules.Auth.Application.Services;
+using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Options;
namespace Femto.Api.Controllers.Auth;
[ApiController]
[Route("auth")]
-public class AuthController(
- IAuthModule authModule,
- IOptions cookieSettings,
- ICurrentUserContext currentUserContext,
- ILogger logger,
- IAuthService authService
-) : ControllerBase
+public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService)
+ : ControllerBase
{
[HttpPost("login")]
public async Task> Login(
@@ -28,32 +18,49 @@ public class AuthController(
CancellationToken cancellationToken
)
{
- var user = await authService.GetUserWithCredentials(
+ var result = await authService.AuthenticateUserCredentials(
request.Username,
request.Password,
cancellationToken
);
-
- if (user is null)
+
+ if (result is null)
return this.BadRequest();
-
- var session = await authService.CreateStrongSession(user.Id);
+
+ var (user, session) = result;
HttpContext.SetSession(session, user);
+ if (request.RememberMe)
+ {
+ var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
+ HttpContext.SetRememberMeToken(newRememberMeToken);
+ }
+
return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser));
}
[HttpPost("register")]
- public async Task> Register([FromBody] RegisterRequest request)
+ public async Task> Register(
+ [FromBody] RegisterRequest request,
+ CancellationToken cancellationToken
+ )
{
- var user = await authModule.Command(
- new RegisterCommand(request.Username, request.Password, request.SignupCode)
+ var (user, session) = await authService.CreateUserWithCredentials(
+ request.Username,
+ request.Password,
+ request.SignupCode,
+ cancellationToken
);
- var session = await authService.CreateStrongSession(user.Id);
-
HttpContext.SetSession(session, user);
+
+ if (request.RememberMe)
+ {
+ var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
+ HttpContext.SetRememberMeToken(newRememberMeToken);
+ }
+
return new RegisterResponse(
user.Id,
user.Username,
@@ -61,6 +68,60 @@ public class AuthController(
);
}
+ [HttpPost("change-password")]
+ public async Task ChangePassword(
+ [FromBody] ChangePasswordRequestBody req,
+ CancellationToken cancellationToken
+ )
+ {
+ if (currentUserContext.CurrentUser is not {} user)
+ return this.BadRequest();
+
+ // superuser do what superuser want
+ if (!user.IsSuperUser)
+ {
+ if (user.Id != req.UserId)
+ return this.BadRequest();
+
+ var session = await authService.GetSession(this.HttpContext.GetSessionId()!);
+
+ // require strong authentication to change password
+ // the user can re-enter their password
+ if (session is null || !session.IsStronglyAuthenticated)
+ return this.BadRequest();
+ }
+
+ await authService.ChangePassword(req.UserId, req.NewPassword, cancellationToken);
+
+ // TODO would be better do handle this from inside the auth service. maybe just have it happen in a post-save event handler?
+ await authService.InvalidateUserSessions(req.UserId, cancellationToken);
+
+ return this.Ok(new {});
+ }
+
+ [HttpPost("delete-current-session")]
+ public async Task DeleteSessionV2()
+ {
+ var sessionId = HttpContext.GetSessionId();
+
+ if (sessionId is not null)
+ {
+ await authService.DeleteSession(sessionId);
+ HttpContext.DeleteSession();
+ }
+
+ var rememberMeToken = HttpContext.GetRememberMeToken();
+
+ if (rememberMeToken is not null)
+ {
+ await authService.DeleteRememberMeToken(rememberMeToken);
+ HttpContext.DeleteRememberMeToken();
+ }
+
+ return Ok(new { });
+ }
+
+ [Obsolete("use POST /auth/delete-current-session")]
[HttpDelete("session")]
public async Task DeleteSession()
{
@@ -72,6 +133,14 @@ public class AuthController(
HttpContext.DeleteSession();
}
+ var rememberMeToken = HttpContext.GetRememberMeToken();
+
+ if (rememberMeToken is not null)
+ {
+ await authService.DeleteRememberMeToken(rememberMeToken);
+ HttpContext.DeleteRememberMeToken();
+ }
+
return Ok(new { });
}
@@ -99,6 +168,7 @@ public class AuthController(
);
}
+ [Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task CreateSignupCode(
@@ -106,21 +176,51 @@ public class AuthController(
CancellationToken cancellationToken
)
{
- await authModule.Command(
- new CreateSignupCodeCommand(request.Code, request.Email, request.Name),
- cancellationToken
- );
+ await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
return Ok(new { });
}
+ [Obsolete("use GET /auth/list-signup-codes")]
[HttpGet("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task> ListSignupCodes(
CancellationToken cancellationToken
)
{
- var codes = await authModule.Query(new GetSignupCodesQuery(), cancellationToken);
+ var codes = await authService.GetSignupCodes(cancellationToken);
+
+ return new ListSignupCodesResult(
+ codes.Select(c => new SignupCodeDto(
+ c.Code,
+ c.Email,
+ c.Name,
+ c.RedeemedByUserId,
+ c.RedeemedByUsername,
+ c.ExpiresOn
+ ))
+ );
+ }
+
+ [HttpPost("create-signup-code")]
+ [Authorize(Roles = "SuperUser")]
+ public async Task CreateSignupCodeV2(
+ [FromBody] CreateSignupCodeRequest request,
+ CancellationToken cancellationToken
+ )
+ {
+ await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
+
+ return Ok(new { });
+ }
+
+ [HttpGet("list-signup-codes")]
+ [Authorize(Roles = "SuperUser")]
+ public async Task> ListSignupCodesV2(
+ CancellationToken cancellationToken
+ )
+ {
+ var codes = await authService.GetSignupCodes(cancellationToken);
return new ListSignupCodesResult(
codes.Select(c => new SignupCodeDto(
diff --git a/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
new file mode 100644
index 0000000..77f1dcd
--- /dev/null
+++ b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Auth;
+
+public record ChangePasswordRequestBody(Guid UserId, string NewPassword);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Auth/LoginRequest.cs b/Femto.Api/Controllers/Auth/LoginRequest.cs
index 8366d14..6c09e64 100644
--- a/Femto.Api/Controllers/Auth/LoginRequest.cs
+++ b/Femto.Api/Controllers/Auth/LoginRequest.cs
@@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth;
-public record LoginRequest(string Username, string Password);
\ No newline at end of file
+public record LoginRequest(string Username, string Password, bool RememberMe);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Auth/RegisterRequest.cs b/Femto.Api/Controllers/Auth/RegisterRequest.cs
index f386198..ee21297 100644
--- a/Femto.Api/Controllers/Auth/RegisterRequest.cs
+++ b/Femto.Api/Controllers/Auth/RegisterRequest.cs
@@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth;
-public record RegisterRequest(string Username, string Password, string SignupCode, string? Email);
\ No newline at end of file
+public record RegisterRequest(string Username, string Password, string SignupCode, bool RememberMe);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs
new file mode 100644
index 0000000..7546af0
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record AddPostCommentRequest(Guid AuthorId, string Content);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
new file mode 100644
index 0000000..b44740c
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record GetPostResponse(PostDto Post);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs
index e5155f6..3705456 100644
--- a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs
+++ b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs
@@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
-public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);
\ No newline at end of file
+public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs
index 7efdeee..54b9df7 100644
--- a/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs
+++ b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs
@@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
-public record LoadPostsResponse(IEnumerable Posts, Guid? Next);
\ No newline at end of file
+public record LoadPostsResponse(IEnumerable Posts);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs
new file mode 100644
index 0000000..04e180a
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs
index a00c8c1..c9af7c6 100644
--- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs
+++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs
@@ -10,5 +10,19 @@ public record PostDto(
IEnumerable Media,
IEnumerable Reactions,
DateTimeOffset CreatedAt,
- IEnumerable PossibleReactions
-);
\ No newline at end of file
+ IEnumerable PossibleReactions,
+ IEnumerable Comments
+)
+{
+ public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) =>
+ new(
+ new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
+ post.PostId,
+ post.Text,
+ post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
+ post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)),
+ post.CreatedAt,
+ post.PossibleReactions,
+ post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn))
+ );
+}
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs
index 81e3a95..f9934c6 100644
--- a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs
+++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs
@@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
-public record PostReactionDto(string Emoji, int Count, bool DidReact);
\ No newline at end of file
+public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs
index 6036767..ed882f7 100644
--- a/Femto.Api/Controllers/Posts/PostsController.cs
+++ b/Femto.Api/Controllers/Posts/PostsController.cs
@@ -1,6 +1,7 @@
using Femto.Api.Controllers.Posts.Dto;
using Femto.Common;
using Femto.Modules.Blog.Application;
+using Femto.Modules.Blog.Application.Commands.AddPostComment;
using Femto.Modules.Blog.Application.Commands.AddPostReaction;
using Femto.Modules.Blog.Application.Commands.ClearPostReaction;
using Femto.Modules.Blog.Application.Commands.CreatePost;
@@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts;
[ApiController]
[Route("posts")]
-public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext)
+public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
: ControllerBase
{
[HttpGet]
@@ -25,7 +26,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
var res = await blogModule.Query(
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
{
- From = searchParams.From,
+ After = searchParams.After,
Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId,
Author = searchParams.Author,
@@ -33,18 +34,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
- return new LoadPostsResponse(
- res.Posts.Select(p => new PostDto(
- new PostAuthorDto(p.Author.AuthorId, p.Author.Username),
- p.PostId,
- p.Text,
- p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
- p.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)),
- p.CreatedAt,
- p.PossibleReactions
- )),
- res.Next
- );
+ return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
}
[HttpPost]
@@ -75,17 +65,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
- return new CreatePostResponse(
- new PostDto(
- new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
- post.PostId,
- post.Text,
- post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
- post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(),
- post.CreatedAt,
- post.PossibleReactions
- )
+ return new CreatePostResponse(PostDto.FromModel(post));
+ }
+
+ [HttpGet("{postId}")]
+ public async Task> GetPost(
+ Guid postId,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await blogModule.Query(
+ new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id),
+ cancellationToken
);
+
+ var post = result.Posts.SingleOrDefault();
+
+ if (post is null)
+ return NotFound();
+
+ return new GetPostResponse(PostDto.FromModel(post));
}
[HttpDelete("{postId}")]
@@ -100,24 +99,56 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
[HttpPost("{postId}/reactions")]
[Authorize]
- public async Task AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken)
+ public async Task AddPostReaction(
+ Guid postId,
+ [FromBody] AddPostReactionRequest request,
+ CancellationToken cancellationToken
+ )
{
var currentUser = currentUserContext.CurrentUser!;
- await blogModule.Command(new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken);
+ await blogModule.Command(
+ new AddPostReactionCommand(postId, request.Emoji, currentUser.Id),
+ cancellationToken
+ );
return this.Ok();
}
-
+
[HttpDelete("{postId}/reactions")]
[Authorize]
- public async Task DeletePostReaction(Guid postId, [FromBody] DeletePostReactionRequest request, CancellationToken cancellationToken)
+ public async Task DeletePostReaction(
+ Guid postId,
+ [FromBody] DeletePostReactionRequest request,
+ CancellationToken cancellationToken
+ )
{
var currentUser = currentUserContext.CurrentUser!;
- await blogModule.Command(new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken);
+ await blogModule.Command(
+ new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id),
+ cancellationToken
+ );
return this.Ok();
}
+ [HttpPost("{postId}/comments")]
+ [Authorize]
+ public async Task AddPostComment(
+ Guid postId,
+ [FromBody] AddPostCommentRequest request,
+ CancellationToken cancellationToken
+ )
+ {
+ if (currentUserContext.CurrentUser?.Id != request.AuthorId)
+ return this.BadRequest();
+
+ await blogModule.Command(
+ new AddPostCommentCommand(postId, request.AuthorId, request.Content),
+ cancellationToken
+ );
+
+ return this.Ok();
+ }
}
diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs
index 08a40b8..ebb81fd 100644
--- a/Femto.Api/Program.cs
+++ b/Femto.Api/Program.cs
@@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus);
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
-builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory);
+builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System);
builder.Services.AddScoped();
builder.Services.AddScoped(s => s.GetRequiredService());
diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json
index 9a9026a..b024278 100644
--- a/Femto.Api/Properties/launchSettings.json
+++ b/Femto.Api/Properties/launchSettings.json
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
- "applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181",
+ "applicationUrl": "https://localhost:7269",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs
index e693180..2b8ee96 100644
--- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs
+++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs
@@ -15,18 +15,12 @@ internal static class HttpContextSessionExtensions
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
- public static string? GetSessionId(this HttpContext httpContext)
- {
- var sessionId = httpContext.Request.Cookies["sid"];
-
- return sessionId;
- }
+ public static string? GetSessionId(this HttpContext httpContext) =>
+ httpContext.Request.Cookies["sid"];
public static void SetSession(this HttpContext context, Session session, UserInfo user)
{
- var cookieSettings = context.RequestServices.GetRequiredService<
- IOptions
- >();
+ var cookieSettings = context.RequestServices.GetRequiredService>();
context.Response.Cookies.Append(
"sid",
@@ -57,7 +51,7 @@ internal static class HttpContextSessionExtensions
}
);
}
-
+
public static void DeleteSession(this HttpContext httpContext)
{
var cookieSettings = httpContext.RequestServices.GetRequiredService<
@@ -91,4 +85,47 @@ internal static class HttpContextSessionExtensions
}
);
}
+
+
+ public static RememberMeToken? GetRememberMeToken(this HttpContext httpContext) =>
+ httpContext.Request.Cookies["rid"] is { } code ? RememberMeToken.FromCode(code) : null;
+
+ public static void SetRememberMeToken(this HttpContext context, NewRememberMeToken token)
+ {
+ var cookieSettings = context.RequestServices.GetRequiredService>();
+
+ context.Response.Cookies.Append(
+ "rid",
+ token.Code,
+ new CookieOptions
+ {
+ Path = "/",
+ IsEssential = true,
+ Domain = cookieSettings.Value.Domain,
+ HttpOnly = true,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = token.Expires,
+ }
+ );
+ }
+
+ public static void DeleteRememberMeToken(this HttpContext context)
+ {
+ var cookieSettings = context.RequestServices.GetRequiredService>();
+
+ context.Response.Cookies.Delete(
+ "rid",
+ new CookieOptions
+ {
+ Path = "/",
+ HttpOnly = true,
+ Domain = cookieSettings.Value.Domain,
+ IsEssential = true,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = DateTimeOffset.UtcNow.AddDays(-1),
+ }
+ );
+ }
}
diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs
index 3e7dae6..629b2d2 100644
--- a/Femto.Common/ICurrentUserContext.cs
+++ b/Femto.Common/ICurrentUserContext.cs
@@ -5,4 +5,4 @@ public interface ICurrentUserContext
CurrentUser? CurrentUser { get; }
}
-public record CurrentUser(Guid Id, string Username);
+public record CurrentUser(Guid Id, string Username, bool IsSuperUser);
\ No newline at end of file
diff --git a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
similarity index 88%
rename from Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
rename to Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
index b86a7e4..e5f338f 100644
--- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
+++ b/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
@@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common.Infrastructure;
-public class SaveChangesPipelineBehaviour(
+public class DDDPipelineBehaviour(
DbContext context,
IPublisher publisher,
- ILogger> logger
+ ILogger> logger
) : IPipelineBehavior
where TRequest : notnull
{
diff --git a/Femto.Common/Infrastructure/DomainServiceExtensions.cs b/Femto.Common/Infrastructure/DomainServiceExtensions.cs
index e83469e..9812c93 100644
--- a/Femto.Common/Infrastructure/DomainServiceExtensions.cs
+++ b/Femto.Common/Infrastructure/DomainServiceExtensions.cs
@@ -12,7 +12,7 @@ public static class DomainServiceExtensions
services.AddScoped(s => s.GetRequiredService());
services.AddTransient(
typeof(IPipelineBehavior<,>),
- typeof(SaveChangesPipelineBehaviour<,>)
+ typeof(DDDPipelineBehaviour<,>)
);
}
diff --git a/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql
new file mode 100644
index 0000000..15d0323
--- /dev/null
+++ b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql
@@ -0,0 +1,5 @@
+-- Migration: AddInvalidateToLongTermSession
+-- Created at: 19/07/2025 10:42:00
+
+ALTER TABLE authn.long_term_session
+ADD COLUMN is_invalidated BOOLEAN NOT NULL DEFAULT FALSE;
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql
new file mode 100644
index 0000000..4557156
--- /dev/null
+++ b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql
@@ -0,0 +1,4 @@
+-- Migration: AddTimestampToReaction
+-- Created at: 10/08/2025 15:21:32
+alter table blog.post_reaction
+add column created_at timestamptz;
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql
new file mode 100644
index 0000000..44e0086
--- /dev/null
+++ b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql
@@ -0,0 +1,11 @@
+-- Migration: AddCommentToPost
+-- Created at: 10/08/2025 17:22:42
+
+CREATE TABLE blog.post_comment
+(
+ id uuid PRIMARY KEY,
+ post_id uuid REFERENCES blog.post(id),
+ author_id uuid REFERENCES blog.author(id),
+ content TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+)
\ No newline at end of file
diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs
index 433f73c..2c8efcc 100644
--- a/Femto.Database/Seed/TestDataSeeder.cs
+++ b/Femto.Database/Seed/TestDataSeeder.cs
@@ -43,6 +43,7 @@ public static class TestDataSeeder
('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id, '["🍆", "🧢", "🧑🏾🎓", "🥕", "🕗"]', 'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
;
+
INSERT INTO blog.post_media
(id, post_id, url, ordering)
VALUES
@@ -63,6 +64,12 @@ public static class TestDataSeeder
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗')
;
+ INSERT INTO blog.post_comment
+ (id, post_id, author_id, content)
+ VALUES
+ ('9116da05-49eb-4053-9199-57f54f92e73a', '019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'this is a comment!')
+ ;
+
INSERT INTO authn.user_identity
(id, username, password_hash, password_salt)
VALUES
diff --git a/Femto.Modules.Auth/Application/AuthService.cs b/Femto.Modules.Auth/Application/AuthService.cs
new file mode 100644
index 0000000..eb6215f
--- /dev/null
+++ b/Femto.Modules.Auth/Application/AuthService.cs
@@ -0,0 +1,258 @@
+using Dapper;
+using Femto.Common.Domain;
+using Femto.Common.Infrastructure.DbConnection;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Data;
+using Femto.Modules.Auth.Infrastructure;
+using Femto.Modules.Auth.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Application;
+
+internal class AuthService(
+ AuthContext context,
+ SessionStorage sessionStorage,
+ IDbConnectionFactory connectionFactory
+) : IAuthService
+{
+ public async Task AuthenticateUserCredentials(
+ string username,
+ string password,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var user = await context
+ .Users.Where(u => u.Username == username)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (user is null)
+ return null;
+
+ if (!user.HasPassword(password))
+ return null;
+
+ var session = new Session(user.Id, true);
+
+ await sessionStorage.AddSession(session);
+
+ return new(
+ new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()),
+ session
+ );
+ }
+
+ public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken)
+ {
+ return context
+ .Users.Where(u => u.Id == userId)
+ .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList()))
+ .SingleOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task CreateNewSession(Guid userId)
+ {
+ var session = new Session(userId, true);
+
+ await sessionStorage.AddSession(session);
+
+ return session;
+ }
+
+ public async Task CreateWeakSession(Guid userId)
+ {
+ var session = new Session(userId, false);
+
+ await sessionStorage.AddSession(session);
+
+ return session;
+ }
+
+ public Task GetSession(string sessionId)
+ {
+ return sessionStorage.GetSession(sessionId);
+ }
+
+ public async Task DeleteSession(string sessionId)
+ {
+ await sessionStorage.DeleteSession(sessionId);
+ }
+
+ public async Task CreateUserWithCredentials(
+ string username,
+ string password,
+ string signupCode,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var code = await context
+ .SignupCodes.Where(c => c.Code == 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 usernameTaken = await context.Users.AnyAsync(
+ u => u.Username == username,
+ cancellationToken
+ );
+
+ if (usernameTaken)
+ throw new DomainError("username taken");
+
+ var user = new UserIdentity(username);
+
+ await context.AddAsync(user, cancellationToken);
+
+ user.SetPassword(password);
+
+ code.Redeem(user.Id);
+
+ var session = new Session(user.Id, true);
+
+ await sessionStorage.AddSession(session);
+
+ await context.SaveChangesAsync(cancellationToken);
+
+ return new(new UserInfo(user), session);
+ }
+
+ public async Task AddSignupCode(
+ string code,
+ string recipientName,
+ CancellationToken cancellationToken
+ )
+ {
+ await context.SignupCodes.AddAsync(
+ new SignupCode("", recipientName, code),
+ cancellationToken
+ );
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task> GetSignupCodes(
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var conn = connectionFactory.GetConnection();
+
+ // lang=sql
+ const string sql = """
+ SELECT
+ sc.code as Code,
+ sc.recipient_email as Email,
+ sc.recipient_name as Name,
+ sc.redeeming_user_id as RedeemedByUserId,
+ u.username as RedeemedByUsername,
+ sc.expires_at as ExpiresOn
+ FROM authn.signup_code sc
+ LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id
+ ORDER BY sc.created_at DESC
+ """;
+
+ var result = await conn.QueryAsync(sql, cancellationToken);
+
+ return result
+ .Select(row => new SignupCodeDto(
+ row.Code,
+ row.Email,
+ row.Name,
+ row.RedeemedByUserId,
+ row.RedeemedByUsername,
+ row.ExpiresOn
+ ))
+ .ToList();
+ }
+
+ public async Task CreateRememberMeToken(Guid userId)
+ {
+ var (rememberMeToken, verifier) = LongTermSession.Create(userId);
+
+ await context.AddAsync(rememberMeToken);
+ await context.SaveChangesAsync();
+
+ return new(rememberMeToken.Selector, verifier, rememberMeToken.Expires);
+ }
+
+ public async Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(
+ RememberMeToken rememberMeToken
+ )
+ {
+ var token = await context.LongTermSessions.SingleOrDefaultAsync(t =>
+ t.Selector == rememberMeToken.Selector
+ );
+
+ if (token is null)
+ return (null, null);
+
+ if (!token.CheckVerifier(rememberMeToken.Verifier))
+ return (null, null);
+
+ var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId);
+
+ if (user is null)
+ return (null, null);
+
+ if (token.ExpiresSoon)
+ {
+ var (newToken, verifier) = LongTermSession.Create(user.Id);
+ await context.AddAsync(newToken);
+ await context.SaveChangesAsync();
+
+ return (new(user), new(newToken.Selector, verifier, newToken.Expires));
+ }
+
+ return (new(user), null);
+ }
+
+ public async Task DeleteRememberMeToken(RememberMeToken rememberMeToken)
+ {
+ var session = await context.LongTermSessions.SingleOrDefaultAsync(s =>
+ s.Selector == rememberMeToken.Selector
+ );
+
+ if (session is null)
+ return;
+
+ if (!session.CheckVerifier(rememberMeToken.Verifier))
+ return;
+
+ context.Remove(session);
+ await context.SaveChangesAsync();
+ }
+
+ public async Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken)
+ {
+ // change the password
+ // invalidate long term sessions
+ // invalidate sessions
+
+ var user = await context.Users.SingleOrDefaultAsync(u => u.Id == userId,cancellationToken);
+
+ if (user is null)
+ throw new DomainError("invalid user");
+
+ user.SetPassword(password);
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken)
+ {
+ await sessionStorage.InvalidateUserSessions(userId);
+ }
+
+ private class GetSignupCodesQueryResultRow
+ {
+ public string Code { get; set; }
+ public string Email { get; set; }
+ public string Name { get; set; }
+ public Guid? RedeemedByUserId { get; set; }
+ public string? RedeemedByUsername { get; set; }
+ public DateTimeOffset? ExpiresOn { get; set; }
+ }
+}
diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs
index c78e923..362e600 100644
--- a/Femto.Modules.Auth/Application/AuthStartup.cs
+++ b/Femto.Modules.Auth/Application/AuthStartup.cs
@@ -3,7 +3,6 @@ using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration;
-using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Infrastructure;
using MediatR;
@@ -21,13 +20,14 @@ public static class AuthStartup
this IServiceCollection rootContainer,
string connectionString,
IEventBus eventBus,
- ILoggerFactory loggerFactory
+ ILoggerFactory loggerFactory,
+ TimeProvider timeProvider
)
{
var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services =>
- ConfigureServices(services, connectionString, eventBus, loggerFactory)
+ ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
);
var host = hostBuilder.Build();
@@ -41,7 +41,6 @@ public static class AuthStartup
}
);
- rootContainer.ExposeScopedService();
rootContainer.ExposeScopedService();
rootContainer.AddHostedService(services => new AuthApplication(host));
@@ -54,9 +53,12 @@ public static class AuthStartup
IServiceCollection services,
string connectionString,
IEventPublisher publisher,
- ILoggerFactory loggerFactory
+ ILoggerFactory loggerFactory,
+ TimeProvider timeProvider
)
{
+ services.AddSingleton(timeProvider);
+
services.AddTransient(_ => new DbConnectionFactory(connectionString));
services.AddDbContext(builder =>
@@ -86,7 +88,8 @@ public static class AuthStartup
services.AddSingleton(publisher);
services.AddSingleton();
- services.AddScoped();
+ services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
+
services.AddScoped();
}
diff --git a/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs
new file mode 100644
index 0000000..5750b8d
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs
@@ -0,0 +1,18 @@
+using Femto.Modules.Auth.Models;
+
+namespace Femto.Modules.Auth.Application.Dto;
+
+public record RememberMeToken(string Selector, string Verifier)
+{
+ public static RememberMeToken FromCode(string code)
+ {
+ var parts = code.Split('.');
+ return new RememberMeToken(parts[0], parts[1]);
+ }
+
+};
+
+public record NewRememberMeToken(string Selector, string Verifier, DateTimeOffset Expires)
+{
+ public string Code => $"{Selector}.{Verifier}";
+}
diff --git a/Femto.Modules.Auth/Application/IAuthService.cs b/Femto.Modules.Auth/Application/IAuthService.cs
new file mode 100644
index 0000000..f656d95
--- /dev/null
+++ b/Femto.Modules.Auth/Application/IAuthService.cs
@@ -0,0 +1,45 @@
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Models;
+
+namespace Femto.Modules.Auth.Application;
+
+public interface IAuthService
+{
+ public Task AuthenticateUserCredentials(
+ string username,
+ string password,
+ CancellationToken cancellationToken = default
+ );
+ public Task GetUserWithId(
+ Guid? userId,
+ CancellationToken cancellationToken = default
+ );
+ public Task CreateNewSession(Guid userId);
+ public Task CreateWeakSession(Guid userId);
+ public Task GetSession(string sessionId);
+ public Task DeleteSession(string sessionId);
+
+ public Task CreateUserWithCredentials(string username,
+ string password,
+ string signupCode,
+ CancellationToken cancellationToken = default);
+
+ public Task AddSignupCode(
+ string code,
+ string recipientName,
+ CancellationToken cancellationToken = default
+ );
+
+ public Task> GetSignupCodes(
+ CancellationToken cancellationToken = default
+ );
+
+ Task CreateRememberMeToken(Guid userId);
+ Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken);
+ Task DeleteRememberMeToken(RememberMeToken rememberMeToken);
+
+ Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken = default);
+ Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken = default);
+}
+
+public record UserAndSession(UserInfo User, Session Session);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs
deleted file mode 100644
index be24aa9..0000000
--- a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-using Femto.Common.Domain;
-
-namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode;
-
-public record CreateSignupCodeCommand(string Code, string RecipientEmail, string RecipientName): ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs
deleted file mode 100644
index cfbb44a..0000000
--- a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Data;
-using Femto.Modules.Auth.Models;
-
-namespace Femto.Modules.Auth.Application.Interface.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/Interface/Deauthenticate/DeauthenticateCommand.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs
deleted file mode 100644
index 44c346f..0000000
--- a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-
-using Femto.Common.Domain;
-
-namespace Femto.Modules.Auth.Application.Interface.Deauthenticate;
-
-public record DeauthenticateCommand(Guid UserId, string SessionId, string? RememberMeToken) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs
deleted file mode 100644
index 435718c..0000000
--- a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Data;
-
-namespace Femto.Modules.Auth.Application.Interface.Deauthenticate;
-
-internal class DeauthenticateCommandHandler(AuthContext context) : ICommandHandler
-{
- public async Task Handle(DeauthenticateCommand request, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs
deleted file mode 100644
index 422a09d..0000000
--- a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
-
-public record GetSignupCodesQuery: IQuery>;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs
deleted file mode 100644
index 201fdce..0000000
--- a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using Dapper;
-using Femto.Common.Domain;
-using Femto.Common.Infrastructure.DbConnection;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
-
-public class GetSignupCodesQueryHandler(IDbConnectionFactory connectionFactory)
- : IQueryHandler>
-{
- public async Task> Handle(
- GetSignupCodesQuery request,
- CancellationToken cancellationToken
- )
- {
- using var conn = connectionFactory.GetConnection();
-
- // lang=sql
- const string sql = """
- SELECT
- sc.code as Code,
- sc.recipient_email as Email,
- sc.recipient_name as Name,
- sc.redeeming_user_id as RedeemedByUserId,
- u.username as RedeemedByUsername,
- sc.expires_at as ExpiresOn
- FROM authn.signup_code sc
- LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id
- ORDER BY sc.created_at DESC
- """;
-
- var result = await conn.QueryAsync(sql);
-
- return result
- .Select(row => new SignupCodeDto(
- row.Code,
- row.Email,
- row.Name,
- row.RedeemedByUserId,
- row.RedeemedByUsername,
- row.ExpiresOn
- ))
- .ToList();
- }
-
- private class QueryResultRow
- {
- public string Code { get; set; }
- public string Email { get; set; }
- public string Name { get; set; }
- public Guid? RedeemedByUserId { get; set; }
- public string? RedeemedByUsername { get; set; }
- public DateTimeOffset? ExpiresOn { get; set; }
- }
-}
diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs
deleted file mode 100644
index 430b0d3..0000000
--- a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.GetUserInfo;
-
-public record GetUserInfoCommand(Guid ForUser) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs
deleted file mode 100644
index 72c5f20..0000000
--- a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Data;
-using Microsoft.EntityFrameworkCore;
-
-namespace Femto.Modules.Auth.Application.Interface.GetUserInfo;
-
-internal class GetUserInfoCommandHandler(AuthContext context)
- : ICommandHandler
-{
- public async Task Handle(
- GetUserInfoCommand request,
- CancellationToken cancellationToken
- )
- {
-
- var user = await context.Users.SingleOrDefaultAsync(
- u => u.Id == request.ForUser,
- cancellationToken
- );
-
- if (user is null)
- return null;
-
- return new UserInfo(user);
- }
-}
diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs
deleted file mode 100644
index 87332cb..0000000
--- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.Register;
-
-public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs
deleted file mode 100644
index 7bb17be..0000000
--- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-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.Interface.Register;
-
-internal class RegisterCommandHandler(AuthContext context)
- : ICommandHandler
-{
- public async Task 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 usernameTaken = await context.Users.AnyAsync(
- u => u.Username == request.Username,
- cancellationToken
- );
-
- if (usernameTaken)
- throw new DomainError("username taken");
-
- var user = new UserIdentity(request.Username);
-
- await context.AddAsync(user, cancellationToken);
-
- user.SetPassword(request.Password);
-
- code.Redeem(user.Id);
-
- return new UserInfo(user);
- }
-}
diff --git a/Femto.Modules.Auth/Application/Services/AuthModule.cs b/Femto.Modules.Auth/Application/Services/AuthModule.cs
deleted file mode 100644
index f64d78f..0000000
--- a/Femto.Modules.Auth/Application/Services/AuthModule.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Femto.Common.Domain;
-using MediatR;
-
-namespace Femto.Modules.Auth.Application.Services;
-
-internal class AuthModule(IMediator mediator) : IAuthModule
-{
- public async Task Command(ICommand command, CancellationToken cancellationToken = default) =>
- await mediator.Send(command, cancellationToken);
-
- public async Task Command(
- ICommand command,
- CancellationToken cancellationToken = default
- ) => await mediator.Send(command, cancellationToken);
-
- public async Task Query(
- IQuery query,
- CancellationToken cancellationToken = default
- ) => await mediator.Send(query, cancellationToken);
-}
diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs
deleted file mode 100644
index 1a9f868..0000000
--- a/Femto.Modules.Auth/Application/Services/AuthService.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Data;
-using Femto.Modules.Auth.Infrastructure;
-using Femto.Modules.Auth.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Femto.Modules.Auth.Application.Services;
-
-internal class AuthService(AuthContext context, SessionStorage storage) : IAuthService
-{
- public async Task GetUserWithCredentials(
- string username,
- string password,
- CancellationToken cancellationToken = default
- )
- {
- var user = await context
- .Users.Where(u => u.Username == username)
- .SingleOrDefaultAsync(cancellationToken);
-
- if (user is null)
- return null;
-
- if (!user.HasPassword(password))
- return null;
-
- return new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList());
- }
-
- public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken)
- {
- return context
- .Users.Where(u => u.Id == userId)
- .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList()))
- .SingleOrDefaultAsync(cancellationToken);
- }
-
- public async Task CreateStrongSession(Guid userId)
- {
- var session = new Session(userId, true);
-
- await storage.AddSession(session);
-
- return session;
- }
-
- public async Task CreateWeakSession(Guid userId)
- {
- var session = new Session(userId, false);
-
- await storage.AddSession(session);
-
- return session;
- }
-
- public Task GetSession(string sessionId)
- {
- return storage.GetSession(sessionId);
- }
-
- public async Task DeleteSession(string sessionId)
- {
- await storage.DeleteSession(sessionId);
- }
-
- public async Task CreateLongTermSession(Guid userId, bool isStrong)
- {
- throw new NotImplementedException();
- }
-
- public async Task DeleteLongTermSession(string sessionId)
- {
- throw new NotImplementedException();
- }
-
- public async Task RefreshLongTermSession(string sessionId)
- {
- throw new NotImplementedException();
- }
-
- public async Task ValidateLongTermSession(string sessionId)
- {
- throw new NotImplementedException();
- }
-}
diff --git a/Femto.Modules.Auth/Application/Services/IAuthModule.cs b/Femto.Modules.Auth/Application/Services/IAuthModule.cs
deleted file mode 100644
index df34366..0000000
--- a/Femto.Modules.Auth/Application/Services/IAuthModule.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using Femto.Common.Domain;
-
-namespace Femto.Modules.Auth.Application.Services;
-
-public interface IAuthModule
-{
- Task Command(ICommand command, CancellationToken cancellationToken = default);
- Task Command(ICommand command, CancellationToken cancellationToken = default);
- Task Query(IQuery query, CancellationToken cancellationToken = default);
-}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs
deleted file mode 100644
index 2858053..0000000
--- a/Femto.Modules.Auth/Application/Services/IAuthService.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Models;
-
-namespace Femto.Modules.Auth.Application.Services;
-
-///
-/// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling,
-/// particularly in regards to session management. I may or may not bother to move the commands and queries here also,
-/// but for controller actions I do quite like having the abstraction, and there is less drive within me to bother.
-/// It just seems redundant to expose them both, and it's a bit confusin'
-///
-public interface IAuthService
-{
- public Task GetUserWithCredentials(string username, string password, CancellationToken cancellationToken = default);
- public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken = default);
- public Task CreateStrongSession(Guid userId);
- public Task CreateWeakSession(Guid userId);
- public Task GetSession(string sessionId);
- public Task DeleteSession(string sessionId);
-}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs
index e4488e4..ac395ba 100644
--- a/Femto.Modules.Auth/Data/AuthContext.cs
+++ b/Femto.Modules.Auth/Data/AuthContext.cs
@@ -1,6 +1,10 @@
+using Femto.Common.Domain;
using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Auth.Models;
+using MediatR;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.Logging;
namespace Femto.Modules.Auth.Data;
@@ -17,4 +21,43 @@ internal class AuthContext(DbContextOptions options) : DbContext(op
builder.HasDefaultSchema("authn");
builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly);
}
+
+ public override int SaveChanges()
+ {
+ throw new InvalidOperationException("Use SaveChangesAsync instead");
+ }
+
+ public override async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ await EmitDomainEvents(cancellationToken);
+
+ return await base.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task EmitDomainEvents(CancellationToken cancellationToken)
+ {
+ var logger = this.GetService>();
+ var publisher = this.GetService();
+ var domainEvents = this
+ .ChangeTracker.Entries()
+ .SelectMany(e =>
+ {
+ var events = e.Entity.DomainEvents;
+ e.Entity.ClearDomainEvents();
+ return events;
+ })
+ .ToList();
+
+ logger.LogTrace("loaded {Count} domain events", domainEvents.Count);
+
+ foreach (var domainEvent in domainEvents)
+ {
+ logger.LogTrace(
+ "publishing {Type} domain event {Id}",
+ domainEvent.GetType().Name,
+ domainEvent.EventId
+ );
+ await publisher.Publish(domainEvent, cancellationToken);
+ }
+ }
}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs
new file mode 100644
index 0000000..00f2a13
--- /dev/null
+++ b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs
@@ -0,0 +1,13 @@
+using Femto.Modules.Auth.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Femto.Modules.Auth.Data.Configurations;
+
+public class LongTermSessionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("long_term_session");
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs
new file mode 100644
index 0000000..cc4f983
--- /dev/null
+++ b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs
@@ -0,0 +1,23 @@
+using Femto.Modules.Auth.Data;
+using MediatR;
+
+namespace Femto.Modules.Auth.Infrastructure;
+
+internal class SaveChangesPipelineBehaviour(AuthContext context)
+ : IPipelineBehavior
+ where TRequest : notnull
+{
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken
+ )
+ {
+ var response = await next(cancellationToken);
+
+ if (context.ChangeTracker.HasChanges())
+ await context.SaveChangesAsync(cancellationToken);
+
+ return response;
+ }
+}
diff --git a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs
index 0e1b3dd..e331396 100644
--- a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs
+++ b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs
@@ -1,29 +1,55 @@
+using System.Collections;
+using System.Collections.Concurrent;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Caching.Memory;
namespace Femto.Modules.Auth.Infrastructure;
-internal class SessionStorage(MemoryCacheOptions? options = null)
+internal class SessionStorage(TimeProvider timeProvider)
{
- private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions());
+ private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions());
- public Task GetSession(string id)
+ public async Task GetSession(string id)
{
- return Task.FromResult(this._storage.Get(id));
+ var session = this._storage.Get($"session:{id}");
+
+ if (session is null)
+ return null;
+
+ var invalidUntil = this._storage.Get(
+ $"user:invalid_until:{session.UserId}"
+ );
+
+ if (invalidUntil is not null && invalidUntil > session.Expires)
+ return null;
+
+ return session;
}
public Task AddSession(Session session)
{
- using var entry = this._storage.CreateEntry(session.Id);
- entry.Value = session;
- entry.SetAbsoluteExpiration(session.Expires);
+ using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}");
+ sessionEntry.Value = session;
+ sessionEntry.SetAbsoluteExpiration(session.Expires);
return Task.CompletedTask;
}
public Task DeleteSession(string id)
{
- this._storage.Remove(id);
+ this._storage.Remove($"session:{id}");
+
+ return Task.CompletedTask;
+ }
+
+ public Task InvalidateUserSessions(Guid userId)
+ {
+ var invalidUntil = timeProvider.GetUtcNow() + Session.ValidityPeriod;
+
+ // invalidate sessions who are currently valid
+ // any sessions created after this will have a validity period that extends past invalid_until
+ // this cache entry doesn't need to live longer than that point in time
+ this._storage.Set($"user:invalid_until:{userId}", invalidUntil, invalidUntil);
return Task.CompletedTask;
}
diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs
new file mode 100644
index 0000000..e09c66e
--- /dev/null
+++ b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs
@@ -0,0 +1,22 @@
+using Femto.Modules.Auth.Data;
+using Femto.Modules.Auth.Models.Events;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Models.DomainEventHandlers;
+
+internal class UserPasswordChangedHandler(AuthContext context)
+ : INotificationHandler
+{
+ public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken)
+ {
+ var longTermSessions = await context
+ .LongTermSessions.Where(s => s.UserId == notification.User.Id)
+ .ToListAsync(cancellationToken);
+
+ foreach (var session in longTermSessions)
+ {
+ session.Invalidate();
+ }
+ }
+}
diff --git a/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs
new file mode 100644
index 0000000..70c9a73
--- /dev/null
+++ b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs
@@ -0,0 +1,5 @@
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Auth.Models.Events;
+
+internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Models/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs
index 06bd19d..eba3d9d 100644
--- a/Femto.Modules.Auth/Models/LongTermSession.cs
+++ b/Femto.Modules.Auth/Models/LongTermSession.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using static System.Security.Cryptography.RandomNumberGenerator;
@@ -6,22 +7,32 @@ namespace Femto.Modules.Auth.Models;
public class LongTermSession
{
private static TimeSpan TokenTimeout { get; } = TimeSpan.FromDays(90);
-
+ private static TimeSpan RefreshBuffer { get; } = TimeSpan.FromDays(5);
+
public int Id { get; private set; }
-
+
public string Selector { get; private set; }
-
+
public byte[] HashedVerifier { get; private set; }
-
+
public DateTimeOffset Expires { get; private set; }
-
+
public Guid UserId { get; private set; }
- private LongTermSession() {}
-
+ public bool IsInvalidated { get; private set; }
+
+ [NotMapped]
+ public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
+
+ private LongTermSession() { }
+
public static (LongTermSession, string) Create(Guid userId)
{
- var selector = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 12);
+ var selector = GetString(
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ 12
+ );
+
var verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32);
using var sha256 = System.Security.Cryptography.SHA256.Create();
@@ -29,23 +40,34 @@ public class LongTermSession
var longTermSession = new LongTermSession
{
Selector = selector,
- HashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)),
+ HashedVerifier = ComputeHash(verifier),
UserId = userId,
- Expires = DateTimeOffset.UtcNow + TokenTimeout
+ Expires = DateTimeOffset.UtcNow + TokenTimeout,
};
-
- var rememberMeToken = $"{selector}.{verifier}";
- return (longTermSession, rememberMeToken);
+ return (longTermSession, verifier);
}
- public bool Validate(string verifier)
+ public bool CheckVerifier(string verifier)
{
- if (this.Expires < DateTimeOffset.UtcNow)
+ if (this.IsInvalidated)
return false;
+ if (this.Expires < DateTimeOffset.UtcNow)
+ return false;
+
+ return ComputeHash(verifier).SequenceEqual(this.HashedVerifier);
+ }
+
+ private static byte[] ComputeHash(string verifier)
+ {
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
- return hashedVerifier.SequenceEqual(this.HashedVerifier);
+ return hashedVerifier;
}
-}
\ No newline at end of file
+
+ public void Invalidate()
+ {
+ this.IsInvalidated = true;
+ }
+}
diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs
index c142fb8..e641a61 100644
--- a/Femto.Modules.Auth/Models/Session.cs
+++ b/Femto.Modules.Auth/Models/Session.cs
@@ -4,11 +4,13 @@ namespace Femto.Modules.Auth.Models;
public class Session(Guid userId, bool isStrong)
{
+ public static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15);
+ private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5);
public string Id { get; } = Convert.ToBase64String(GetBytes(32));
public Guid UserId { get; } = userId;
- public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(15);
+ public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + ValidityPeriod;
- public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5);
+ public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
public bool IsStronglyAuthenticated { get; } = isStrong;
public bool IsExpired => this.Expires < DateTimeOffset.UtcNow;
}
diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs
index a7e3ddd..bd0288f 100644
--- a/Femto.Modules.Auth/Models/UserIdentity.cs
+++ b/Femto.Modules.Auth/Models/UserIdentity.cs
@@ -28,6 +28,8 @@ internal class UserIdentity : Entity
public void SetPassword(string password)
{
+ if (this.Password is not null)
+ this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
this.Password = new Password(password);
}
diff --git a/Femto.Modules.Blog.Data/Class1.cs b/Femto.Modules.Blog.Data/Class1.cs
deleted file mode 100644
index 3be8b2a..0000000
--- a/Femto.Modules.Blog.Data/Class1.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace Femto.Modules.Blog.Data;
-
-public class Class1
-{
-}
\ No newline at end of file
diff --git a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
deleted file mode 100644
index 17b910f..0000000
--- a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net9.0
- enable
- enable
-
-
-
diff --git a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
deleted file mode 100644
index 6ae6742..0000000
--- a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
- net9.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
- ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll
-
-
-
-
diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs
new file mode 100644
index 0000000..445c59e
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs
@@ -0,0 +1,5 @@
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
+
+public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs
new file mode 100644
index 0000000..6e52877
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs
@@ -0,0 +1,20 @@
+using Femto.Common.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
+
+internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler
+{
+ public async Task Handle(AddPostCommentCommand request, CancellationToken cancellationToken)
+ {
+ var post = await context.Posts.SingleOrDefaultAsync(
+ p => p.Id == request.PostId,
+ cancellationToken
+ );
+
+ if (post is null)
+ return;
+
+ post.AddComment(request.AuthorId, request.Content);
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs
index 2d9c713..25bab45 100644
--- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs
+++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs
@@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context)
media.Width,
media.Height
))
- .ToList()
- )
- {
- IsPublic = request.IsPublic is true
- };
+ .ToList(),
+ request.IsPublic is true
+ );
await context.AddAsync(post, cancellationToken);
@@ -39,7 +37,8 @@ internal class CreatePostCommandHandler(BlogContext context)
post.PostedOn,
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username),
[],
- post.PossibleReactions
+ post.PossibleReactions,
+ []
);
}
}
diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs
index b1defec..630cbe2 100644
--- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs
+++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs
@@ -24,6 +24,8 @@ internal class PostConfiguration : IEntityTypeConfiguration
}
);
+ table.OwnsMany(p => p.Comments).WithOwner();
+
table.Property("PossibleReactionsJson").HasColumnName("possible_reactions");
table.Ignore(e => e.PossibleReactions);
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs
index be8157a..8b75d6e 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
-public record GetPostsQueryResult(IList Posts, Guid? Next);
\ No newline at end of file
+public record GetPostsQueryResult(IList Posts);
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs
new file mode 100644
index 0000000..55ea5e8
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs
@@ -0,0 +1,3 @@
+namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
+
+public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs
index b8b6a3d..63efede 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs
@@ -7,5 +7,6 @@ public record PostDto(
DateTimeOffset CreatedAt,
PostAuthorDto Author,
IList Reactions,
- IEnumerable PossibleReactions
-);
+ IEnumerable PossibleReactions,
+ IList Comments
+);
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs
index 9ea33dd..60349b9 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
-public record PostReactionDto(string Emoji, int Count, bool DidReact);
\ No newline at end of file
+public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs
index f8af9d2..1bb1d4c 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs
@@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
+///
+/// Get posts in reverse chronological order
+///
+///
public record GetPostsQuery(Guid? CurrentUserId) : IQuery
{
- public Guid? From { get; init; }
+ ///
+ /// Id of the specific post to load. If specified, After and Amount are ignored
+ ///
+ public Guid? PostId { get; }
+
+ ///
+ /// If specified, loads posts from after the given Id. Used for paging
+ ///
+ public Guid? After { get; init; }
public int Amount { get; init; } = 20;
public Guid? AuthorId { get; init; }
public string? Author { get; init; }
- ///
- /// Default is to load in reverse chronological order
- /// TODO this is not exposed on the client as it probably wouldn't work that well
- ///
- public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
-}
-
-public enum GetPostsDirection
-{
- Forward,
- Backward,
-}
+ public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
+ {
+ this.PostId = postId;
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs
index 26ae43a..c57627f 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs
@@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
var username = query.Author;
var authorGuid = query.AuthorId;
- var cursor = query.From;
+ var cursor = query.After;
var showPrivate = query.CurrentUserId is not null;
var loadPostsResult = await conn.QueryAsync(
@@ -33,9 +33,10 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
from blog.post
inner join blog.author on blog.author.id = blog.post.author_id
where (@username is null or blog.author.username = @username)
+ and (@postId is null or blog.post.id = @postId)
and (@showPrivate or blog.post.is_public = true)
and (@authorGuid is null or blog.author.id = @authorGuid)
- and (@cursor is null or blog.post.id <= @cursor)
+ and (@cursor is null or blog.post.id < @cursor)
order by blog.post.id desc
limit @amount
""",
@@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
username,
authorGuid,
cursor,
- // load an extra one to take for the cursor
- amount = query.Amount + 1,
+ amount = query.PostId is not null ? 1 : query.Amount,
showPrivate,
+ postId = query.PostId,
}
);
- var loadedPosts = loadPostsResult.ToList();
- var posts = loadedPosts.Take(query.Amount).ToList();
- var next = loadedPosts.LastOrDefault()?.PostId;
+ var posts = loadPostsResult.ToList();
var postIds = posts.Select(p => p.PostId).ToList();
@@ -70,69 +69,69 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
new { postIds }
);
+ var media = loadMediaResult.ToList();
+
var loadReactionsResult = await conn.QueryAsync(
"""
select
pr.post_id as PostId,
- pr.author_id as AuthorId,
- pr.emoji as Emoji
+ a.username as AuthorName,
+ pr.emoji as Emoji,
+ pr.created_at as CreatedOn
from blog.post_reaction pr
+ join blog.author a on a.id = pr.author_id
where pr.post_id = ANY (@postIds)
""",
new { postIds }
);
- var reactionsByPostId = loadReactionsResult
- .GroupBy(r => r.PostId)
- .ToDictionary(
- group => group.Key,
- group =>
- group
- .GroupBy(
- r => r.Emoji,
- (key, g) =>
- {
- var reactions = g.ToList();
- return new PostReactionDto(
- key,
- reactions.Count,
- reactions.Any(r => r.AuthorId == query.CurrentUserId)
- );
- }
- )
- .ToList()
- );
+ var reactions = loadReactionsResult.ToList();
- var mediaByPostId = loadMediaResult
- .GroupBy(m => m.PostId)
- .ToDictionary(
- g => g.Key,
- g =>
- g.Select(m => new PostMediaDto(
- new Uri(m.MediaUrl),
- m.MediaWidth,
- m.MediaHeight
- ))
- .ToList()
- );
+ var loadCommentsResult = await conn.QueryAsync(
+ """
+ select
+ pc.id as CommentId,
+ pc.post_id as PostId,
+ pc.content as Content,
+ pc.created_at as PostedOn,
+ a.username as AuthorName
+ from blog.post_comment pc
+ join blog.author a on pc.author_id = a.id
+ where pc.post_id = ANY (@postIds)
+ """,
+ new { postIds }
+ );
+
+ var comments = loadCommentsResult.ToList();
return new GetPostsQueryResult(
posts
.Select(p => new PostDto(
p.PostId,
p.Content,
- mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [],
+ media
+ .Where(m => m.PostId == p.PostId)
+ .Select(m => new PostMediaDto(
+ new Uri(m.MediaUrl),
+ m.MediaWidth,
+ m.MediaHeight
+ ))
+ .ToList(),
p.PostedOn,
new PostAuthorDto(p.AuthorId, p.Username),
- reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos)
- ? reactionDtos.ToList()
- : [],
+ reactions
+ .Where(r => r.PostId == p.PostId)
+ .Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
+ .ToList(),
!string.IsNullOrEmpty(p.PossibleReactions)
? JsonSerializer.Deserialize>(p.PossibleReactions)!
- : []
+ : [],
+ comments
+ .Where(c => c.PostId == p.PostId)
+ .Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn))
+ .ToList()
))
- .ToList(),
- next
+ .ToList()
);
}
@@ -158,7 +157,17 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
internal record LoadReactionRow
{
public Guid PostId { get; init; }
- public Guid AuthorId { get; init; }
+ public string AuthorName { get; init; }
public string Emoji { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
+ }
+
+ internal record LoadCommentRow
+ {
+ public Guid CommentId { get; init; }
+ public Guid PostId { get; init; }
+ public string Content { get; init; }
+ public DateTimeOffset PostedOn { get; init; }
+ public string AuthorName { get; init; }
}
}
diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs
index dc4d937..b5a9b2d 100644
--- a/Femto.Modules.Blog/Domain/Posts/Post.cs
+++ b/Femto.Modules.Blog/Domain/Posts/Post.cs
@@ -13,7 +13,9 @@ internal class Post : Entity
public IList Media { get; private set; }
public IList Reactions { get; private set; } = [];
- public bool IsPublic { get; set; }
+
+ public IList Comments { get; private set; } = [];
+ public bool IsPublic { get; private set; }
public DateTimeOffset PostedOn { get; private set; }
@@ -27,7 +29,7 @@ internal class Post : Entity
private Post() { }
- public Post(Guid authorId, string content, IList media)
+ public Post(Guid authorId, string content, IList media, bool isPublic)
{
this.Id = Guid.CreateVersion7();
this.AuthorId = authorId;
@@ -35,6 +37,7 @@ internal class Post : Entity
this.Media = media;
this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
this.PostedOn = DateTimeOffset.UtcNow;
+ this.IsPublic = isPublic;
this.AddDomainEvent(new PostCreated(this));
}
@@ -56,4 +59,14 @@ internal class Post : Entity
.Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji)
.ToList();
}
+
+ public void AddComment(Guid authorId, string content)
+ {
+ // XXX just ignore empty comments for now. we may want to upgrade this to an error
+ // but it is probably suitable to just consider it a no-op
+ if (string.IsNullOrWhiteSpace(content))
+ return;
+
+ this.Comments.Add(new PostComment(authorId, content));
+ }
}
diff --git a/Femto.Modules.Blog/Domain/Posts/PostComment.cs b/Femto.Modules.Blog/Domain/Posts/PostComment.cs
new file mode 100644
index 0000000..6f658a8
--- /dev/null
+++ b/Femto.Modules.Blog/Domain/Posts/PostComment.cs
@@ -0,0 +1,19 @@
+namespace Femto.Modules.Blog.Domain.Posts;
+
+internal class PostComment
+{
+ public Guid Id { get; private set; }
+ public Guid AuthorId { get; private set; }
+ public DateTimeOffset CreatedAt { get; private set; }
+ public string Content { get; private set; }
+
+ private PostComment() {}
+
+ public PostComment(Guid authorId, string content)
+ {
+ this.Id = Guid.CreateVersion7();
+ this.AuthorId = authorId;
+ this.Content = content;
+ this.CreatedAt = TimeProvider.System.GetUtcNow();
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs
index ea8ab16..38e33b8 100644
--- a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs
+++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs
@@ -5,11 +5,13 @@ public class PostReaction
public Guid AuthorId { get; private set; }
public Guid PostId { get; private set; }
public string Emoji { get; private set; } = null!;
+ public DateTimeOffset CreatedAt { get; private set; }
public PostReaction(Guid authorId, Guid postId, string emoji)
{
this.AuthorId = authorId;
this.PostId = postId;
this.Emoji = emoji;
+ this.CreatedAt = TimeProvider.System.GetUtcNow();
}
private PostReaction() { }
diff --git a/Femto.Modules.Files/Domain/Files/File.cs b/Femto.Modules.Files/Domain/Files/File.cs
deleted file mode 100644
index 9600ceb..0000000
--- a/Femto.Modules.Files/Domain/Files/File.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Femto.Modules.Files.Domain.Files;
-
-public class File
-{
- Guid Id { get; set; }
-
-
-}
\ No newline at end of file
diff --git a/Femto.Modules.Files/Femto.Modules.Files.csproj b/Femto.Modules.Files/Femto.Modules.Files.csproj
deleted file mode 100644
index 17b910f..0000000
--- a/Femto.Modules.Files/Femto.Modules.Files.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net9.0
- enable
- enable
-
-
-