diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs new file mode 100644 index 0000000..1d33425 --- /dev/null +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -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 options, + ILoggerFactory logger, + UrlEncoder encoder, + IAuthenticationModule authModule, + CurrentUserContext currentUserContext +) : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task 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 + { + 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"); + } + } +} diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index feba2c7..5e22096 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -22,8 +22,8 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase return new LoginResponse(result.UserId, result.Username); } - [HttpPost("signup")] - public async Task> Signup([FromBody] SignupRequest request) + [HttpPost("register")] + public async Task> Register([FromBody] RegisterRequest request) { var result = await authModule.PostCommand( new RegisterCommand(request.Username, request.Password) @@ -31,7 +31,7 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase HttpContext.SetSession(result.Session); - return new SignupResponse(result.UserId, result.Username); + return new RegisterResponse(result.UserId, result.Username); } [HttpPost("delete-session")] diff --git a/Femto.Api/Controllers/Auth/RegisterRequest.cs b/Femto.Api/Controllers/Auth/RegisterRequest.cs new file mode 100644 index 0000000..f386198 --- /dev/null +++ b/Femto.Api/Controllers/Auth/RegisterRequest.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Auth; + +public record RegisterRequest(string Username, string Password, string SignupCode, string? Email); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/RegisterResponse.cs b/Femto.Api/Controllers/Auth/RegisterResponse.cs new file mode 100644 index 0000000..17de500 --- /dev/null +++ b/Femto.Api/Controllers/Auth/RegisterResponse.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Auth; + +public record RegisterResponse(Guid UserId, string Username); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/SignupRequest.cs b/Femto.Api/Controllers/Auth/SignupRequest.cs deleted file mode 100644 index cb811ef..0000000 --- a/Femto.Api/Controllers/Auth/SignupRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Auth; - -public record SignupRequest(string Username, string Password, string SignupCode, string? Email); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/SignupResponse.cs b/Femto.Api/Controllers/Auth/SignupResponse.cs deleted file mode 100644 index 638d084..0000000 --- a/Femto.Api/Controllers/Auth/SignupResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Auth; - -public record SignupResponse(Guid UserId, string Username); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 8e195c5..7517d8d 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -4,6 +4,7 @@ using Femto.Modules.Blog.Application; using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Queries.GetPosts; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Femto.Api.Controllers.Posts; @@ -13,6 +14,7 @@ namespace Femto.Api.Controllers.Posts; public class PostsController(IBlogModule blogModule) : ControllerBase { [HttpGet] + [Authorize] public async Task> GetAllPublicPosts( [FromQuery] GetPublicPostsSearchParams searchParams, CancellationToken cancellationToken diff --git a/Femto.Api/CurrentUserContext.cs b/Femto.Api/CurrentUserContext.cs new file mode 100644 index 0000000..1754dbe --- /dev/null +++ b/Femto.Api/CurrentUserContext.cs @@ -0,0 +1,8 @@ +using Femto.Common; + +namespace Femto.Api; + +internal class CurrentUserContext : ICurrentUserContext +{ + public CurrentUser? CurrentUser { get; set; } +} \ No newline at end of file diff --git a/Femto.Api/Femto.Api.csproj b/Femto.Api/Femto.Api.csproj index d5012e6..c61ffdf 100644 --- a/Femto.Api/Femto.Api.csproj +++ b/Femto.Api/Femto.Api.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 31f69d5..50f44b8 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -1,8 +1,14 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Femto.Api; +using Femto.Api.Auth; +using Femto.Common; using Femto.Modules.Auth.Application; using Femto.Modules.Blog.Application; using Femto.Modules.Media.Application; +using Microsoft.AspNetCore.Authentication; + +const string CorsPolicyName = "DefaultCorsPolicy"; var builder = WebApplication.CreateBuilder(args); @@ -20,15 +26,18 @@ builder.Services.InitializeBlogModule(connectionString); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); builder.Services.InitializeAuthenticationModule(connectionString); +builder.Services.AddScoped(); +builder.Services.AddScoped(s => s.GetRequiredService()); + builder.Services.AddControllers(); builder.Services.AddCors(options => { options.AddPolicy( - "DefaultCorsPolicy", + CorsPolicyName, b => { - b.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin(); + b.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:5173"); } ); }); @@ -44,9 +53,18 @@ builder options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString; }); +builder + .Services.AddAuthentication("SessionAuth") + .AddScheme( + "SessionAuth", + options => { } + ); + +builder.Services.AddAuthorization(); // if not already added + var app = builder.Build(); -app.UseCors("DefaultCorsPolicy"); +app.UseCors(CorsPolicyName); if (app.Environment.IsDevelopment()) { diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index 451d401..47bad20 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -12,8 +12,8 @@ internal static class HttpContextSessionExtensions new CookieOptions { HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Strict, + // Secure = true, + // SameSite = SameSiteMode.Strict, Expires = session.Expires, } ); diff --git a/Femto.Common/Domain/DomainError.cs b/Femto.Common/Domain/DomainError.cs new file mode 100644 index 0000000..0bfa541 --- /dev/null +++ b/Femto.Common/Domain/DomainError.cs @@ -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) {} +} \ No newline at end of file diff --git a/Femto.Common/Domain/DomainException.cs b/Femto.Common/Domain/DomainException.cs deleted file mode 100644 index 39dba19..0000000 --- a/Femto.Common/Domain/DomainException.cs +++ /dev/null @@ -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) {} -} \ No newline at end of file diff --git a/Femto.Common/Domain/Entity.cs b/Femto.Common/Domain/Entity.cs index 24ce8a0..be55648 100644 --- a/Femto.Common/Domain/Entity.cs +++ b/Femto.Common/Domain/Entity.cs @@ -16,6 +16,6 @@ public abstract class Entity protected void CheckRule(IRule rule) { if (!rule.Check()) - throw new RuleBrokenException(rule.Message); + throw new RuleBrokenError(rule.Message); } } diff --git a/Femto.Common/Domain/RuleBrokenError.cs b/Femto.Common/Domain/RuleBrokenError.cs new file mode 100644 index 0000000..ce75232 --- /dev/null +++ b/Femto.Common/Domain/RuleBrokenError.cs @@ -0,0 +1,3 @@ +namespace Femto.Common.Domain; + +public class RuleBrokenError(string message) : DomainError(message); \ No newline at end of file diff --git a/Femto.Common/Domain/RuleBrokenException.cs b/Femto.Common/Domain/RuleBrokenException.cs deleted file mode 100644 index 9648642..0000000 --- a/Femto.Common/Domain/RuleBrokenException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Common.Domain; - -public class RuleBrokenException(string message) : DomainException(message); \ No newline at end of file diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs new file mode 100644 index 0000000..3e7dae6 --- /dev/null +++ b/Femto.Common/ICurrentUserContext.cs @@ -0,0 +1,8 @@ +namespace Femto.Common; + +public interface ICurrentUserContext +{ + CurrentUser? CurrentUser { get; } +} + +public record CurrentUser(Guid Id, string Username); diff --git a/Femto.Database/Migrations/20250425121459_Init.sql b/Femto.Database/Migrations/20250425121459_Init.sql index bf09859..b608942 100644 --- a/Femto.Database/Migrations/20250425121459_Init.sql +++ b/Femto.Database/Migrations/20250425121459_Init.sql @@ -53,4 +53,11 @@ CREATE TABLE media.saved_blob uploaded_on timestamp DEFAULT now() NOT NULL, type varchar(64) NOT NULL, size int +); + +CREATE SCHEMA authn; + +CREATE TABLE authn.user_identity +( + ); \ No newline at end of file diff --git a/Femto.Docs/Femto.Docs.csproj b/Femto.Docs/Femto.Docs.csproj index 85b4959..bfd2976 100644 --- a/Femto.Docs/Femto.Docs.csproj +++ b/Femto.Docs/Femto.Docs.csproj @@ -1,10 +1,8 @@  - Exe net9.0 enable enable - diff --git a/Femto.Modules.Auth/Application/AuthenticationStartup.cs b/Femto.Modules.Auth/Application/AuthenticationStartup.cs index ac35662..283ccf5 100644 --- a/Femto.Modules.Auth/Application/AuthenticationStartup.cs +++ b/Femto.Modules.Auth/Application/AuthenticationStartup.cs @@ -35,6 +35,8 @@ public static class AuthenticationStartup builder.EnableSensitiveDataLogging(); }); + services.AddScoped(s => s.GetRequiredService()); + services.AddMediatR(c => { c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly); diff --git a/Femto.Modules.Auth/Application/Commands/Login/LoginCommandHandler.cs b/Femto.Modules.Auth/Application/Commands/Login/LoginCommandHandler.cs index 53bf800..633867f 100644 --- a/Femto.Modules.Auth/Application/Commands/Login/LoginCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Commands/Login/LoginCommandHandler.cs @@ -1,6 +1,5 @@ using Femto.Common.Domain; using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Application.Services; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Models; using MediatR; @@ -8,7 +7,7 @@ using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Application.Commands.Login; -internal class LoginCommandHandler(AuthContext context, SessionGenerator sessionGenerator) +internal class LoginCommandHandler(AuthContext context) : ICommandHandler { public async Task Handle(LoginCommand request, CancellationToken cancellationToken) @@ -19,14 +18,12 @@ internal class LoginCommandHandler(AuthContext context, SessionGenerator session ); if (user is null) - throw new DomainException("invalid credentials"); + throw new DomainError("invalid credentials"); if (!user.HasPassword(request.Password)) - throw new DomainException("invalid credentials"); + throw new DomainError("invalid credentials"); - var session = sessionGenerator.GenerateSession(); - - await context.AddAsync(session, cancellationToken); + var session = user.StartNewSession(); return new(new Session(session.Id, session.Expires), user.Id, user.Username); } diff --git a/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs index 5b8c12a..dad1330 100644 --- a/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Commands/Register/RegisterCommandHandler.cs @@ -1,12 +1,11 @@ using Femto.Common.Domain; using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Application.Services; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application.Commands.Register; -internal class RegisterCommandHandler(AuthContext context, SessionGenerator sessionGenerator) : ICommandHandler +internal class RegisterCommandHandler(AuthContext context) : ICommandHandler { public async Task Handle(RegisterCommand request, CancellationToken cancellationToken) { diff --git a/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommand.cs b/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommand.cs index b1525fb..6f89808 100644 --- a/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommand.cs +++ b/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommand.cs @@ -3,4 +3,8 @@ using Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Commands.ValidateSession; -public record ValidateSessionCommand(string SessionId) : ICommand; \ No newline at end of file +/// +/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future +/// +/// +public record ValidateSessionCommand(string SessionId) : ICommand; diff --git a/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs index 9d60da5..f668831 100644 --- a/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs +++ b/Femto.Modules.Auth/Application/Commands/ValidateSession/ValidateSessionCommandHandler.cs @@ -1,6 +1,7 @@ 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; @@ -21,7 +22,7 @@ internal class ValidateSessionCommandHandler(AuthContext context) ); if (user is null) - throw new DomainException("invalid session"); + throw new InvalidSessionError(); var session = user.StartNewSession(); diff --git a/Femto.Modules.Auth/Application/Services/SessionGenerator.cs b/Femto.Modules.Auth/Application/Services/SessionGenerator.cs deleted file mode 100644 index b536712..0000000 --- a/Femto.Modules.Auth/Application/Services/SessionGenerator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Femto.Modules.Auth.Models; - -namespace Femto.Modules.Auth.Application.Services; - -public class SessionGenerator -{ - public UserSession GenerateSession() - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs index e48296a..03a9bce 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace Femto.Modules.Auth.Data; -internal class AuthContext : DbContext, IOutboxContext +internal class AuthContext(DbContextOptions options) : DbContext(options), IOutboxContext { public virtual DbSet Users { get; } public virtual DbSet Outbox { get; } diff --git a/Femto.Modules.Auth/Errors/InvalidSessionError.cs b/Femto.Modules.Auth/Errors/InvalidSessionError.cs new file mode 100644 index 0000000..4bd0999 --- /dev/null +++ b/Femto.Modules.Auth/Errors/InvalidSessionError.cs @@ -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) { } +} diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index aed0031..c4f9e47 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -14,7 +14,7 @@ internal class UserIdentity : Entity public UserPassword Password { get; private set; } - public ICollection Sessions { get; private set; } + public ICollection Sessions { get; private set; } = []; 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);