This commit is contained in:
john 2025-05-15 17:47:20 +02:00
parent 0dc41337da
commit 14fd359ea8
28 changed files with 156 additions and 52 deletions

View file

@ -0,0 +1,53 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Application.Commands.ValidateSession;
using Femto.Modules.Auth.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Femto.Api.Auth;
internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAuthenticationModule authModule,
CurrentUserContext currentUserContext
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var sessionId = this.Request.Cookies["session"];
if (string.IsNullOrWhiteSpace(sessionId))
return AuthenticateResult.NoResult();
try
{
var result = await authModule.PostCommand(new ValidateSessionCommand(sessionId));
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);
this.Context.SetSession(result.Session);
currentUserContext.CurrentUser = new CurrentUser(result.UserId, result.Username);
return AuthenticateResult.Success(
new AuthenticationTicket(principal, this.Scheme.Name)
);
}
catch (InvalidSessionError)
{
return AuthenticateResult.Fail("Invalid session");
}
}
}

View file

@ -22,8 +22,8 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase
return new LoginResponse(result.UserId, result.Username); return new LoginResponse(result.UserId, result.Username);
} }
[HttpPost("signup")] [HttpPost("register")]
public async Task<ActionResult<SignupResponse>> Signup([FromBody] SignupRequest request) public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
{ {
var result = await authModule.PostCommand( var result = await authModule.PostCommand(
new RegisterCommand(request.Username, request.Password) new RegisterCommand(request.Username, request.Password)
@ -31,7 +31,7 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase
HttpContext.SetSession(result.Session); HttpContext.SetSession(result.Session);
return new SignupResponse(result.UserId, result.Username); return new RegisterResponse(result.UserId, result.Username);
} }
[HttpPost("delete-session")] [HttpPost("delete-session")]

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ using Femto.Modules.Blog.Application;
using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Commands.CreatePost;
using Femto.Modules.Blog.Application.Queries.GetPosts; using Femto.Modules.Blog.Application.Queries.GetPosts;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Femto.Api.Controllers.Posts; namespace Femto.Api.Controllers.Posts;
@ -13,6 +14,7 @@ namespace Femto.Api.Controllers.Posts;
public class PostsController(IBlogModule blogModule) : ControllerBase public class PostsController(IBlogModule blogModule) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize]
public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts( public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts(
[FromQuery] GetPublicPostsSearchParams searchParams, [FromQuery] GetPublicPostsSearchParams searchParams,
CancellationToken cancellationToken CancellationToken cancellationToken

View file

@ -0,0 +1,8 @@
using Femto.Common;
namespace Femto.Api;
internal class CurrentUserContext : ICurrentUserContext
{
public CurrentUser? CurrentUser { get; set; }
}

View file

@ -28,4 +28,8 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Middleware\" />
</ItemGroup>
</Project> </Project>

View file

@ -1,8 +1,14 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Femto.Api;
using Femto.Api.Auth;
using Femto.Common;
using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Application;
using Femto.Modules.Blog.Application; using Femto.Modules.Blog.Application;
using Femto.Modules.Media.Application; using Femto.Modules.Media.Application;
using Microsoft.AspNetCore.Authentication;
const string CorsPolicyName = "DefaultCorsPolicy";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -20,15 +26,18 @@ builder.Services.InitializeBlogModule(connectionString);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
builder.Services.InitializeAuthenticationModule(connectionString); builder.Services.InitializeAuthenticationModule(connectionString);
builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy( options.AddPolicy(
"DefaultCorsPolicy", CorsPolicyName,
b => b =>
{ {
b.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin(); b.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:5173");
} }
); );
}); });
@ -44,9 +53,18 @@ builder
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString; options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
}); });
builder
.Services.AddAuthentication("SessionAuth")
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(
"SessionAuth",
options => { }
);
builder.Services.AddAuthorization(); // if not already added
var app = builder.Build(); var app = builder.Build();
app.UseCors("DefaultCorsPolicy"); app.UseCors(CorsPolicyName);
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {

View file

@ -12,8 +12,8 @@ internal static class HttpContextSessionExtensions
new CookieOptions new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
Secure = true, // Secure = true,
SameSite = SameSiteMode.Strict, // SameSite = SameSiteMode.Strict,
Expires = session.Expires, Expires = session.Expires,
} }
); );

View file

@ -0,0 +1,7 @@
namespace Femto.Common.Domain;
public class DomainError : Exception
{
public DomainError(string message, Exception innerException) : base(message, innerException) {}
public DomainError(string message) : base(message) {}
}

View file

@ -1,7 +0,0 @@
namespace Femto.Common.Domain;
public class DomainException : Exception
{
public DomainException(string message, Exception innerException) : base(message, innerException) {}
public DomainException(string message) : base(message) {}
}

View file

@ -16,6 +16,6 @@ public abstract class Entity
protected void CheckRule(IRule rule) protected void CheckRule(IRule rule)
{ {
if (!rule.Check()) if (!rule.Check())
throw new RuleBrokenException(rule.Message); throw new RuleBrokenError(rule.Message);
} }
} }

View file

@ -0,0 +1,3 @@
namespace Femto.Common.Domain;
public class RuleBrokenError(string message) : DomainError(message);

View file

@ -1,3 +0,0 @@
namespace Femto.Common.Domain;
public class RuleBrokenException(string message) : DomainException(message);

View file

@ -0,0 +1,8 @@
namespace Femto.Common;
public interface ICurrentUserContext
{
CurrentUser? CurrentUser { get; }
}
public record CurrentUser(Guid Id, string Username);

View file

@ -54,3 +54,10 @@ CREATE TABLE media.saved_blob
type varchar(64) NOT NULL, type varchar(64) NOT NULL,
size int size int
); );
CREATE SCHEMA authn;
CREATE TABLE authn.user_identity
(
);

View file

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

View file

@ -35,6 +35,8 @@ public static class AuthenticationStartup
builder.EnableSensitiveDataLogging(); builder.EnableSensitiveDataLogging();
}); });
services.AddScoped<DbContext>(s => s.GetRequiredService<AuthContext>());
services.AddMediatR(c => services.AddMediatR(c =>
{ {
c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly); c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly);

View file

@ -1,6 +1,5 @@
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models; using Femto.Modules.Auth.Models;
using MediatR; using MediatR;
@ -8,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Commands.Login; namespace Femto.Modules.Auth.Application.Commands.Login;
internal class LoginCommandHandler(AuthContext context, SessionGenerator sessionGenerator) internal class LoginCommandHandler(AuthContext context)
: ICommandHandler<LoginCommand, LoginResult> : ICommandHandler<LoginCommand, LoginResult>
{ {
public async Task<LoginResult> Handle(LoginCommand request, CancellationToken cancellationToken) public async Task<LoginResult> Handle(LoginCommand request, CancellationToken cancellationToken)
@ -19,14 +18,12 @@ internal class LoginCommandHandler(AuthContext context, SessionGenerator session
); );
if (user is null) if (user is null)
throw new DomainException("invalid credentials"); throw new DomainError("invalid credentials");
if (!user.HasPassword(request.Password)) if (!user.HasPassword(request.Password))
throw new DomainException("invalid credentials"); throw new DomainError("invalid credentials");
var session = sessionGenerator.GenerateSession(); var session = user.StartNewSession();
await context.AddAsync(session, cancellationToken);
return new(new Session(session.Id, session.Expires), user.Id, user.Username); return new(new Session(session.Id, session.Expires), user.Id, user.Username);
} }

View file

@ -1,12 +1,11 @@
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Application.Services;
using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models; using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Commands.Register; namespace Femto.Modules.Auth.Application.Commands.Register;
internal class RegisterCommandHandler(AuthContext context, SessionGenerator sessionGenerator) : ICommandHandler<RegisterCommand, RegisterResult> internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<RegisterCommand, RegisterResult>
{ {
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken) public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken)
{ {

View file

@ -3,4 +3,8 @@ using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Commands.ValidateSession; 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>; public record ValidateSessionCommand(string SessionId) : ICommand<ValidateSessionResult>;

View file

@ -1,6 +1,7 @@
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Commands.ValidateSession; namespace Femto.Modules.Auth.Application.Commands.ValidateSession;
@ -21,7 +22,7 @@ internal class ValidateSessionCommandHandler(AuthContext context)
); );
if (user is null) if (user is null)
throw new DomainException("invalid session"); throw new InvalidSessionError();
var session = user.StartNewSession(); var session = user.StartNewSession();

View file

@ -1,11 +0,0 @@
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Services;
public class SessionGenerator
{
public UserSession GenerateSession()
{
throw new NotImplementedException();
}
}

View file

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Data; namespace Femto.Modules.Auth.Data;
internal class AuthContext : DbContext, IOutboxContext internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(options), IOutboxContext
{ {
public virtual DbSet<UserIdentity> Users { get; } public virtual DbSet<UserIdentity> Users { get; }
public virtual DbSet<OutboxEntry> Outbox { get; } public virtual DbSet<OutboxEntry> Outbox { get; }

View file

@ -0,0 +1,14 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Errors;
public class InvalidSessionError : DomainError
{
private const string Code = "invalid_session";
public InvalidSessionError()
: base("invalid_session") { }
public InvalidSessionError(Exception e)
: base("invalid_session", e) { }
}

View file

@ -14,7 +14,7 @@ internal class UserIdentity : Entity
public UserPassword Password { get; private set; } public UserPassword Password { get; private set; }
public ICollection<UserSession> Sessions { get; private set; } public ICollection<UserSession> Sessions { get; private set; } = [];
private UserIdentity() { } private UserIdentity() { }
@ -57,4 +57,4 @@ internal class UserIdentity : Entity
} }
} }
public class SetPasswordError(string message, Exception inner) : DomainException(message, inner); public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);