Compare commits

...

17 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
69 changed files with 1057 additions and 587 deletions

View file

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

View file

@ -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<AuthenticateResult> 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<Claim>
{
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<AuthenticateResult> FailAndDeleteSession(string sessionId)
private async Task<UserInfo?> 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<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,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> cookieSettings,
ICurrentUserContext currentUserContext,
ILogger<AuthController> logger,
IAuthService authService
) : ControllerBase
public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService)
: ControllerBase
{
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> 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)
return Forbid();
var session = await authService.CreateStrongSession(user.Id);
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 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<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()
{
@ -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<ActionResult> 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<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

@ -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 +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 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 LoadPostsResponse(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

@ -10,5 +10,19 @@ public record PostDto(
IEnumerable<PostMediaDto> Media,
IEnumerable<PostReactionDto> Reactions,
DateTimeOffset CreatedAt,
IEnumerable<string> PossibleReactions
);
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

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

View file

@ -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<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}")]
@ -100,24 +99,56 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
[HttpPost("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken)
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);
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)
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);
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

@ -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<CookieSettings>
>();
var cookieSettings = context.RequestServices.GetRequiredService<IOptions<CookieSettings>>();
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<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);
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
{

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

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

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

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

@ -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<IAuthModule>();
rootContainer.ExposeScopedService<IAuthService>();
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<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<AuthContext>(builder =>
@ -86,7 +88,8 @@ public static class AuthStartup
services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>();
services.AddScoped<IAuthModule, AuthModule>();
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
services.AddScoped<IAuthService, AuthService>();
}

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

@ -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;
namespace Femto.Modules.Auth.Application.Interface.Deauthenticate;
public record DeauthenticateCommand(Guid UserId, string SessionId, string? RememberMeToken) : ICommand;

View file

@ -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<DeauthenticateCommand>
{
public async Task Handle(DeauthenticateCommand request, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}

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.GetUserInfo;
public record GetUserInfoCommand(Guid ForUser) : ICommand<UserInfo?>;

View file

@ -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<GetUserInfoCommand, UserInfo?>
{
public async Task<UserInfo?> 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);
}
}

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

View file

@ -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<RegisterCommand, UserInfo>
{
public async Task<UserInfo> 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);
}
}

View file

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

@ -1,79 +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<UserInfo?> GetUserWithCredentials(
string username,
string password,
CancellationToken cancellationToken = default
)
{
return await context
.Users.Where(u => u.Username == username)
.Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList()))
.SingleOrDefaultAsync(cancellationToken);
}
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> CreateStrongSession(Guid userId)
{
var session = new Session(userId, true);
await storage.AddSession(session);
return session;
}
public async Task<Session> CreateWeakSession(Guid userId)
{
var session = new Session(userId, false);
await storage.AddSession(session);
return session;
}
public Task<Session?> GetSession(string sessionId)
{
return storage.GetSession(sessionId);
}
public async Task DeleteSession(string sessionId)
{
await storage.DeleteSession(sessionId);
}
public async Task<LongTermSession> CreateLongTermSession(Guid userId, bool isStrong)
{
throw new NotImplementedException();
}
public async Task<LongTermSession> DeleteLongTermSession(string sessionId)
{
throw new NotImplementedException();
}
public async Task<LongTermSession> RefreshLongTermSession(string sessionId)
{
throw new NotImplementedException();
}
public async Task<ValidateSessionResult> ValidateLongTermSession(string sessionId)
{
throw new NotImplementedException();
}
}

View file

@ -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<TResponse> Command<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default);
Task<TResponse> Query<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default);
}

View file

@ -1,14 +0,0 @@
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Services;
public interface IAuthService
{
public Task<UserInfo?> GetUserWithCredentials(string username, string password, CancellationToken cancellationToken = default);
public Task<UserInfo?> GetUserWithId(Guid? userId, CancellationToken cancellationToken = default);
public Task<Session> CreateStrongSession(Guid userId);
public Task<Session> CreateWeakSession(Guid userId);
public Task<Session?> GetSession(string sessionId);
public Task DeleteSession(string sessionId);
}

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

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

@ -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<Session?> GetSession(string id)
public async Task<Session?> GetSession(string id)
{
return Task.FromResult(this._storage.Get<Session>(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 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;
}

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

@ -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;
}
}
public void Invalidate()
{
this.IsInvalidated = true;
}
}

View file

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

View file

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

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

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

@ -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,
[]
);
}
}

View file

@ -24,6 +24,8 @@ internal class PostConfiguration : IEntityTypeConfiguration<Post>
}
);
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

@ -7,5 +7,6 @@ public record PostDto(
DateTimeOffset CreatedAt,
PostAuthorDto Author,
IList<PostReactionDto> Reactions,
IEnumerable<string> PossibleReactions
);
IEnumerable<string> PossibleReactions,
IList<PostCommentDto> Comments
);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostReactionDto(string Emoji, int Count, bool DidReact);
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

@ -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<LoadPostRow>(
@ -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<LoadReactionRow>(
"""
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<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,
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<IEnumerable<string>>(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; }
}
}

View file

@ -13,7 +13,9 @@ internal class Post : Entity
public IList<PostMedia> Media { get; private set; }
public IList<PostReaction> Reactions { get; private set; } = [];
public bool IsPublic { get; set; }
public IList<PostComment> 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<PostMedia> media)
public Post(Guid authorId, string content, IList<PostMedia> 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));
}
}

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

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

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>