Compare commits

...

27 commits

Author SHA1 Message Date
3de79728a8 v0.1.31 2025-08-10 21:22:27 +02:00
ce3888f1ab comments 2025-08-10 21:21:40 +02:00
cbf67bf5f1 remove leftover directories 2025-08-10 18:49:57 +02:00
31d3de7bf3 v0.1.30 2025-08-10 18:21:15 +02:00
c49267e6c4 fix search param name 2025-08-10 18:20:44 +02:00
e8d5d50ae5 v0.1.29 2025-08-10 18:14:27 +02:00
5379d29c5f refactor post reactions 2025-08-10 18:12:16 +02:00
2519fc77d2 deleting password 2025-07-19 14:10:01 +02:00
36d8cc9a4d v0.1.28 2025-07-19 12:32:46 +02:00
6746a02398 fix session lifespan 2025-06-21 11:42:21 +02:00
8629883f88 remember me 2025-06-21 11:41:53 +02:00
dac3acfecf add remember me to API 2025-06-16 21:24:37 +02:00
84457413b2 refactor 2025-06-16 21:11:40 +02:00
e282e2ece3 v0.1.27 2025-06-15 19:30:12 +02:00
65ba3a6435 change login failure status code 2025-06-15 19:14:49 +02:00
a57515c33e oops 2025-06-15 19:12:34 +02:00
cb75412d19 write some comment 2025-06-11 23:36:30 +02:00
8137a33e24 v0.1.26 2025-06-11 22:51:20 +02:00
8798c8999d change something 2025-06-01 23:38:09 +02:00
120adf806d ocd 2025-06-01 23:35:44 +02:00
c2846aed4d dont use user from user cookie whatsoever!!! 2025-06-01 23:35:33 +02:00
f48b421500 do sessions in memory and also fix glaring security hole 2025-06-01 23:28:00 +02:00
7b6c155a73 wip session auth 2025-05-29 00:39:40 +02:00
aa4394fd21 v0.1.25 2025-05-28 22:08:59 +02:00
8e8e4e2258 post reactions 2025-05-28 22:05:20 +02:00
d1687f276b return postdto from post create 2025-05-28 20:05:00 +02:00
8ad4302ec8 wip 2025-05-27 00:28:16 +02:00
96 changed files with 5498 additions and 690 deletions

View file

@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.1.24</Version>
<Version>0.1.31</Version>
</PropertyGroup>
</Project>

View file

@ -3,11 +3,11 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Interface.ValidateSession;
using Femto.Modules.Auth.Errors;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Extensions;
namespace Femto.Api.Auth;
@ -15,48 +15,115 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAuthModule authModule,
IAuthService authService,
CurrentUserContext currentUserContext
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var sessionId = this.Request.Cookies["session"];
if (string.IsNullOrWhiteSpace(sessionId))
Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
var user = await this.TryAuthenticateWithSession();
if (user is null)
user = await this.TryAuthenticateWithRememberMeToken();
if (user is null)
return AuthenticateResult.NoResult();
try
var claims = new List<Claim>
{
var result = await authModule.Command(new ValidateSessionCommand(sessionId));
new(ClaimTypes.Name, user.Username),
new("sub", user.Id.ToString()),
new("user_id", user.Id.ToString()),
};
var claims = new List<Claim>
{
new(ClaimTypes.Name, result.User.Username),
new("sub", result.User.Id.ToString()),
new("user_id", result.User.Id.ToString()),
};
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
claims.AddRange(
result.User.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))
);
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
currentUserContext.CurrentUser = new CurrentUser(
user.Id,
user.Username,
user.Roles.Contains(Role.SuperUser)
);
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
}
this.Context.SetSession(result.Session, result.User, Logger);
currentUserContext.CurrentUser = new CurrentUser(
result.User.Id,
result.User.Username,
result.Session.SessionId
);
private async Task<UserInfo?> TryAuthenticateWithSession()
{
var sessionId = this.Context.GetSessionId();
return AuthenticateResult.Success(
new AuthenticationTicket(principal, this.Scheme.Name)
);
}
catch (InvalidSessionError)
if (sessionId is null)
{
return AuthenticateResult.Fail("Invalid session");
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<UserInfo?> 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;
}
}

View file

@ -1,95 +1,174 @@
using Femto.Api.Auth;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Application.Interface.CreateSignupCode;
using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
using Femto.Modules.Auth.Application.Interface.Login;
using Femto.Modules.Auth.Application.Interface.RefreshUserSession;
using Femto.Modules.Auth.Application.Interface.Register;
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> cookieSettings,
ICurrentUserContext currentUserContext,
ILogger<AuthController> logger
) : ControllerBase
public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService)
: ControllerBase
{
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
public async Task<ActionResult<LoginResponse>> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken
)
{
var result = await authModule.Command(new LoginCommand(request.Username, request.Password));
HttpContext.SetSession(result.Session, result.User, logger);
return new LoginResponse(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
var result = await authService.AuthenticateUserCredentials(
request.Username,
request.Password,
cancellationToken
);
if (result is null)
return this.BadRequest();
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<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
public async Task<ActionResult<RegisterResponse>> Register(
[FromBody] RegisterRequest request,
CancellationToken cancellationToken
)
{
var result = await authModule.Command(
new RegisterCommand(request.Username, request.Password, request.SignupCode)
var (user, session) = await authService.CreateUserWithCredentials(
request.Username,
request.Password,
request.SignupCode,
cancellationToken
);
HttpContext.SetSession(result.Session, result.User, logger);
HttpContext.SetSession(session, user);
if (request.RememberMe)
{
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
HttpContext.SetRememberMeToken(newRememberMeToken);
}
return new RegisterResponse(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
}
[HttpPost("change-password")]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> DeleteSession()
{
HttpContext.DeleteSession();
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 { });
}
[HttpGet("user/{userId}")]
[Authorize]
public async Task<ActionResult<RefreshUserResult>> RefreshUser(
public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
Guid userId,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
var currentUser = currentUserContext.CurrentUser;
try
{
var result = await authModule.Command(
new RefreshUserSessionCommand(userId, currentUser),
cancellationToken
);
if (currentUser is null || currentUser.Id != userId)
return this.BadRequest();
return new RefreshUserResult(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
);
}
catch (Exception)
{
HttpContext.DeleteSession();
return this.Forbid();
}
var user = await authService.GetUserWithId(userId, cancellationToken);
if (user is null)
return this.BadRequest();
return new GetUserInfoResult(
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
}
[Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task<ActionResult> CreateSignupCode(
@ -97,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<ActionResult<ListSignupCodesResult>> 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<ActionResult> 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<ActionResult<ListSignupCodesResult>> ListSignupCodesV2(
CancellationToken cancellationToken
)
{
var codes = await authService.GetSignupCodes(cancellationToken);
return new ListSignupCodesResult(
codes.Select(c => new SignupCodeDto(

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record ChangePasswordRequestBody(Guid UserId, string NewPassword);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record LoginRequest(string Username, string Password);
public record LoginRequest(string Username, string Password, bool RememberMe);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth;
public record RegisterRequest(string Username, string Password, string SignupCode, string? Email);
public record RegisterRequest(string Username, string Password, string SignupCode, bool RememberMe);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record AddPostCommentRequest(Guid AuthorId, string Content);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record AddPostReactionRequest(string Emoji);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record CreatePostResponse(Guid PostId);
public record CreatePostResponse(PostDto Post);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record DeletePostReactionRequest(string Emoji);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record GetPostResponse(PostDto Post);

View file

@ -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);
public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author);

View file

@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
public record GetAllPublicPostsResponse(IEnumerable<PostDto> Posts, Guid? Next);
public record LoadPostsResponse(IEnumerable<PostDto> Posts);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);

View file

@ -8,5 +8,21 @@ public record PostDto(
Guid PostId,
string Content,
IEnumerable<PostMediaDto> Media,
DateTimeOffset CreatedAt
);
IEnumerable<PostReactionDto> Reactions,
DateTimeOffset CreatedAt,
IEnumerable<string> PossibleReactions,
IEnumerable<PostCommentDto> 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))
);
}

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);

View file

@ -1,6 +1,9 @@
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;
using Femto.Modules.Blog.Application.Commands.DeletePost;
using Femto.Modules.Blog.Application.Queries.GetPosts;
@ -11,10 +14,11 @@ namespace Femto.Api.Controllers.Posts;
[ApiController]
[Route("posts")]
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
: ControllerBase
{
[HttpGet]
public async Task<ActionResult<GetAllPublicPostsResponse>> LoadPosts(
public async Task<ActionResult<LoadPostsResponse>> LoadPosts(
[FromQuery] GetPublicPostsSearchParams searchParams,
CancellationToken cancellationToken
)
@ -22,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,
@ -30,16 +34,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
return new GetAllPublicPostsResponse(
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.CreatedAt
)),
res.Next
);
return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
}
[HttpPost]
@ -49,7 +44,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
CancellationToken cancellationToken
)
{
var guid = await blogModule.Command(
var post = await blogModule.Command(
new CreatePostCommand(
req.AuthorId,
req.Content,
@ -64,18 +59,96 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
media.Height
)
),
req.IsPublic
req.IsPublic,
currentUserContext.CurrentUser!
),
cancellationToken
);
return new CreatePostResponse(guid);
return new CreatePostResponse(PostDto.FromModel(post));
}
[HttpGet("{postId}")]
public async Task<ActionResult<GetPostResponse>> 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}")]
[Authorize]
public async Task DeletePost(Guid postId, CancellationToken cancellationToken)
{
await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), cancellationToken);
await blogModule.Command(
new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id),
cancellationToken
);
}
[HttpPost("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> AddPostReaction(
Guid postId,
[FromBody] AddPostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(
new AddPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
[HttpDelete("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> DeletePostReaction(
Guid postId,
[FromBody] DeletePostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(
new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
[HttpPost("{postId}/comments")]
[Authorize]
public async Task<ActionResult> 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();
}
}

View file

@ -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<CurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());

View file

@ -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"
}

View file

@ -1,60 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions;
internal record SessionInfo(string? SessionId, Guid? UserId);
internal static class HttpContextSessionExtensions
{
public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger)
private static readonly JsonSerializerOptions JsonOptions = new()
{
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>();
var secure = cookieSettings?.Value.Secure ?? true;
var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict;
var domain = cookieSettings?.Value.Domain;
var expires = session.Expires;
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
logger.LogInformation(
"cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}",
secure,
sameSite,
domain,
expires
);
public static string? GetSessionId(this HttpContext httpContext) =>
httpContext.Request.Cookies["sid"];
httpContext.Response.Cookies.Append(
"session",
session.SessionId,
public static void SetSession(this HttpContext context, Session session, UserInfo user)
{
var cookieSettings = context.RequestServices.GetRequiredService<IOptions<CookieSettings>>();
context.Response.Cookies.Append(
"sid",
session.Id,
new CookieOptions
{
Path = "/",
IsEssential = true,
Domain = domain,
Domain = cookieSettings.Value.Domain,
HttpOnly = true,
Secure = secure,
SameSite = sameSite,
Expires = expires,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires,
}
);
httpContext.Response.Cookies.Append(
context.Response.Cookies.Append(
"user",
JsonSerializer.Serialize(
user,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
}
),
JsonSerializer.Serialize(user, JsonOptions),
new CookieOptions
{
Domain = domain,
Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = secure,
SameSite = sameSite,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires,
}
);
@ -62,28 +54,78 @@ internal static class HttpContextSessionExtensions
public static void DeleteSession(this HttpContext httpContext)
{
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>();
var secure = cookieSettings?.Value.Secure ?? true;
var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified;
var domain = cookieSettings?.Value.Domain;
httpContext.Response.Cookies.Delete("session", new CookieOptions
{
HttpOnly = true,
Domain = domain,
IsEssential = true,
Secure = secure,
SameSite = sameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
});
httpContext.Response.Cookies.Delete("user", new CookieOptions
{
Domain = domain,
IsEssential = true,
Secure = secure,
SameSite = sameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
});
var cookieSettings = httpContext.RequestServices.GetRequiredService<
IOptions<CookieSettings>
>();
httpContext.Response.Cookies.Delete(
"sid",
new CookieOptions
{
Path = "/",
HttpOnly = true,
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
}
);
httpContext.Response.Cookies.Delete(
"user",
new CookieOptions
{
Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
}
);
}
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<IOptions<CookieSettings>>();
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<IOptions<CookieSettings>>();
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),
}
);
}
}

View file

@ -5,4 +5,4 @@ public interface ICurrentUserContext
CurrentUser? CurrentUser { get; }
}
public record CurrentUser(Guid Id, string Username, string SessionId);
public record CurrentUser(Guid Id, string Username, bool IsSuperUser);

View file

@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common.Infrastructure;
public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
public class DDDPipelineBehaviour<TRequest, TResponse>(
DbContext context,
IPublisher publisher,
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
ILogger<DDDPipelineBehaviour<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
@ -18,7 +18,12 @@ public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
CancellationToken cancellationToken
)
{
logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken);
var hasChanges = context.ChangeTracker.HasChanges();
logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
if (context.ChangeTracker.HasChanges())
{
await context.EmitDomainEvents(logger, publisher, cancellationToken);

View file

@ -12,7 +12,7 @@ public static class DomainServiceExtensions
services.AddScoped<DbContext>(s => s.GetRequiredService<TContext>());
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(SaveChangesPipelineBehaviour<,>)
typeof(DDDPipelineBehaviour<,>)
);
}

View file

@ -3,19 +3,24 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common;
/// <summary>
/// We use this to bind a scope to the request scope in the composition root
/// Any scoped services provided by this subcontainer should be accessed via a ScopeBinding injected in the host
/// </summary>
/// <param name="scope"></param>
public class ScopeBinding<T>(IServiceScope scope) : IDisposable
where T : notnull
public class ScopeBinding(IServiceScope scope) : IDisposable
{
public T GetService() {
return scope.ServiceProvider.GetRequiredService<T>();
private IServiceScope Scope { get; } = scope;
public T GetService<T>()
where T : notnull
{
return this.Scope.ServiceProvider.GetRequiredService<T>();
}
public void Dispose() {
scope.Dispose();
public virtual void Dispose()
{
this.Scope.Dispose();
}
}

View file

@ -6,4 +6,4 @@ CREATE TABLE authn.user_role
user_id uuid REFERENCES authn.user_identity(id),
role int,
primary key (user_id, role)
);
)

View file

@ -0,0 +1,13 @@
-- Migration: AddReactions
-- Created at: 26/05/2025 22:00:32
ALTER TABLE blog.post
ADD COLUMN possible_reactions TEXT;
CREATE TABLE blog.post_reaction
(
post_id uuid REFERENCES blog.post(id),
author_id uuid REFERENCES blog.author(id),
emoji text not null,
primary key (post_id, author_id, emoji)
);

View file

@ -0,0 +1,13 @@
-- Migration: addLongTermSessions
-- Created at: 29/05/2025 10:13:46
DROP TABLE authn.user_session;
CREATE TABLE authn.long_term_session
(
id serial PRIMARY KEY,
selector varchar(16) NOT NULL,
hashed_verifier bytea NOT NULL,
expires timestamptz not null,
user_id uuid REFERENCES authn.user_identity (id)
);

View file

@ -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;

View file

@ -0,0 +1,4 @@
-- Migration: AddTimestampToReaction
-- Created at: 10/08/2025 15:21:32
alter table blog.post_reaction
add column created_at timestamptz;

View file

@ -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()
)

View file

@ -35,14 +35,15 @@ public static class TestDataSeeder
;
INSERT INTO blog.post
(id, author_id, content)
(id, author_id, possible_reactions, content)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id,'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id,'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('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
@ -54,6 +55,21 @@ public static class TestDataSeeder
('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0)
;
INSERT INTO blog.post_reaction
(post_id, author_id, emoji)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '🍆'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '🍆'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🧑🏾'),
('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

View file

@ -0,0 +1,27 @@
# Remember me
We want to implement long lived sessions
we will do this with a remember me cookie
this should be implemented as so:
logging or registering and including a "rememberMe" flag with the request will generate a new remember me token, which can be stored as a cookie .
the remember me token should live until:
* the user changes password anywhere
* the user logs out on that device
* the user logs in with an expired session, in which case the remember me token will be used to refresh the session, and then it will be swapped out for a new one
that means we need to implement three spots:
- [ ] login
- [ ] register
- [ ] validate session
we will implement it as described [here](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence)
we will only check the remember me token in "validate session".
"refresh session" is only called with valid sessions so we do not need to check it here, as the session should already have been validated

View file

@ -0,0 +1,16 @@
# Strong vs weak sessions
a **strong** session is one that should have the power to do account level admin tasks like change password
a **weak** session has strictly fewer privileges than a strong session
## where to get a strong session
a strong session is created when a user provides a username and a password. a session remains strong until it is refreshed, at which point it becomes weak.
## where to get a weak session
A weak session is any session that has not been directly created by user credentials, i.e.:
* short-term session refresh
* long-term session refresh

View file

@ -1,22 +0,0 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Auth.Application;
internal class AuthModule(IMediator mediator) : IAuthModule
{
public async Task Command(ICommand command, CancellationToken cancellationToken = default) =>
await mediator.Send(command, cancellationToken);
public async Task<TResponse> Command<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default
) => await mediator.Send(command, cancellationToken);
public async Task<TResponse> Query<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default
) => await mediator.Send(query, cancellationToken);
}

View file

@ -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<UserAndSession?> 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<UserInfo?> 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<Session> CreateNewSession(Guid userId)
{
var session = new Session(userId, true);
await sessionStorage.AddSession(session);
return session;
}
public async Task<Session> CreateWeakSession(Guid userId)
{
var session = new Session(userId, false);
await sessionStorage.AddSession(session);
return session;
}
public Task<Session?> GetSession(string sessionId)
{
return sessionStorage.GetSession(sessionId);
}
public async Task DeleteSession(string sessionId)
{
await sessionStorage.DeleteSession(sessionId);
}
public async Task<UserAndSession> 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<ICollection<SignupCodeDto>> 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<GetSignupCodesQueryResultRow>(sql, cancellationToken);
return result
.Select(row => new SignupCodeDto(
row.Code,
row.Email,
row.Name,
row.RedeemedByUserId,
row.RedeemedByUsername,
row.ExpiresOn
))
.ToList();
}
public async Task<NewRememberMeToken> 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; }
}
}

View file

@ -20,20 +20,29 @@ 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();
rootContainer.AddScoped(_ => new ScopeBinding<IAuthModule>(host.Services.CreateScope()));
rootContainer.AddScoped(services =>
services.GetRequiredService<ScopeBinding<IAuthModule>>().GetService()
rootContainer.AddKeyedScoped<ScopeBinding>(
"AuthServiceScope",
(s, o) =>
{
var scope = host.Services.CreateScope();
return new ScopeBinding(scope);
}
);
rootContainer.ExposeScopedService<IAuthService>();
rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@ -44,9 +53,12 @@ public static class AuthStartup
IServiceCollection services,
string connectionString,
IEventPublisher publisher,
ILoggerFactory loggerFactory
ILoggerFactory loggerFactory,
TimeProvider timeProvider
)
{
services.AddSingleton(timeProvider);
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<AuthContext>(builder =>
@ -66,7 +78,7 @@ public static class AuthStartup
{
options.WaitForJobsToComplete = true;
});
// #endif
services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@ -74,8 +86,11 @@ public static class AuthStartup
services.ConfigureDomainServices<AuthContext>();
services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>();
services.AddScoped<IAuthModule, AuthModule>();
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
services.AddScoped<IAuthService, AuthService>();
}
private static async Task EventSubscriber(
@ -107,3 +122,14 @@ public static class AuthStartup
}
}
}
internal static class AuthServiceCollectionExtensions
{
public static void ExposeScopedService<T>(this IServiceCollection container)
where T : class
{
container.AddScoped<T>(services =>
services.GetRequiredKeyedService<ScopeBinding>("AuthServiceScope").GetService<T>()
);
}
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
public record LoginResult(Session Session, UserInfo User);
public record LoginResult(SessionDto SessionDto, UserInfo User);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
public record RefreshUserSessionResult(Session Session, UserInfo User);
public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
public record RegisterResult(Session Session, UserInfo User);
public record RegisterResult(SessionDto SessionDto, UserInfo User);

View file

@ -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}";
}

View file

@ -2,9 +2,16 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto;
public record Session(string SessionId, DateTimeOffset Expires)
public record SessionDto(
string SessionId,
DateTimeOffset Expires,
bool Weak,
string? RememberMe = null
)
{
internal Session(UserSession session) : this(session.Id, session.Expires)
{
}
}
internal SessionDto(Session session)
: this(session.Id, session.Expires, !session.IsStronglyAuthenticated) { }
internal SessionDto(Session session, string? rememberMe)
: this(session.Id, session.Expires, !session.IsStronglyAuthenticated, rememberMe) { }
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
public record ValidateSessionResult(Session Session, UserInfo User);
public record ValidateSessionResult(SessionDto SessionDto);

View file

@ -1,10 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Application;
public interface IAuthModule
{
Task Command(ICommand command, CancellationToken cancellationToken = default);
Task<TResponse> Command<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default);
Task<TResponse> Query<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default);
}

View file

@ -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<UserAndSession?> AuthenticateUserCredentials(
string username,
string password,
CancellationToken cancellationToken = default
);
public Task<UserInfo?> GetUserWithId(
Guid? userId,
CancellationToken cancellationToken = default
);
public Task<Session> CreateNewSession(Guid userId);
public Task<Session> CreateWeakSession(Guid userId);
public Task<Session?> GetSession(string sessionId);
public Task DeleteSession(string sessionId);
public Task<UserAndSession> CreateUserWithCredentials(string username,
string password,
string signupCode,
CancellationToken cancellationToken = default);
public Task AddSignupCode(
string code,
string recipientName,
CancellationToken cancellationToken = default
);
public Task<ICollection<SignupCodeDto>> GetSignupCodes(
CancellationToken cancellationToken = default
);
Task<NewRememberMeToken> 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);

View file

@ -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;

View file

@ -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<CreateSignupCodeCommand>
{
public async Task Handle(CreateSignupCodeCommand command, CancellationToken cancellationToken)
{
var code = new SignupCode(command.RecipientEmail, command.RecipientName, command.Code);
await context.SignupCodes.AddAsync(code, cancellationToken);
}
}

View file

@ -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<ICollection<SignupCodeDto>>;

View file

@ -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<GetSignupCodesQuery, ICollection<SignupCodeDto>>
{
public async Task<ICollection<SignupCodeDto>> 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<QueryResultRow>(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; }
}
}

View file

@ -1,6 +0,0 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.Login;
public record LoginCommand(string Username, string Password) : ICommand<LoginResult>;

View file

@ -1,28 +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.Login;
internal class LoginCommandHandler(AuthContext context)
: ICommandHandler<LoginCommand, LoginResult>
{
public async Task<LoginResult> Handle(LoginCommand request, CancellationToken cancellationToken)
{
var user = await context.Users.SingleOrDefaultAsync(
u => u.Username == request.Username,
cancellationToken
);
if (user is null)
throw new DomainError("invalid credentials");
if (!user.HasPassword(request.Password))
throw new DomainError("invalid credentials");
var session = user.StartNewSession();
return new(new Session(session.Id, session.Expires), new UserInfo(user));
}
}

View file

@ -1,7 +0,0 @@
using Femto.Common;
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
public record RefreshUserSessionCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand<RefreshUserSessionResult>;

View file

@ -1,32 +0,0 @@
using Femto.Common.Domain;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
internal class RefreshUserSessionCommandHandler(AuthContext context)
: ICommandHandler<RefreshUserSessionCommand, RefreshUserSessionResult>
{
public async Task<RefreshUserSessionResult> Handle(
RefreshUserSessionCommand request,
CancellationToken cancellationToken
)
{
if (request.CurrentUser.Id != request.ForUser)
throw new DomainError("invalid request");
var user = await context.Users.SingleOrDefaultAsync(
u => u.Id == request.ForUser,
cancellationToken
);
if (user is null)
throw new DomainError("invalid request");
var session = user.PossiblyRefreshSession(request.CurrentUser.SessionId);
return new(new Session(session), new UserInfo(user));
}
}

View file

@ -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<RegisterResult>;

View file

@ -1,35 +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<RegisterCommand, RegisterResult>
{
public async Task<RegisterResult> 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);
var session = user.StartNewSession();
await context.AddAsync(user, cancellationToken);
code.Redeem(user.Id);
return new(new Session(session.Id, session.Expires), new UserInfo(user));
}
}

View file

@ -1,10 +0,0 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
/// <summary>
/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future
/// </summary>
/// <param name="SessionId"></param>
public record ValidateSessionCommand(string SessionId) : ICommand<ValidateSessionResult>;

View file

@ -1,34 +0,0 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Errors;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
internal class ValidateSessionCommandHandler(AuthContext context)
: ICommandHandler<ValidateSessionCommand, ValidateSessionResult>
{
public async Task<ValidateSessionResult> Handle(
ValidateSessionCommand request,
CancellationToken cancellationToken
)
{
var now = DateTimeOffset.UtcNow;
var user = await context.Users.SingleOrDefaultAsync(
u => u.Sessions.Any(s => s.Id == request.SessionId && s.Expires > now),
cancellationToken
);
if (user is null)
throw new InvalidSessionError();
var session = user.PossiblyRefreshSession(request.SessionId);
return new ValidateSessionResult(
new Session(session.Id, session.Expires),
new UserInfo(user)
);
}
}

View file

@ -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;
@ -8,6 +12,7 @@ internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(op
{
public virtual DbSet<UserIdentity> Users { get; set; }
public virtual DbSet<SignupCode> SignupCodes { get; set; }
public virtual DbSet<LongTermSession> LongTermSessions { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
@ -16,4 +21,43 @@ internal class AuthContext(DbContextOptions<AuthContext> 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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
await EmitDomainEvents(cancellationToken);
return await base.SaveChangesAsync(cancellationToken);
}
private async Task EmitDomainEvents(CancellationToken cancellationToken)
{
var logger = this.GetService<ILogger<AuthContext>>();
var publisher = this.GetService<IPublisher>();
var domainEvents = this
.ChangeTracker.Entries<Entity>()
.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);
}
}
}

View file

@ -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<LongTermSession>
{
public void Configure(EntityTypeBuilder<LongTermSession> builder)
{
builder.ToTable("long_term_session");
}
}

View file

@ -19,8 +19,6 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIden
}
);
builder.OwnsMany(u => u.Sessions).WithOwner().HasForeignKey("user_id");
builder
.OwnsMany(u => u.Roles, entity =>
{

View file

@ -0,0 +1,23 @@
using Femto.Modules.Auth.Data;
using MediatR;
namespace Femto.Modules.Auth.Infrastructure;
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(AuthContext context)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next(cancellationToken);
if (context.ChangeTracker.HasChanges())
await context.SaveChangesAsync(cancellationToken);
return response;
}
}

View file

@ -0,0 +1,56 @@
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(TimeProvider timeProvider)
{
private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions());
public async Task<Session?> GetSession(string id)
{
var session = this._storage.Get<Session>($"session:{id}");
if (session is null)
return null;
var invalidUntil = this._storage.Get<DateTimeOffset?>(
$"user:invalid_until:{session.UserId}"
);
if (invalidUntil is not null && invalidUntil > session.Expires)
return null;
return session;
}
public Task AddSession(Session session)
{
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($"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;
}
}

View file

@ -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<UserWasCreatedEvent>
{
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();
}
}
}

View file

@ -0,0 +1,5 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Models.Events;
internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;

View file

@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using static System.Security.Cryptography.RandomNumberGenerator;
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; }
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 verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32);
using var sha256 = System.Security.Cryptography.SHA256.Create();
var longTermSession = new LongTermSession
{
Selector = selector,
HashedVerifier = ComputeHash(verifier),
UserId = userId,
Expires = DateTimeOffset.UtcNow + TokenTimeout,
};
return (longTermSession, verifier);
}
public bool CheckVerifier(string verifier)
{
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;
}
public void Invalidate()
{
this.IsInvalidated = true;
}
}

View file

@ -0,0 +1,16 @@
using static System.Security.Cryptography.RandomNumberGenerator;
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 + ValidityPeriod;
public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
public bool IsStronglyAuthenticated { get; } = isStrong;
public bool IsExpired => this.Expires < DateTimeOffset.UtcNow;
}

View file

@ -1,9 +1,6 @@
using System.Text;
using System.Text.Unicode;
using Femto.Common.Domain;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events;
using Geralt;
namespace Femto.Modules.Auth.Models;
@ -15,8 +12,6 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; }
public ICollection<UserSession> Sessions { get; private set; } = [];
public ICollection<UserRole> Roles { get; private set; } = [];
private UserIdentity() { }
@ -31,14 +26,10 @@ internal class UserIdentity : Entity
this.AddDomainEvent(new UserWasCreatedEvent(this));
}
public UserIdentity WithPassword(string password)
{
this.SetPassword(password);
return this;
}
public void SetPassword(string password)
{
if (this.Password is not null)
this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
this.Password = new Password(password);
}
@ -51,25 +42,6 @@ internal class UserIdentity : Entity
return this.Password.Check(requestPassword);
}
public UserSession PossiblyRefreshSession(string sessionId)
{
var session = this.Sessions.Single(s => s.Id == sessionId);
if (session.ExpiresSoon)
return this.StartNewSession();
return session;
}
public UserSession StartNewSession()
{
var session = UserSession.Create();
this.Sessions.Add(session);
return session;
}
}
public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);

View file

@ -1,21 +0,0 @@
namespace Femto.Modules.Auth.Models;
internal class UserSession
{
private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30);
private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5);
public string Id { get; private set; }
public DateTimeOffset Expires { get; private set; }
public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer;
private UserSession() {}
public static UserSession Create()
{
return new()
{
Id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)),
Expires = DateTimeOffset.UtcNow + SessionTimeout
};
}
}

View file

@ -1,5 +0,0 @@
namespace Femto.Modules.Blog.Data;
public class Class1
{
}

View file

@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.EntityFrameworkCore">
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -35,9 +35,13 @@ public static class BlogStartup
rootContainer.AddHostedService(_ => new BlogApplication(host));
rootContainer.AddScoped(_ => new ScopeBinding<IBlogModule>(host.Services.CreateScope()));
rootContainer.AddKeyedScoped<ScopeBinding>(
"BlogService",
(_, o) => new ScopeBinding(host.Services.CreateScope())
);
rootContainer.AddScoped(services =>
services.GetRequiredService<ScopeBinding<IBlogModule>>().GetService()
services.GetRequiredKeyedService<ScopeBinding>("BlogService").GetService<IBlogModule>()
);
bus.Subscribe(

View file

@ -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;

View file

@ -0,0 +1,20 @@
using Femto.Common.Domain;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler<AddPostCommentCommand>
{
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);
}
}

View file

@ -0,0 +1,6 @@
using Femto.Common;
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.AddPostReaction;
public record AddPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId) : ICommand;

View file

@ -0,0 +1,21 @@
using Femto.Common.Domain;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Application.Commands.AddPostReaction;
internal class AddPostReactionCommandHandler(BlogContext context)
: ICommandHandler<AddPostReactionCommand>
{
public async Task Handle(AddPostReactionCommand request, CancellationToken cancellationToken)
{
var post = await context.Posts.SingleOrDefaultAsync(
p => p.Id == request.PostId,
cancellationToken
);
if (post is null)
return;
post.AddReaction(request.ReactorId, request.Emoji);
}
}

View file

@ -0,0 +1,6 @@
using Femto.Common;
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction;
public record ClearPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId): ICommand;

View file

@ -0,0 +1,22 @@
using Femto.Common.Domain;
using Femto.Modules.Blog.Application.Commands.AddPostReaction;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction;
internal class ClearPostReactionCommandHandler(BlogContext context)
: ICommandHandler<ClearPostReactionCommand>
{
public async Task Handle(ClearPostReactionCommand request, CancellationToken cancellationToken)
{
var post = await context.Posts.SingleOrDefaultAsync(
p => p.Id == request.PostId,
cancellationToken
);
if (post is null)
return;
post.RemoveReaction(request.ReactorId, request.Emoji);
}
}

View file

@ -1,8 +1,22 @@
using Femto.Common;
using Femto.Common.Domain;
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<CreatePostMedia> Media, bool? IsPublic)
: ICommand<Guid>;
public record CreatePostCommand(
Guid AuthorId,
string Content,
IEnumerable<CreatePostMedia> Media,
bool? IsPublic,
CurrentUser CurrentUser
) : ICommand<PostDto>;
public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height);
public record CreatePostMedia(
Guid MediaId,
Uri Url,
string? Type,
int Order,
int? Width,
int? Height
);

View file

@ -1,12 +1,16 @@
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
using Femto.Modules.Blog.Domain.Posts;
using MediatR;
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
internal class CreatePostCommandHandler(BlogContext context)
: IRequestHandler<CreatePostCommand, Guid>
: IRequestHandler<CreatePostCommand, PostDto>
{
public async Task<Guid> Handle(CreatePostCommand request, CancellationToken cancellationToken)
public async Task<PostDto> Handle(
CreatePostCommand request,
CancellationToken cancellationToken
)
{
var post = new Post(
request.AuthorId,
@ -20,13 +24,21 @@ internal class CreatePostCommandHandler(BlogContext context)
media.Width,
media.Height
))
.ToList()
.ToList(),
request.IsPublic is true
);
post.IsPublic = request.IsPublic is true;
await context.AddAsync(post, cancellationToken);
return post.Id;
return new PostDto(
post.Id,
post.Content,
post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)).ToList(),
post.PostedOn,
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username),
[],
post.PossibleReactions,
[]
);
}
}

View file

@ -10,5 +10,24 @@ internal class PostConfiguration : IEntityTypeConfiguration<Post>
{
table.ToTable("post");
table.OwnsMany(post => post.Media).WithOwner();
table.OwnsMany(
post => post.Reactions,
reactions =>
{
reactions.WithOwner().HasForeignKey(r => r.PostId);
reactions.HasKey(r => new
{
r.PostId,
r.AuthorId,
r.Emoji,
});
}
);
table.OwnsMany(p => p.Comments).WithOwner();
table.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions");
table.Ignore(e => e.PossibleReactions);
}
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);
public record GetPostsQueryResult(IList<PostDto> Posts);

View file

@ -0,0 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);

View file

@ -1,3 +1,12 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostDto(Guid PostId, string Text, IList<PostMediaDto> Media, DateTimeOffset CreatedAt, PostAuthorDto Author);
public record PostDto(
Guid PostId,
string Text,
IList<PostMediaDto> Media,
DateTimeOffset CreatedAt,
PostAuthorDto Author,
IList<PostReactionDto> Reactions,
IEnumerable<string> PossibleReactions,
IList<PostCommentDto> Comments
);

View file

@ -0,0 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);

View file

@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
/// <summary>
/// Get posts in reverse chronological order
/// </summary>
/// <param name="CurrentUserId"></param>
public record GetPostsQuery(Guid? CurrentUserId) : IQuery<GetPostsQueryResult>
{
public Guid? From { get; init; }
/// <summary>
/// Id of the specific post to load. If specified, After and Amount are ignored
/// </summary>
public Guid? PostId { get; }
/// <summary>
/// If specified, loads posts from after the given Id. Used for paging
/// </summary>
public Guid? After { get; init; }
public int Amount { get; init; } = 20;
public Guid? AuthorId { get; init; }
public string? Author { get; init; }
/// <summary>
/// Default is to load in reverse chronological order
/// TODO this is not exposed on the client as it probably wouldn't work that well
/// </summary>
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
}
public enum GetPostsDirection
{
Forward,
Backward,
}
public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
{
this.PostId = postId;
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Dapper;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
@ -15,101 +16,158 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
{
using var conn = connectionFactory.GetConnection();
var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc";
var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">=";
var username = query.Author;
var authorGuid = query.AuthorId;
var cursor = query.From;
var cursor = query.After;
var showPrivate = query.CurrentUserId is not null;
// lang=sql
var sql = $$"""
with page as (
select blog.post.*, blog.author.username as Username, blog.author.id as AuthorId
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 (@showPrivate or blog.post.is_public = true)
and (@authorGuid is null or blog.author.id = @authorGuid)
and (@cursor is null or blog.post.id {{pageFilter}} @cursor)
order by blog.post.id {{orderBy}}
limit @amount
)
select
page.id as PostId,
page.content as Content,
blog.post_media.url as MediaUrl,
blog.post_media.width as MediaWidth,
blog.post_media.height as MediaHeight,
page.posted_on as PostedOn,
page.Username,
page.AuthorId
from page
left join blog.post_media on blog.post_media.post_id = page.id
order by page.id {{orderBy}}
""";
var result = await conn.QueryAsync<QueryResult>(
sql,
var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
"""
select
blog.post.id as PostId,
blog.post.content as Content,
blog.post.posted_on as PostedOn,
blog.author.username as Username,
blog.author.id as AuthorId,
blog.post.possible_reactions as PossibleReactions
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)
order by blog.post.id desc
limit @amount
""",
new
{
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 rows = result.ToList();
var posts = loadPostsResult.ToList();
var posts = rows.GroupBy(row => row.PostId)
.Select(group =>
{
var postId = group.Key;
var post = group.First();
var media = group
.Select(row =>
{
if (row.MediaUrl is not null)
{
return new PostMediaDto(
new Uri(row.MediaUrl),
row.MediaHeight,
row.MediaHeight
);
}
else
return null;
})
.OfType<PostMediaDto>()
.ToList();
return new PostDto(
postId,
post.Content,
media,
post.PostedOn,
new PostAuthorDto(post.AuthorId, post.Username)
);
})
.ToList();
var postIds = posts.Select(p => p.PostId).ToList();
var next = rows.Count >= query.Amount ? rows.LastOrDefault()?.PostId : null;
var loadMediaResult = await conn.QueryAsync<LoadMediaRow>(
"""
select
pm.url as MediaUrl,
pm.type as MediaType,
pm.width as MediaWidth,
pm.height as MediaHeight,
pm.post_id as PostId
from blog.post_media pm where pm.post_id = ANY (@postIds)
order by pm.ordering
""",
new { postIds }
);
return new GetPostsQueryResult(posts, next);
var media = loadMediaResult.ToList();
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
"""
select
pr.post_id as PostId,
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 reactions = loadReactionsResult.ToList();
var loadCommentsResult = await conn.QueryAsync<LoadCommentRow>(
"""
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,
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),
reactions
.Where(r => r.PostId == p.PostId)
.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
.ToList(),
!string.IsNullOrEmpty(p.PossibleReactions)
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
: [],
comments
.Where(c => c.PostId == p.PostId)
.Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn))
.ToList()
))
.ToList()
);
}
internal class QueryResult
internal record LoadPostRow
{
public Guid PostId { get; set; }
public string Content { get; set; }
public string? MediaUrl { get; set; }
public string? MediaType { get; set; }
public int? MediaWidth { get; set; }
public int? MediaHeight { get; set; }
public DateTimeOffset PostedOn { get; set; }
public Guid AuthorId { get; set; }
public string Username { get; set; }
public Guid PostId { get; init; }
public string Content { get; init; }
public DateTimeOffset PostedOn { get; init; }
public string Username { get; init; }
public Guid AuthorId { get; init; }
public string? PossibleReactions { get; init; }
}
internal record LoadMediaRow
{
public string MediaUrl { get; init; }
public string? MediaType { get; init; }
public int? MediaWidth { get; init; }
public int? MediaHeight { get; init; }
public Guid PostId { get; init; }
}
internal record LoadReactionRow
{
public Guid PostId { 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; }
}
}

View file

@ -1,5 +1,7 @@
using System.Text.Json;
using Femto.Common.Domain;
using Femto.Modules.Blog.Domain.Posts.Events;
using Femto.Modules.Blog.Emoji;
namespace Femto.Modules.Blog.Domain.Posts;
@ -9,17 +11,62 @@ internal class Post : Entity
public Guid AuthorId { get; private set; }
public string Content { get; private set; } = null!;
public IList<PostMedia> Media { get; private set; }
public bool IsPublic { get; set; }
public IList<PostReaction> Reactions { get; private set; } = [];
public IList<PostComment> Comments { get; private set; } = [];
public bool IsPublic { get; private set; }
public DateTimeOffset PostedOn { get; private set; }
private string PossibleReactionsJson { get; set; } = null!;
public IEnumerable<string> PossibleReactions
{
get => JsonSerializer.Deserialize<IEnumerable<string>>(this.PossibleReactionsJson)!;
init => PossibleReactionsJson = JsonSerializer.Serialize(value);
}
private Post() { }
public Post(Guid authorId, string content, IList<PostMedia> media)
public Post(Guid authorId, string content, IList<PostMedia> media, bool isPublic)
{
this.Id = Guid.CreateVersion7();
this.AuthorId = authorId;
this.Content = content;
this.Media = media;
this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
this.PostedOn = DateTimeOffset.UtcNow;
this.IsPublic = isPublic;
this.AddDomainEvent(new PostCreated(this));
}
public void AddReaction(Guid reactorId, string emoji)
{
if (!this.PossibleReactions.Contains(emoji))
return;
if (this.Reactions.Any(r => r.AuthorId == reactorId && r.Emoji == emoji))
return;
this.Reactions.Add(new PostReaction(reactorId, this.Id, emoji));
}
public void RemoveReaction(Guid reactorId, string emoji)
{
this.Reactions = this
.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));
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,18 @@
namespace Femto.Modules.Blog.Domain.Posts;
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() { }
}

View file

@ -0,0 +1,18 @@
namespace Femto.Modules.Blog.Emoji;
internal static partial class AllEmoji
{
public static IList<string> GetRandomEmoji(int count) => new Random().TakeRandomly(Emojis).Distinct().Take(count).ToList();
}
internal static class RandomExtensions
{
public static IEnumerable<T> TakeRandomly<T>(this Random rand, ICollection<T> collection)
{
while (true)
{
var idx = rand.Next(collection.Count);
yield return collection.ElementAt(idx);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
namespace Femto.Modules.Files.Domain.Files;
public class File
{
Guid Id { get; set; }
}

View file

@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,38 @@
try {
console.error('Downloading emoji-test.txt...');
const response = await fetch('https://unicode.org/Public/emoji/latest/emoji-test.txt');
const text = await response.text();
const emojis = extractEmojis(text);
console.error(`Extracted ${emojis.length} fully-qualified emojis.`);
console.log(`
namespace Femto.Modules.Blog.Emoji;
internal static partial class AllEmoji
{
public static readonly string[] Emojis = [\n${emojis.map(e => `"${e}"`).join(',\n')}\n];
}
`)
} catch (err) {
console.error('Error:', err);
}
function extractEmojis(text) {
const lines = text.split('\n');
const emojis = [];
for (const line of lines) {
if (line.startsWith('#') || line.trim() === '') continue;
const [codePart, descPart] = line.split(';');
if (!descPart || !descPart.includes('fully-qualified')) continue;
const match = line.match(/#\s+(.+?)\s+E\d+\.\d+/);
if (match) {
emojis.push(match[1]);
}
}
return emojis;
}