Compare commits

..

No commits in common. "main" and "v0.1.1" have entirely different histories.
main ... v0.1.1

113 changed files with 501 additions and 5836 deletions

View file

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

View file

@ -2,7 +2,6 @@ namespace Femto.Api.Auth;
public class CookieSettings
{
public SameSiteMode SameSite { get; set; }
public bool SameSite { get; set; }
public bool Secure { get; set; }
public string? Domain { get; set; }
}

View file

@ -3,9 +3,8 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models;
using Femto.Modules.Auth.Application.Commands.ValidateSession;
using Femto.Modules.Auth.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
@ -15,115 +14,41 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAuthService authService,
CurrentUserContext currentUserContext
IAuthModule authModule,
CurrentUserContext currentUserContext,
IOptions<CookieSettings> cookieOptions
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
var user = await this.TryAuthenticateWithSession();
if (user is null)
user = await this.TryAuthenticateWithRememberMeToken();
if (user is null)
var sessionId = this.Request.Cookies["session"];
if (string.IsNullOrWhiteSpace(sessionId))
return AuthenticateResult.NoResult();
var claims = new List<Claim>
try
{
new(ClaimTypes.Name, user.Username),
new("sub", user.Id.ToString()),
new("user_id", user.Id.ToString()),
};
var result = await authModule.PostCommand(new ValidateSessionCommand(sessionId));
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
var claims = new List<Claim>
{
new(ClaimTypes.Name, result.Username),
new("sub", result.UserId.ToString()),
new("user_id", result.UserId.ToString()),
};
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
currentUserContext.CurrentUser = new CurrentUser(
user.Id,
user.Username,
user.Roles.Contains(Role.SuperUser)
);
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
var principal = new ClaimsPrincipal(identity);
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
}
this.Context.SetSession(result.Session, cookieOptions.Value);
currentUserContext.CurrentUser = new CurrentUser(result.UserId, result.Username);
private async Task<UserInfo?> TryAuthenticateWithSession()
{
var sessionId = this.Context.GetSessionId();
if (sessionId is null)
{
Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
return null;
return AuthenticateResult.Success(
new AuthenticationTicket(principal, this.Scheme.Name)
);
}
var session = await authService.GetSession(sessionId);
if (session is null)
catch (InvalidSessionError)
{
Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
return null;
return AuthenticateResult.Fail("Invalid session");
}
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,236 +1,45 @@
using Femto.Api.Auth;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Contracts;
using Microsoft.AspNetCore.Authorization;
using Femto.Modules.Auth.Application.Commands.Login;
using Femto.Modules.Auth.Application.Commands.Register;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Femto.Api.Controllers.Auth;
[ApiController]
[Route("auth")]
public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService)
: ControllerBase
public class AuthController(IAuthModule authModule, IOptions<CookieSettings> cookieSettings) : ControllerBase
{
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken
)
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
{
var result = await authService.AuthenticateUserCredentials(
request.Username,
request.Password,
cancellationToken
var result = await authModule.PostCommand(
new LoginCommand(request.Username, request.Password)
);
if (result is null)
return this.BadRequest();
HttpContext.SetSession(result.Session, cookieSettings.Value);
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));
return new LoginResponse(result.UserId, result.Username);
}
[HttpPost("register")]
public async Task<ActionResult<RegisterResponse>> Register(
[FromBody] RegisterRequest request,
CancellationToken cancellationToken
)
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
{
var (user, session) = await authService.CreateUserWithCredentials(
request.Username,
request.Password,
request.SignupCode,
cancellationToken
var result = await authModule.PostCommand(
new RegisterCommand(request.Username, request.Password)
);
HttpContext.SetSession(session, user);
HttpContext.SetSession(result.Session, cookieSettings.Value);
if (request.RememberMe)
{
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
HttpContext.SetRememberMeToken(newRememberMeToken);
}
return new RegisterResponse(
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
return new RegisterResponse(result.UserId, result.Username);
}
[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()
{
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();
}
HttpContext.Response.Cookies.Delete("session");
return Ok(new { });
}
[HttpGet("user/{userId}")]
[Authorize]
public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
Guid userId,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser;
if (currentUser is null || currentUser.Id != userId)
return this.BadRequest();
var user = await authService.GetUserWithId(userId, cancellationToken);
if (user is null)
return this.BadRequest();
return new GetUserInfoResult(
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
}
[Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task<ActionResult> CreateSignupCode(
[FromBody] CreateSignupCodeRequest request,
CancellationToken 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 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(
c.Code,
c.Email,
c.Name,
c.RedeemedByUserId,
c.RedeemedByUsername,
c.ExpiresOn
))
);
}
}

View file

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

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record CreateSignupCodeRequest(string Code, string Email, string Name);

View file

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

View file

@ -1,14 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record ListSignupCodesResult(
IEnumerable<SignupCodeDto> SignupCodes
);
public record SignupCodeDto(
string Code,
string Email,
string Name,
Guid? RedeemingUserId,
string? RedeemingUsername,
DateTimeOffset? ExpiresOn
);

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
using Femto.Api.Controllers.Media.Dto;
using Femto.Modules.Media.Application;
using Femto.Modules.Media.Contracts;
using Femto.Modules.Media.Contracts.LoadFile;
using Femto.Modules.Media.Contracts.SaveFile;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,21 +8,5 @@ public record PostDto(
Guid PostId,
string Content,
IEnumerable<PostMediaDto> Media,
IEnumerable<PostReactionDto> Reactions,
DateTimeOffset CreatedAt,
IEnumerable<string> PossibleReactions,
IEnumerable<PostCommentDto> Comments
)
{
public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) =>
new(
new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
post.PostId,
post.Text,
post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)),
post.CreatedAt,
post.PossibleReactions,
post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn))
);
}
DateTimeOffset CreatedAt
);

View file

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

View file

@ -1,11 +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;
using Femto.Modules.Blog.Application.Commands.DeletePost;
using Femto.Modules.Blog.Application.Queries.GetPosts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -14,19 +10,18 @@ namespace Femto.Api.Controllers.Posts;
[ApiController]
[Route("posts")]
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
: ControllerBase
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<LoadPostsResponse>> LoadPosts(
public async Task<ActionResult<GetAllPublicPostsResponse>> LoadPosts(
[FromQuery] GetPublicPostsSearchParams searchParams,
CancellationToken cancellationToken
)
{
var res = await blogModule.Query(
var res = await blogModule.PostQuery(
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
{
After = searchParams.After,
From = searchParams.From,
Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId,
Author = searchParams.Author,
@ -34,7 +29,16 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
return new GetAllPublicPostsResponse(
res.Posts.Select(p => new PostDto(
new PostAuthorDto(p.Author.AuthorId, p.Author.Username),
p.PostId,
p.Text,
p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
p.CreatedAt
)),
res.Next
);
}
[HttpPost]
@ -44,7 +48,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
CancellationToken cancellationToken
)
{
var post = await blogModule.Command(
var guid = await blogModule.PostCommand(
new CreatePostCommand(
req.AuthorId,
req.Content,
@ -59,96 +63,11 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
media.Height
)
),
req.IsPublic,
currentUserContext.CurrentUser!
req.IsPublic
),
cancellationToken
);
return new CreatePostResponse(PostDto.FromModel(post));
}
[HttpGet("{postId}")]
public async Task<ActionResult<GetPostResponse>> GetPost(
Guid postId,
CancellationToken cancellationToken
)
{
var result = await blogModule.Query(
new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id),
cancellationToken
);
var post = result.Posts.SingleOrDefault();
if (post is null)
return NotFound();
return new GetPostResponse(PostDto.FromModel(post));
}
[HttpDelete("{postId}")]
[Authorize]
public async Task DeletePost(Guid postId, CancellationToken cancellationToken)
{
await blogModule.Command(
new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id),
cancellationToken
);
}
[HttpPost("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> AddPostReaction(
Guid postId,
[FromBody] AddPostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(
new AddPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
[HttpDelete("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> DeletePostReaction(
Guid postId,
[FromBody] DeletePostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(
new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
[HttpPost("{postId}/comments")]
[Authorize]
public async Task<ActionResult> AddPostComment(
Guid postId,
[FromBody] AddPostCommentRequest request,
CancellationToken cancellationToken
)
{
if (currentUserContext.CurrentUser?.Id != request.AuthorId)
return this.BadRequest();
await blogModule.Command(
new AddPostCommentCommand(postId, request.AuthorId, request.Content),
cancellationToken
);
return this.Ok();
return new CreatePostResponse(guid);
}
}

View file

@ -5,4 +5,4 @@ namespace Femto.Api;
internal class CurrentUserContext : ICurrentUserContext
{
public CurrentUser? CurrentUser { get; set; }
}
}

View file

@ -68,7 +68,7 @@ RUN mkdir -p /app/scripts && \
ENV PATH="/app/scripts:${PATH}"
# This envvar is used by Femto.Database up to load the migrations
ENV MigrationsDirectory='/app/femto-db/Migrations'
ENV MIGRATIONS_DIRECTORY='/app/femto-db/Migrations'
# Entrypoint for the API
ENTRYPOINT ["dotnet", "Femto.Api.dll"]

View file

@ -21,17 +21,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var loggerFactory = LoggerFactory.Create(b =>
{
b.SetMinimumLevel(LogLevel.Information)
.AddConfiguration(builder.Configuration.GetSection("Logging"))
.AddConsole()
.AddDebug();
});
builder.Services.AddSingleton(loggerFactory);
builder.Services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
var connectionString = builder.Configuration.GetConnectionString("Database");
if (connectionString is null)
throw new Exception("no database connection string found");
@ -44,9 +33,9 @@ if (blobStorageRoot is null)
var eventBus = new EventBus(Channel.CreateUnbounded<IEvent>());
builder.Services.AddHostedService(_ => eventBus);
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
builder.Services.InitializeBlogModule(connectionString, eventBus);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System);
builder.Services.InitializeAuthenticationModule(connectionString, eventBus);
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://localhost:7269",
"applicationUrl": "https://0.0.0.0:7269;http://0.0.0.0:5181",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View file

@ -1,131 +1,37 @@
using System.Text.Json;
using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions;
internal record SessionInfo(string? SessionId, Guid? UserId);
internal static class HttpContextSessionExtensions
{
private static readonly JsonSerializerOptions JsonOptions = new()
public static void SetSession(
this HttpContext httpContext,
Session session,
CookieSettings cookieSettings
)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
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>>();
context.Response.Cookies.Append(
"sid",
session.Id,
httpContext.Response.Cookies.Append(
"session",
session.SessionId,
new CookieOptions
{
Path = "/",
IsEssential = true,
Domain = cookieSettings.Value.Domain,
HttpOnly = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Secure = cookieSettings.Secure,
SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified,
Expires = session.Expires,
}
);
context.Response.Cookies.Append(
"user",
JsonSerializer.Serialize(user, JsonOptions),
httpContext.Response.Cookies.Append(
"hasSession",
"true",
new CookieOptions
{
Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Secure = cookieSettings.Secure,
SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified,
Expires = session.Expires,
}
);
}
public static void DeleteSession(this HttpContext httpContext)
{
var cookieSettings = httpContext.RequestServices.GetRequiredService<
IOptions<CookieSettings>
>();
httpContext.Response.Cookies.Delete(
"sid",
new CookieOptions
{
Path = "/",
HttpOnly = true,
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
}
);
httpContext.Response.Cookies.Delete(
"user",
new CookieOptions
{
Path = "/",
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
}
);
}
public static RememberMeToken? GetRememberMeToken(this HttpContext httpContext) =>
httpContext.Request.Cookies["rid"] is { } code ? RememberMeToken.FromCode(code) : null;
public static void SetRememberMeToken(this HttpContext context, NewRememberMeToken token)
{
var cookieSettings = context.RequestServices.GetRequiredService<IOptions<CookieSettings>>();
context.Response.Cookies.Append(
"rid",
token.Code,
new CookieOptions
{
Path = "/",
IsEssential = true,
Domain = cookieSettings.Value.Domain,
HttpOnly = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = token.Expires,
}
);
}
public static void DeleteRememberMeToken(this HttpContext context)
{
var cookieSettings = context.RequestServices.GetRequiredService<IOptions<CookieSettings>>();
context.Response.Cookies.Delete(
"rid",
new CookieOptions
{
Path = "/",
HttpOnly = true,
Domain = cookieSettings.Value.Domain,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = DateTimeOffset.UtcNow.AddDays(-1),
}
);
}
}

View file

@ -3,9 +3,7 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Femto": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ CREATE TABLE blog.post
content text NOT NULL,
posted_on timestamptz NOT NULL DEFAULT now(),
author_id uuid NOT NULL REFERENCES blog.author (id) on DELETE CASCADE,
is_public bool NOT NULL DEFAULT false
is_private bool NOT NULL DEFAULT false
);
CREATE TABLE blog.post_media

View file

@ -1,14 +0,0 @@
-- Migration: InitSignupCode
-- Created at: 18/05/2025 16:30:39
CREATE TABLE authn.signup_code
(
code varchar(32) PRIMARY KEY,
recipient_email TEXT NOT NULL,
recipient_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
-- we don't make this a foreign key as we don't really need it for lookups,
-- and should the redeeming user delete be deleted, it's better that we keep the ID here
redeeming_user_id UUID
);

View file

@ -1,9 +0,0 @@
-- Migration: AddUserRole
-- Created at: 18/05/2025 19:31:13
CREATE TABLE authn.user_role
(
user_id uuid REFERENCES authn.user_identity(id),
role int,
primary key (user_id, role)
)

View file

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

View file

@ -1,13 +0,0 @@
-- 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

@ -1,5 +0,0 @@
-- 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

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

View file

@ -1,11 +0,0 @@
-- 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

@ -19,7 +19,7 @@ var newCommand = new Command("new", "creates a new migrations")
newCommand.SetHandler(MakeNewMigration, nameArg, migrationsDirectoryOption);
var connectionStringArg = new Argument<string>(
"connection-string",
"--connection-string",
"the connection string to the database"
);
@ -34,7 +34,7 @@ var connectionStringOption = new Option<string?>(
var upCommand = new Command("up", "update the database to the most current migration")
{
migrationsDirectoryOption,
connectionStringOption,
connectionStringArg,
};
upCommand.SetHandler(

View file

@ -14,7 +14,7 @@ public static class TestDataSeeder
var id = Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0");
var username = "johnbotris";
var salt = new byte[32];
var password = "password"u8;
var password = "hunter2"u8;
var hashInput = new byte[password.Length + salt.Length];
password.CopyTo(hashInput);
salt.CopyTo(hashInput, password.Length);
@ -35,15 +35,14 @@ public static class TestDataSeeder
;
INSERT INTO blog.post
(id, author_id, possible_reactions, content)
(id, author_id, content)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id,'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id,'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
;
INSERT INTO blog.post_media
(id, post_id, url, ordering)
VALUES
@ -55,35 +54,10 @@ public static class TestDataSeeder
('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0)
;
INSERT INTO blog.post_reaction
(post_id, author_id, emoji)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '🍆'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '🍆'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🧑🏾'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗')
;
INSERT INTO blog.post_comment
(id, post_id, author_id, content)
VALUES
('9116da05-49eb-4053-9199-57f54f92e73a', '019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'this is a comment!')
;
INSERT INTO authn.user_identity
(id, username, password_hash, password_salt)
VALUES
(@id, @username, @passwordHash, @salt);
INSERT INTO authn.user_role
(user_id, role)
VALUES
(@id, 1);
INSERT INTO authn.signup_code
(code, recipient_email, recipient_name, expires_at, redeeming_user_id)
VALUES
('fickli', 'me@johnbotr.is', 'john', null, null);
"""
);

View file

@ -1,27 +0,0 @@
# 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

@ -1,16 +0,0 @@
# 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,7 +0,0 @@
namespace Femto.Modules.Auth.Contracts;
public enum Role
{
User = 0,
SuperUser = 1,
}

View file

@ -0,0 +1,17 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Auth.Application;
internal class AuthModule(IHost host) : IAuthModule
{
public async Task<TResponse> PostCommand<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(command, cancellationToken);
return response;
}
}

View file

@ -1,258 +0,0 @@
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

@ -1,6 +1,4 @@
using Femto.Common;
using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration;
using Femto.Modules.Auth.Data;
@ -16,83 +14,47 @@ namespace Femto.Modules.Auth.Application;
public static class AuthStartup
{
public static void InitializeAuthenticationModule(
this IServiceCollection rootContainer,
string connectionString,
IEventBus eventBus,
ILoggerFactory loggerFactory,
TimeProvider timeProvider
)
public static void InitializeAuthenticationModule(this IServiceCollection rootContainer,
string connectionString, IEventBus eventBus)
{
var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
);
hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus));
var host = hostBuilder.Build();
rootContainer.AddKeyedScoped<ScopeBinding>(
"AuthServiceScope",
(s, o) =>
{
var scope = host.Services.CreateScope();
return new ScopeBinding(scope);
}
);
rootContainer.ExposeScopedService<IAuthService>();
rootContainer.AddScoped<IAuthModule>(_ => new AuthModule(host));
rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
);
eventBus.Subscribe((evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken));
}
private static void ConfigureServices(
IServiceCollection services,
string connectionString,
IEventPublisher publisher,
ILoggerFactory loggerFactory,
TimeProvider timeProvider
)
private static void ConfigureServices(IServiceCollection services, string connectionString, IEventPublisher publisher)
{
services.AddSingleton(timeProvider);
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<AuthContext>(builder =>
{
builder.UseNpgsql(connectionString);
builder.UseSnakeCaseNamingConvention();
builder.UseLoggerFactory(loggerFactory);
// #if DEBUG
// builder.EnableSensitiveDataLogging();
// #endif
});
services.AddSingleton(loggerFactory);
services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
// #endif
services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
services.AddDbContext<AuthContext>(builder =>
{
builder.UseNpgsql();
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b => { });
builder.UseLoggerFactory(loggerFactory);
});
services.ConfigureDomainServices<AuthContext>();
services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>();
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
services.AddScoped<IAuthService, AuthService>();
}
private static async Task EventSubscriber(
IEvent evt,
IServiceProvider provider,
@ -122,14 +84,3 @@ 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

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

View file

@ -0,0 +1,30 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Commands.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), user.Id, user.Username);
}
}

View file

@ -0,0 +1,6 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Commands.Register;
public record RegisterCommand(string Username, string Password) : ICommand<RegisterResult>;

View file

@ -0,0 +1,22 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Commands.Register;
internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<RegisterCommand, RegisterResult>
{
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken)
{
var user = new UserIdentity(request.Username);
user.SetPassword(request.Password);
var session = user.StartNewSession();
await context.AddAsync(user, cancellationToken);
return new(new Session(session.Id, session.Expires), user.Id, user.Username);
}
}

View file

@ -0,0 +1,10 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Commands.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

@ -0,0 +1,35 @@
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.Commands.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),
user.Id,
user.Username
);
}
}

View file

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
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,16 +2,4 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto;
public record SessionDto(
string SessionId,
DateTimeOffset Expires,
bool Weak,
string? RememberMe = null
)
{
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) { }
}
public record Session(string SessionId, DateTimeOffset Expires);

View file

@ -1,10 +0,0 @@
namespace Femto.Modules.Auth.Application.Dto;
public record SignupCodeDto(
string Code,
string Email,
string Name,
Guid? RedeemedByUserId,
string? RedeemedByUsername,
DateTimeOffset? ExpiresOn
);

View file

@ -1,10 +0,0 @@
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto;
public record UserInfo(Guid Id, string Username, ICollection<Role> Roles)
{
internal UserInfo(UserIdentity user)
: this(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()) { }
};

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
public record ValidateSessionResult(SessionDto SessionDto);
public record ValidateSessionResult(Session Session, Guid UserId, string Username);

View file

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

View file

@ -1,45 +0,0 @@
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,18 +1,12 @@
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;
internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(options), IOutboxContext
{
public virtual DbSet<UserIdentity> Users { get; set; }
public virtual DbSet<SignupCode> SignupCodes { get; set; }
public virtual DbSet<LongTermSession> LongTermSessions { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
@ -21,43 +15,4 @@ 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

@ -1,13 +0,0 @@
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

@ -1,14 +0,0 @@
using Femto.Modules.Auth.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Auth.Data.Configurations;
internal class SignupCodeConfiguration : IEntityTypeConfiguration<SignupCode>
{
public void Configure(EntityTypeBuilder<SignupCode> builder)
{
builder.ToTable("signup_code");
builder.HasKey(t => t.Code);
}
}

View file

@ -9,21 +9,16 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIden
public void Configure(EntityTypeBuilder<UserIdentity> builder)
{
builder.ToTable("user_identity");
builder.OwnsOne(
u => u.Password,
pw =>
{
pw.Property(p => p.Hash).HasColumnName("password_hash").IsRequired(false);
builder.OwnsOne(u => u.Password, pw =>
{
pw.Property(p => p.Hash)
.HasColumnName("password_hash")
.IsRequired(false);
pw.Property(p => p.Salt).HasColumnName("password_salt").IsRequired(false);
}
);
builder
.OwnsMany(u => u.Roles, entity =>
{
entity.WithOwner().HasForeignKey(x => x.UserId);
entity.HasKey(x => new { x.UserId, x.Role});
});
pw.Property(p => p.Salt)
.HasColumnName("password_salt")
.IsRequired(false);
});
builder.OwnsMany(u => u.Sessions).WithOwner().HasForeignKey("user_id");
}
}

View file

@ -8,7 +8,6 @@
<ItemGroup>
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Geralt" Version="3.3.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />

View file

@ -1,23 +0,0 @@
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,56 +0,0 @@
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

@ -1,22 +0,0 @@
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

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

View file

@ -1,73 +0,0 @@
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

@ -1,16 +0,0 @@
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,41 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Models;
public class SignupCode
{
private static readonly TimeSpan ExpiryTime = TimeSpan.FromDays(14);
public string Code { get; private set; }
/// <summary>
/// The email of the intended recipient
/// </summary>
public string RecipientEmail { get; private set; }
/// <summary>
/// The name of the intended recipient
/// </summary>
public string RecipientName { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? ExpiresAt { get; private set; }
public Guid? RedeemingUserId { get; private set; }
private SignupCode() { }
public SignupCode(string recipientEmail, string recipientName, string code)
{
this.Code = code;
this.RecipientEmail = recipientEmail;
this.RecipientName = recipientName;
this.CreatedAt = DateTimeOffset.UtcNow;
this.ExpiresAt = this.CreatedAt + ExpiryTime;
}
public void Redeem(Guid userGuid)
{
if (this.RedeemingUserId is not null)
throw new DomainError("invalid signup code");
this.RedeemingUserId = userGuid;
}
}

View file

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

View file

@ -1,17 +0,0 @@
using Femto.Modules.Auth.Contracts;
namespace Femto.Modules.Auth.Models;
internal class UserRole
{
public Guid UserId { get; set; }
public Role Role { get; set; }
private UserRole() {}
public UserRole( Role role){
Role = role;
}
}

View file

@ -0,0 +1,21 @@
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

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

View file

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

View file

@ -0,0 +1,24 @@
<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

@ -1,29 +1,37 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Blog.Application;
internal class BlogModule(IMediator mediator) : IBlogModule
internal class BlogModule(IHost host) : IBlogModule
{
public async Task Command(ICommand command, CancellationToken cancellationToken = default)
public async Task PostCommand(ICommand command, CancellationToken cancellationToken = default)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(command, cancellationToken);
}
public async Task<TResponse> Command<TResponse>(
public async Task<TResponse> PostCommand<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default
)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(command, cancellationToken);
return response;
}
public async Task<TResponse> Query<TResponse>(
public async Task<TResponse> PostQuery<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default
)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(query, cancellationToken);
return response;
}

View file

@ -1,5 +1,4 @@
using System.Runtime.CompilerServices;
using Femto.Common;
using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox;
@ -21,28 +20,20 @@ public static class BlogStartup
public static void InitializeBlogModule(
this IServiceCollection rootContainer,
string connectionString,
IEventBus bus,
ILoggerFactory loggerFactory
IEventBus bus
)
{
var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, bus, loggerFactory)
ConfigureServices(services, connectionString, bus)
);
var host = hostBuilder.Build();
rootContainer.AddHostedService(_ => new BlogApplication(host));
rootContainer.AddHostedService(services => new BlogApplication(host));
rootContainer.AddKeyedScoped<ScopeBinding>(
"BlogService",
(_, o) => new ScopeBinding(host.Services.CreateScope())
);
rootContainer.AddScoped(services =>
services.GetRequiredKeyedService<ScopeBinding>("BlogService").GetService<IBlogModule>()
);
rootContainer.AddScoped<IBlogModule>(_ => new BlogModule(host));
bus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@ -52,8 +43,7 @@ public static class BlogStartup
private static void ConfigureServices(
this IServiceCollection services,
string connectionString,
IEventPublisher publisher,
ILoggerFactory loggerFactory
IEventPublisher publisher
)
{
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
@ -68,12 +58,10 @@ public static class BlogStartup
}
);
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b => { });
builder.UseLoggerFactory(loggerFactory);
});
services.AddSingleton(loggerFactory);
services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
services.AddOutbox<BlogContext, OutboxMessageHandler>();
services.AddMediatR(c =>
@ -83,8 +71,6 @@ public static class BlogStartup
services.ConfigureDomainServices<BlogContext>();
services.AddScoped<IBlogModule, BlogModule>();
services.AddSingleton(publisher);
}

View file

@ -1,5 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand;

View file

@ -1,20 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.DeletePost;
public record DeletePostCommand(Guid PostId, Guid InitiatingUserId) : ICommand;

View file

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

View file

@ -4,14 +4,14 @@ namespace Femto.Modules.Blog.Application;
public interface IBlogModule
{
Task Command(ICommand command, CancellationToken cancellationToken = default);
Task PostCommand(ICommand command, CancellationToken cancellationToken = default);
Task<TResponse> Command<TResponse>(
Task<TResponse> PostCommand<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default
);
Task<TResponse> Query<TResponse>(
Task<TResponse> PostQuery<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default
);

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more