Compare commits

...

23 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
83 changed files with 1346 additions and 667 deletions

View file

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

View file

@ -3,11 +3,11 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions; using Femto.Api.Sessions;
using Femto.Common; using Femto.Common;
using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Interface.ValidateSession; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Errors; using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Extensions;
namespace Femto.Api.Auth; namespace Femto.Api.Auth;
@ -15,48 +15,115 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
IAuthModule authModule, IAuthService authService,
CurrentUserContext currentUserContext CurrentUserContext currentUserContext
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) ) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
var sessionId = this.Request.Cookies["session"]; Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
if (string.IsNullOrWhiteSpace(sessionId))
var user = await this.TryAuthenticateWithSession();
if (user is null)
user = await this.TryAuthenticateWithRememberMeToken();
if (user is null)
return AuthenticateResult.NoResult(); 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> claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
{
new(ClaimTypes.Name, result.User.Username),
new("sub", result.User.Id.ToString()),
new("user_id", result.User.Id.ToString()),
};
claims.AddRange( var identity = new ClaimsIdentity(claims, this.Scheme.Name);
result.User.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())) 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); return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
var principal = new ClaimsPrincipal(identity); }
this.Context.SetSession(result.Session, result.User, Logger); private async Task<UserInfo?> TryAuthenticateWithSession()
currentUserContext.CurrentUser = new CurrentUser( {
result.User.Id, var sessionId = this.Context.GetSessionId();
result.User.Username,
result.Session.SessionId
);
return AuthenticateResult.Success( if (sessionId is null)
new AuthenticationTicket(principal, this.Scheme.Name)
);
}
catch (InvalidSessionError)
{ {
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.Api.Sessions;
using Femto.Common; using Femto.Common;
using Femto.Modules.Auth.Application; 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 Femto.Modules.Auth.Contracts;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Femto.Api.Controllers.Auth; namespace Femto.Api.Controllers.Auth;
[ApiController] [ApiController]
[Route("auth")] [Route("auth")]
public class AuthController( public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService)
IAuthModule authModule, : ControllerBase
IOptions<CookieSettings> cookieSettings,
ICurrentUserContext currentUserContext,
ILogger<AuthController> logger
) : ControllerBase
{ {
[HttpPost("login")] [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)); var result = await authService.AuthenticateUserCredentials(
request.Username,
HttpContext.SetSession(result.Session, result.User, logger); request.Password,
cancellationToken
return new LoginResponse(
result.User.Id,
result.User.Username,
result.User.Roles.Any(r => r == Role.SuperUser)
); );
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")] [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( var (user, session) = await authService.CreateUserWithCredentials(
new RegisterCommand(request.Username, request.Password, request.SignupCode) 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( return new RegisterResponse(
result.User.Id, user.Id,
result.User.Username, user.Username,
result.User.Roles.Any(r => r == Role.SuperUser) 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")] [HttpDelete("session")]
public async Task<ActionResult> DeleteSession() 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 { }); return Ok(new { });
} }
[HttpGet("user/{userId}")] [HttpGet("user/{userId}")]
[Authorize] [Authorize]
public async Task<ActionResult<RefreshUserResult>> RefreshUser( public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
Guid userId, Guid userId,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var currentUser = currentUserContext.CurrentUser!; var currentUser = currentUserContext.CurrentUser;
try if (currentUser is null || currentUser.Id != userId)
{ return this.BadRequest();
var result = await authModule.Command(
new RefreshUserSessionCommand(userId, currentUser),
cancellationToken
);
return new RefreshUserResult( var user = await authService.GetUserWithId(userId, cancellationToken);
result.User.Id,
result.User.Username, if (user is null)
result.User.Roles.Any(r => r == Role.SuperUser) return this.BadRequest();
);
} return new GetUserInfoResult(
catch (Exception) user.Id,
{ user.Username,
HttpContext.DeleteSession(); user.Roles.Any(r => r == Role.SuperUser)
return this.Forbid(); );
}
} }
[Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")] [HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")] [Authorize(Roles = "SuperUser")]
public async Task<ActionResult> CreateSignupCode( public async Task<ActionResult> CreateSignupCode(
@ -97,21 +176,51 @@ public class AuthController(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
await authModule.Command( await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
new CreateSignupCodeCommand(request.Code, request.Email, request.Name),
cancellationToken
);
return Ok(new { }); return Ok(new { });
} }
[Obsolete("use GET /auth/list-signup-codes")]
[HttpGet("signup-codes")] [HttpGet("signup-codes")]
[Authorize(Roles = "SuperUser")] [Authorize(Roles = "SuperUser")]
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes( public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes(
CancellationToken cancellationToken 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( return new ListSignupCodesResult(
codes.Select(c => new SignupCodeDto( 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; 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; 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; namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI] [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; namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI] [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<PostMediaDto> Media,
IEnumerable<PostReactionDto> Reactions, IEnumerable<PostReactionDto> Reactions,
DateTimeOffset CreatedAt, 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; 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.Api.Controllers.Posts.Dto;
using Femto.Common; using Femto.Common;
using Femto.Modules.Blog.Application; 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.AddPostReaction;
using Femto.Modules.Blog.Application.Commands.ClearPostReaction; using Femto.Modules.Blog.Application.Commands.ClearPostReaction;
using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Commands.CreatePost;
@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts;
[ApiController] [ApiController]
[Route("posts")] [Route("posts")]
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
: ControllerBase : ControllerBase
{ {
[HttpGet] [HttpGet]
@ -25,7 +26,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
var res = await blogModule.Query( var res = await blogModule.Query(
new GetPostsQuery(currentUserContext.CurrentUser?.Id) new GetPostsQuery(currentUserContext.CurrentUser?.Id)
{ {
From = searchParams.From, After = searchParams.After,
Amount = searchParams.Amount ?? 20, Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId, AuthorId = searchParams.AuthorId,
Author = searchParams.Author, Author = searchParams.Author,
@ -33,18 +34,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken cancellationToken
); );
return new LoadPostsResponse( return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
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
);
} }
[HttpPost] [HttpPost]
@ -75,17 +65,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken cancellationToken
); );
return new CreatePostResponse( return new CreatePostResponse(PostDto.FromModel(post));
new PostDto( }
new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
post.PostId, [HttpGet("{postId}")]
post.Text, public async Task<ActionResult<GetPostResponse>> GetPost(
post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), Guid postId,
post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(), CancellationToken cancellationToken
post.CreatedAt, )
post.PossibleReactions {
) 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}")] [HttpDelete("{postId}")]
@ -100,24 +99,56 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
[HttpPost("{postId}/reactions")] [HttpPost("{postId}/reactions")]
[Authorize] [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!; 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(); return this.Ok();
} }
[HttpDelete("{postId}/reactions")] [HttpDelete("{postId}/reactions")]
[Authorize] [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!; 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(); 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.InitializeBlogModule(connectionString, eventBus, loggerFactory);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); 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<CurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>()); builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());

View file

@ -14,7 +14,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181", "applicationUrl": "https://localhost:7269",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View file

@ -1,60 +1,52 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using Femto.Api.Auth; using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions; namespace Femto.Api.Sessions;
internal record SessionInfo(string? SessionId, Guid? UserId);
internal static class HttpContextSessionExtensions 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>>(); PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
var secure = cookieSettings?.Value.Secure ?? true; public static string? GetSessionId(this HttpContext httpContext) =>
var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict; httpContext.Request.Cookies["sid"];
var domain = cookieSettings?.Value.Domain;
var expires = session.Expires;
logger.LogInformation( public static void SetSession(this HttpContext context, Session session, UserInfo user)
"cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}", {
secure, var cookieSettings = context.RequestServices.GetRequiredService<IOptions<CookieSettings>>();
sameSite,
domain,
expires
);
httpContext.Response.Cookies.Append( context.Response.Cookies.Append(
"session", "sid",
session.SessionId, session.Id,
new CookieOptions new CookieOptions
{ {
Path = "/",
IsEssential = true, IsEssential = true,
Domain = domain, Domain = cookieSettings.Value.Domain,
HttpOnly = true, HttpOnly = true,
Secure = secure, Secure = cookieSettings.Value.Secure,
SameSite = sameSite, SameSite = cookieSettings.Value.SameSite,
Expires = expires, Expires = session.Expires,
} }
); );
httpContext.Response.Cookies.Append( context.Response.Cookies.Append(
"user", "user",
JsonSerializer.Serialize( JsonSerializer.Serialize(user, JsonOptions),
user,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
}
),
new CookieOptions new CookieOptions
{ {
Domain = domain, Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true, IsEssential = true,
Secure = secure, Secure = cookieSettings.Value.Secure,
SameSite = sameSite, SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires, Expires = session.Expires,
} }
); );
@ -62,28 +54,78 @@ internal static class HttpContextSessionExtensions
public static void DeleteSession(this HttpContext httpContext) public static void DeleteSession(this HttpContext httpContext)
{ {
var cookieSettings = httpContext.RequestServices.GetService<IOptions<CookieSettings>>(); var cookieSettings = httpContext.RequestServices.GetRequiredService<
IOptions<CookieSettings>
>();
var secure = cookieSettings?.Value.Secure ?? true; httpContext.Response.Cookies.Delete(
var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified; "sid",
var domain = cookieSettings?.Value.Domain; 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("session", new CookieOptions httpContext.Response.Cookies.Delete(
{ "user",
HttpOnly = true, new CookieOptions
Domain = domain, {
IsEssential = true, Path = "/",
Secure = secure, Domain = cookieSettings.Value.Domain,
SameSite = sameSite, IsEssential = true,
Expires = DateTimeOffset.UtcNow.AddDays(-1), Secure = cookieSettings.Value.Secure,
}); SameSite = cookieSettings.Value.SameSite,
httpContext.Response.Cookies.Delete("user", new CookieOptions Expires = DateTimeOffset.UtcNow.AddDays(-1),
{ }
Domain = domain, );
IsEssential = true, }
Secure = secure,
SameSite = 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; } 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; namespace Femto.Common.Infrastructure;
public class SaveChangesPipelineBehaviour<TRequest, TResponse>( public class DDDPipelineBehaviour<TRequest, TResponse>(
DbContext context, DbContext context,
IPublisher publisher, IPublisher publisher,
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger ILogger<DDDPipelineBehaviour<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse> ) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull where TRequest : notnull
{ {
@ -18,7 +18,12 @@ public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken); var response = await next(cancellationToken);
var hasChanges = context.ChangeTracker.HasChanges();
logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
if (context.ChangeTracker.HasChanges()) if (context.ChangeTracker.HasChanges())
{ {
await context.EmitDomainEvents(logger, publisher, cancellationToken); await context.EmitDomainEvents(logger, publisher, cancellationToken);

View file

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

View file

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

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

@ -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.') ('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 INSERT INTO blog.post_media
(id, post_id, url, ordering) (id, post_id, url, ordering)
VALUES VALUES
@ -63,6 +64,12 @@ public static class TestDataSeeder
('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 INSERT INTO authn.user_identity
(id, username, password_hash, password_salt) (id, username, password_hash, password_salt)
VALUES 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, this IServiceCollection rootContainer,
string connectionString, string connectionString,
IEventBus eventBus, IEventBus eventBus,
ILoggerFactory loggerFactory ILoggerFactory loggerFactory,
TimeProvider timeProvider
) )
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, eventBus, loggerFactory) ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
); );
var host = hostBuilder.Build(); var host = hostBuilder.Build();
rootContainer.AddScoped(_ => new ScopeBinding<IAuthModule>(host.Services.CreateScope())); rootContainer.AddKeyedScoped<ScopeBinding>(
rootContainer.AddScoped(services => "AuthServiceScope",
services.GetRequiredService<ScopeBinding<IAuthModule>>().GetService() (s, o) =>
{
var scope = host.Services.CreateScope();
return new ScopeBinding(scope);
}
); );
rootContainer.ExposeScopedService<IAuthService>();
rootContainer.AddHostedService(services => new AuthApplication(host)); rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe( eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@ -44,9 +53,12 @@ public static class AuthStartup
IServiceCollection services, IServiceCollection services,
string connectionString, string connectionString,
IEventPublisher publisher, IEventPublisher publisher,
ILoggerFactory loggerFactory ILoggerFactory loggerFactory,
TimeProvider timeProvider
) )
{ {
services.AddSingleton(timeProvider);
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString)); services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<AuthContext>(builder => services.AddDbContext<AuthContext>(builder =>
@ -66,7 +78,7 @@ public static class AuthStartup
{ {
options.WaitForJobsToComplete = true; options.WaitForJobsToComplete = true;
}); });
// #endif
services.AddOutbox<AuthContext, OutboxMessageHandler>(); services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@ -74,8 +86,11 @@ public static class AuthStartup
services.ConfigureDomainServices<AuthContext>(); services.ConfigureDomainServices<AuthContext>();
services.AddSingleton(publisher); 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( 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; 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; 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; 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; 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; 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.Common.Infrastructure.Outbox;
using Femto.Modules.Auth.Models; using Femto.Modules.Auth.Models;
using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Auth.Data; 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<UserIdentity> Users { get; set; }
public virtual DbSet<SignupCode> SignupCodes { get; set; } public virtual DbSet<SignupCode> SignupCodes { get; set; }
public virtual DbSet<LongTermSession> LongTermSessions { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; } public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@ -16,4 +21,43 @@ internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(op
builder.HasDefaultSchema("authn"); builder.HasDefaultSchema("authn");
builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly); 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 builder
.OwnsMany(u => u.Roles, entity => .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.Common.Domain;
using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events; using Femto.Modules.Auth.Models.Events;
using Geralt;
namespace Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Models;
@ -15,8 +12,6 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; } public Password? Password { get; private set; }
public ICollection<UserSession> Sessions { get; private set; } = [];
public ICollection<UserRole> Roles { get; private set; } = []; public ICollection<UserRole> Roles { get; private set; } = [];
private UserIdentity() { } private UserIdentity() { }
@ -31,14 +26,10 @@ internal class UserIdentity : Entity
this.AddDomainEvent(new UserWasCreatedEvent(this)); this.AddDomainEvent(new UserWasCreatedEvent(this));
} }
public UserIdentity WithPassword(string password)
{
this.SetPassword(password);
return this;
}
public void SetPassword(string password) public void SetPassword(string password)
{ {
if (this.Password is not null)
this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
this.Password = new Password(password); this.Password = new Password(password);
} }
@ -51,25 +42,6 @@ internal class UserIdentity : Entity
return this.Password.Check(requestPassword); 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); 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.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 => rootContainer.AddScoped(services =>
services.GetRequiredService<ScopeBinding<IBlogModule>>().GetService() services.GetRequiredKeyedService<ScopeBinding>("BlogService").GetService<IBlogModule>()
); );
bus.Subscribe( 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

@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context)
media.Width, media.Width,
media.Height media.Height
)) ))
.ToList() .ToList(),
) request.IsPublic is true
{ );
IsPublic = request.IsPublic is true
};
await context.AddAsync(post, cancellationToken); await context.AddAsync(post, cancellationToken);
@ -39,7 +37,8 @@ internal class CreatePostCommandHandler(BlogContext context)
post.PostedOn, post.PostedOn,
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), 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.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions");
table.Ignore(e => e.PossibleReactions); table.Ignore(e => e.PossibleReactions);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; 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, DateTimeOffset CreatedAt,
PostAuthorDto Author, PostAuthorDto Author,
IList<PostReactionDto> Reactions, 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; 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; 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 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 int Amount { get; init; } = 20;
public Guid? AuthorId { get; init; } public Guid? AuthorId { get; init; }
public string? Author { get; init; } public string? Author { get; init; }
/// <summary> public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
/// Default is to load in reverse chronological order {
/// TODO this is not exposed on the client as it probably wouldn't work that well this.PostId = postId;
/// </summary> }
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
}
public enum GetPostsDirection
{
Forward,
Backward,
} }

View file

@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
var username = query.Author; var username = query.Author;
var authorGuid = query.AuthorId; var authorGuid = query.AuthorId;
var cursor = query.From; var cursor = query.After;
var showPrivate = query.CurrentUserId is not null; var showPrivate = query.CurrentUserId is not null;
var loadPostsResult = await conn.QueryAsync<LoadPostRow>( var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
@ -33,9 +33,10 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
from blog.post from blog.post
inner join blog.author on blog.author.id = blog.post.author_id inner join blog.author on blog.author.id = blog.post.author_id
where (@username is null or blog.author.username = @username) 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 (@showPrivate or blog.post.is_public = true)
and (@authorGuid is null or blog.author.id = @authorGuid) 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 order by blog.post.id desc
limit @amount limit @amount
""", """,
@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
username, username,
authorGuid, authorGuid,
cursor, cursor,
// load an extra one to take for the cursor amount = query.PostId is not null ? 1 : query.Amount,
amount = query.Amount + 1,
showPrivate, showPrivate,
postId = query.PostId,
} }
); );
var loadedPosts = loadPostsResult.ToList(); var posts = loadPostsResult.ToList();
var posts = loadedPosts.Take(query.Amount).ToList();
var next = loadedPosts.LastOrDefault()?.PostId;
var postIds = posts.Select(p => p.PostId).ToList(); var postIds = posts.Select(p => p.PostId).ToList();
@ -70,69 +69,69 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
new { postIds } new { postIds }
); );
var media = loadMediaResult.ToList();
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>( var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
""" """
select select
pr.post_id as PostId, pr.post_id as PostId,
pr.author_id as AuthorId, a.username as AuthorName,
pr.emoji as Emoji pr.emoji as Emoji,
pr.created_at as CreatedOn
from blog.post_reaction pr from blog.post_reaction pr
join blog.author a on a.id = pr.author_id
where pr.post_id = ANY (@postIds) where pr.post_id = ANY (@postIds)
""", """,
new { postIds } new { postIds }
); );
var reactionsByPostId = loadReactionsResult var reactions = loadReactionsResult.ToList();
.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 mediaByPostId = loadMediaResult var loadCommentsResult = await conn.QueryAsync<LoadCommentRow>(
.GroupBy(m => m.PostId) """
.ToDictionary( select
g => g.Key, pc.id as CommentId,
g => pc.post_id as PostId,
g.Select(m => new PostMediaDto( pc.content as Content,
new Uri(m.MediaUrl), pc.created_at as PostedOn,
m.MediaWidth, a.username as AuthorName
m.MediaHeight from blog.post_comment pc
)) join blog.author a on pc.author_id = a.id
.ToList() where pc.post_id = ANY (@postIds)
); """,
new { postIds }
);
var comments = loadCommentsResult.ToList();
return new GetPostsQueryResult( return new GetPostsQueryResult(
posts posts
.Select(p => new PostDto( .Select(p => new PostDto(
p.PostId, p.PostId,
p.Content, 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, p.PostedOn,
new PostAuthorDto(p.AuthorId, p.Username), new PostAuthorDto(p.AuthorId, p.Username),
reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos) reactions
? reactionDtos.ToList() .Where(r => r.PostId == p.PostId)
: [], .Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
.ToList(),
!string.IsNullOrEmpty(p.PossibleReactions) !string.IsNullOrEmpty(p.PossibleReactions)
? JsonSerializer.Deserialize<IEnumerable<string>>(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(), .ToList()
next
); );
} }
@ -158,7 +157,17 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
internal record LoadReactionRow internal record LoadReactionRow
{ {
public Guid PostId { get; init; } public Guid PostId { get; init; }
public Guid AuthorId { get; init; } public string AuthorName { get; init; }
public string Emoji { 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<PostMedia> Media { get; private set; }
public IList<PostReaction> Reactions { 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; } public DateTimeOffset PostedOn { get; private set; }
@ -27,7 +29,7 @@ internal class Post : Entity
private Post() { } 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.Id = Guid.CreateVersion7();
this.AuthorId = authorId; this.AuthorId = authorId;
@ -35,6 +37,7 @@ internal class Post : Entity
this.Media = media; this.Media = media;
this.PossibleReactions = AllEmoji.GetRandomEmoji(5); this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
this.PostedOn = DateTimeOffset.UtcNow; this.PostedOn = DateTimeOffset.UtcNow;
this.IsPublic = isPublic;
this.AddDomainEvent(new PostCreated(this)); this.AddDomainEvent(new PostCreated(this));
} }
@ -56,4 +59,14 @@ internal class Post : Entity
.Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji) .Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji)
.ToList(); .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 AuthorId { get; private set; }
public Guid PostId { get; private set; } public Guid PostId { get; private set; }
public string Emoji { get; private set; } = null!; public string Emoji { get; private set; } = null!;
public DateTimeOffset CreatedAt { get; private set; }
public PostReaction(Guid authorId, Guid postId, string emoji) public PostReaction(Guid authorId, Guid postId, string emoji)
{ {
this.AuthorId = authorId; this.AuthorId = authorId;
this.PostId = postId; this.PostId = postId;
this.Emoji = emoji; this.Emoji = emoji;
this.CreatedAt = TimeProvider.System.GetUtcNow();
} }
private PostReaction() { } 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>