From 1bba76b71aa2c659054e12b823f120c7b43490aa Mon Sep 17 00:00:00 2001 From: the1mason Date: Sat, 5 Apr 2025 03:04:46 +0500 Subject: [PATCH] Added user list, updated stuff --- .../Repositories/Users/IUserRepository.cs | 1 + src/FastBlog.Core/DependencyInjection.cs | 2 +- src/FastBlog.Core/FastBlog.Core.csproj | 12 ++ src/FastBlog.Core/Options/SessionOptions.cs | 4 +- .../Repositories/SessionRepository.cs | 2 +- .../Repositories/UserRepository.cs | 14 +- src/FastBlog.Core/Services/UserService.cs | 31 +++- .../Controllers/FilesController.cs | 4 + .../Controllers/TooManyRequestsController.cs | 4 +- .../Controllers/UsersController.cs | 153 ++++++++++++++++-- src/FastBlog.Web/HtmxAttribute.cs | 11 +- .../Middlewares/SimpleAuthMiddleware.cs | 13 +- src/FastBlog.Web/Program.cs | 31 ++++ .../Views/Blogs/Unpublished.cshtml | 2 +- src/FastBlog.Web/Views/Files/Index.cshtml | 11 +- src/FastBlog.Web/Views/Shared/_Layout.cshtml | 60 +++++-- .../Views/TooManyRequests/Index.cshtml | 6 +- src/FastBlog.Web/Views/Users/Index.cshtml | 76 +++++++++ src/FastBlog.Web/Views/Users/LogIn.cshtml | 62 +++---- src/FastBlog.Web/Views/Users/Profile.cshtml | 86 +++++++++- src/FastBlog.Web/wwwroot/css/site.css | 17 ++ 21 files changed, 508 insertions(+), 94 deletions(-) create mode 100644 src/FastBlog.Web/Views/Users/Index.cshtml diff --git a/src/FastBlog.Core/Abstractions/Repositories/Users/IUserRepository.cs b/src/FastBlog.Core/Abstractions/Repositories/Users/IUserRepository.cs index 7dc3f87..11b7a1a 100644 --- a/src/FastBlog.Core/Abstractions/Repositories/Users/IUserRepository.cs +++ b/src/FastBlog.Core/Abstractions/Repositories/Users/IUserRepository.cs @@ -6,6 +6,7 @@ namespace FastBlog.Core.Abstractions.Repositories.Users; public interface IUserRepository { Task Get(int id); + Task GetWithPassword(int id); Task GetByName(string username); Task SetPassword(int id, string newPassword); Task> GetUsers(PagedRequest pagedRequest); diff --git a/src/FastBlog.Core/DependencyInjection.cs b/src/FastBlog.Core/DependencyInjection.cs index de5d005..7c0e5c3 100644 --- a/src/FastBlog.Core/DependencyInjection.cs +++ b/src/FastBlog.Core/DependencyInjection.cs @@ -91,7 +91,7 @@ public static class DependencyInjection { var userService = provider.GetRequiredService(); var existingUserResult = await userService.GetUsers(new PagedRequest(1, 0)); - if (existingUserResult.Amount is 1) + if (existingUserResult.Amount >= 1) return; var newUser = new NewUser diff --git a/src/FastBlog.Core/FastBlog.Core.csproj b/src/FastBlog.Core/FastBlog.Core.csproj index 2c2797d..7fd103d 100644 --- a/src/FastBlog.Core/FastBlog.Core.csproj +++ b/src/FastBlog.Core/FastBlog.Core.csproj @@ -34,4 +34,16 @@ + + + + + + + + + + + + diff --git a/src/FastBlog.Core/Options/SessionOptions.cs b/src/FastBlog.Core/Options/SessionOptions.cs index e13c47d..dd57bf8 100644 --- a/src/FastBlog.Core/Options/SessionOptions.cs +++ b/src/FastBlog.Core/Options/SessionOptions.cs @@ -16,10 +16,10 @@ public class SessionOptions (bool slidingValid, bool absoluteValid) = (true, true); if (EnableAbsoluteExpiration) - slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) < DateTime.UtcNow; + slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) > DateTime.UtcNow; if (EnableSlidingExpiration) - absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) < DateTime.UtcNow; + absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) > DateTime.UtcNow; return (slidingValid, absoluteValid); } diff --git a/src/FastBlog.Core/Repositories/SessionRepository.cs b/src/FastBlog.Core/Repositories/SessionRepository.cs index 49405c9..5cd4a21 100644 --- a/src/FastBlog.Core/Repositories/SessionRepository.cs +++ b/src/FastBlog.Core/Repositories/SessionRepository.cs @@ -19,7 +19,7 @@ public sealed class SessionRepository(SqliteConnectionFactory connectionFactory) """; return await connection.QueryFirstOrDefaultAsync(sql, new { Token = token }); } - + public async Task Set(Session session) { using var connection = connectionFactory.Create(); diff --git a/src/FastBlog.Core/Repositories/UserRepository.cs b/src/FastBlog.Core/Repositories/UserRepository.cs index 1d076df..fa8dfae 100644 --- a/src/FastBlog.Core/Repositories/UserRepository.cs +++ b/src/FastBlog.Core/Repositories/UserRepository.cs @@ -15,16 +15,23 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) : return await connection.QueryFirstOrDefaultAsync(sql, new { id }); } + public async Task GetWithPassword(int id) + { + const string sql = "select * from Users where id = @id and deleted is false"; + using var connection = connectionFactory.Create(); + return await connection.QueryFirstOrDefaultAsync(sql, new { id }); + } + public async Task GetByName(string username) { - const string sql = "select * from Users where username = @username and deleted is false"; + const string sql = "select * from Users where username = @username"; using var connection = connectionFactory.Create(); return await connection.QueryFirstOrDefaultAsync(sql, new { username }); } public async Task SetPassword(int id, string newPassword) { - const string sql = "update Users set passwordHash = @newPassword where id = @id"; + const string sql = "update Users set passwordHash = @newPassword where id = @id and deleted = 0"; using var connection = connectionFactory.Create(); return await connection.ExecuteAsync(sql, new { id, newPassword }) > 0; } @@ -34,6 +41,7 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) : const string sql = """ select COUNT(*) from Users; select * from Users + where deleted = 0 limit @Amount offset @Offset; """; using var connection = connectionFactory.Create(); @@ -53,7 +61,7 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) : public async Task DeleteUser(int userId) { - const string sql = "update Users set deleted = true where id = @userId"; + const string sql = "update Users set deleted = true where id = @userId and deleted = 0"; using var connection = connectionFactory.Create(); return await connection.ExecuteAsync(sql, new { userId }) > 0; } diff --git a/src/FastBlog.Core/Services/UserService.cs b/src/FastBlog.Core/Services/UserService.cs index 355de7f..116d8eb 100644 --- a/src/FastBlog.Core/Services/UserService.cs +++ b/src/FastBlog.Core/Services/UserService.cs @@ -8,16 +8,41 @@ public sealed class UserService(IUserRepository repository) { public Task Get(int id) => repository.Get(id); + public async Task GetByName(string username) + { + return User.FromUserPassword(await repository.GetByName(username)); + } + public async Task GetByCredentials(string username, string password) { var user = await repository.GetByName(username); - if (user is null) return null; + if (user is null || user.Deleted) return null; return !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? null : User.FromUserPassword(user); } - public Task SetPassword(int id, string newPassword) => - repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword)); + public sealed record ChangePasswordResult(bool Success, string Message); + public async Task ChangePassword(int id, string oldPassword, string newPassword) + { + var user = await repository.GetWithPassword(id); + if (user is null) + return new(false, "Unknown user"); + if (!BCrypt.Net.BCrypt.Verify(oldPassword, user.PasswordHash)) + return new(false, "Wrong password"); + if (!await repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword))) + return new(false, "Couldn't update password"); + return new(true, "Successfully updated the password"); + } + + public async Task SetPassword(int id, string newPassword) + { + var user = await repository.GetWithPassword(id); + if (user is null) + return new(false, "Unknown user"); + if (!await repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword))) + return new(false, "Couldn't update password"); + return new(true, "Successfully updated the password"); + } public Task> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest); diff --git a/src/FastBlog.Web/Controllers/FilesController.cs b/src/FastBlog.Web/Controllers/FilesController.cs index 6d28880..774609b 100644 --- a/src/FastBlog.Web/Controllers/FilesController.cs +++ b/src/FastBlog.Web/Controllers/FilesController.cs @@ -13,6 +13,7 @@ public class FilesController(FileService fileService) : Controller private const long MaxFileSize = 8L * 1024L * 1024L * 1024L; [HttpGet] + [SimpleAuth] public async Task Index() { var files = await fileService.GetFiles(new PagedRequest(25, 0)); @@ -20,6 +21,7 @@ public class FilesController(FileService fileService) : Controller } [HttpGet("{offset:int}")] + [SimpleAuth] public async Task Page(int offset) { var files = await fileService.GetFiles(new PagedRequest(25, offset)); @@ -27,6 +29,7 @@ public class FilesController(FileService fileService) : Controller } [HttpDelete("{metaId:int}")] + [SimpleAuth] public async Task Delete(int metaId) { var result = await fileService.DeleteFile(metaId); @@ -36,6 +39,7 @@ public class FilesController(FileService fileService) : Controller [HttpPost] + [SimpleAuth] public async Task Upload([FromForm(Name = "sourcePath")] string? sourcePath) { // TODO: Validate path to prevent directory traversal diff --git a/src/FastBlog.Web/Controllers/TooManyRequestsController.cs b/src/FastBlog.Web/Controllers/TooManyRequestsController.cs index f7a43ee..a3b4c67 100644 --- a/src/FastBlog.Web/Controllers/TooManyRequestsController.cs +++ b/src/FastBlog.Web/Controllers/TooManyRequestsController.cs @@ -2,9 +2,9 @@ namespace FastBlog.Web.Controllers; -public class RateLimitController : Controller +public class TooManyRequestsController : Controller { - [HttpGet("/RateLimited")] + [HttpGet("/too-many-requests")] public IActionResult Index() { return View(); diff --git a/src/FastBlog.Web/Controllers/UsersController.cs b/src/FastBlog.Web/Controllers/UsersController.cs index 1f5d9b0..613cc6b 100644 --- a/src/FastBlog.Web/Controllers/UsersController.cs +++ b/src/FastBlog.Web/Controllers/UsersController.cs @@ -1,25 +1,41 @@ using System.Diagnostics; -using System.Globalization; +using FastBlog.Core.Models; +using FastBlog.Core.Models.Users; using FastBlog.Core.Services; +using FastBlog.Web.Middlewares; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace FastBlog.Web.Controllers; - +[EnableRateLimiting("fixed")] public sealed class UsersController(UserService userService, SessionService sessionService) : Controller { + public UserService UserService { get; } = userService; + public sealed record LoginRequest(string? Login, string? Password); + + public sealed record CreateRequest(string Login, string Password); + public sealed record LoginModel(string? Login, string? Password, List Errors); - + [HttpGet("/users/login")] - public async Task Login() + public IActionResult LogIn() { + if (HttpContext.IsUser()) + return Redirect("/"); + return View(new LoginModel(null, null, [])); } + [Htmx] + [EnableRateLimiting("fixed")] [HttpPost("/users/login")] - public async Task Login(LoginRequest request) + public async Task LogIn(LoginRequest request) { + if (HttpContext.IsUser()) + return Redirect("/"); + List errors = []; // validation if (string.IsNullOrWhiteSpace(request.Login) @@ -36,23 +52,136 @@ public sealed class UsersController(UserService userService, SessionService sess if (errors.Count > 0) { - return View(new LoginModel(request.Login, request.Password, errors)); + return PartialView(new LoginModel(request.Login, request.Password, errors)); } - + Debug.Assert(request.Login is not null); Debug.Assert(request.Password is not null); - + // execute var session = await sessionService.Authenticate(request.Login, request.Password); if (session is null) { errors.Add("Unknown user or incorrect password"); - return View(new LoginModel(request.Login, request.Password, errors)); + return PartialView(new LoginModel(request.Login, request.Password, errors)); } - + UpdateCookie(HttpContext, "session-t", session.Token); - return Redirect("/"); + + HttpContext.Response.Headers["HX-Redirect"] = "/"; + return Ok(); + } + + [SimpleAuth] + [Htmx] + [HttpDelete("/users/logout")] + public async ValueTask LogOut() + { + if (!HttpContext.Request.Cookies.TryGetValue("session-t", out var sessionT)) + return Redirect("/"); + + await sessionService.Deactivate(sessionT); + HttpContext.Response.Cookies.Delete("session-t"); + HttpContext.Response.Headers["HX-Redirect"] = "/"; + return Ok(); + } + + + [HttpGet("/users")] + [SimpleAuth] + public async Task Page([FromQuery] int page = 0) + { + var users = await userService.GetUsers(new PagedRequest(25, page)); + return View("Index", users); + } + + [HttpDelete("/users/{id:int}")] + [SimpleAuth] + public async Task Delete(int id) + { + _ = await userService.DeleteUser(id); + HttpContext.Response.Headers["HX-Redirect"] = "/users"; + return Ok(); + } + + public sealed record UserViewModel(User User, bool Success = true, string? Message = null); + + [SimpleAuth] + [HttpGet("/users/me")] + public IActionResult GetCurrent() + { + if (!HttpContext.TryGetUser(out var user)) + { + return Redirect("/users/login"); + } + + return View("Profile", new UserViewModel(user)); + } + + [SimpleAuth] + [HttpPost("/users")] + public async Task Create(CreateRequest createRequest) + { + if (await userService.GetByName(createRequest.Login) is not null) + { + var u = await userService.GetUsers(new PagedRequest(25, 0)); + var v = View("Index", u); + v.ViewData.Add("error", "User already exists"); + return v; + } + var user = await userService.CreateUser(new NewUser + { + Password = createRequest.Password, + Username = createRequest.Login + }); + var users = await userService.GetUsers(new PagedRequest(25, 0)); + return View("Index", users); + } + + [SimpleAuth] + [HttpGet("/users/{id:int}")] + public async Task Get(int id) + { + var user = await userService.Get(id); + if (user is null) + return Redirect("~/"); + return View("Profile", new UserViewModel(user)); + } + + + public sealed record ChangePasswordModel(string OldPassword, string NewPassword); + + [SimpleAuth] + [HttpPost("/users/{id:int}/change-password")] + public async Task ChangePassword(int id, ChangePasswordModel model) + { + var user = await userService.Get(id); + if (user is null) + return Redirect("~/"); + var result = await userService.ChangePassword(id, model.OldPassword, model.NewPassword); + return View("Profile", new UserViewModel(user, result.Success, result.Message)); + } + + [SimpleAuth] + [HttpPost("/users/{id:int}/set-password")] + public async Task SetPassword(int id, string newPassword) + { + if (!HttpContext.TryGetUser(out var currentUser)) + { + return Redirect("~/"); + } + + if (currentUser.Id == id) + { + return View("Profile", new UserViewModel(currentUser, false, "Provide the old password too")); + } + + var user = await userService.Get(id); + if (user is null) + return Redirect("~/"); + var result = await userService.SetPassword(id, newPassword); + return View("Profile", new UserViewModel(user, result.Success, result.Message)); } private static void UpdateCookie(HttpContext context, string key, string value) @@ -62,8 +191,8 @@ public sealed class UsersController(UserService userService, SessionService sess { HttpOnly = true, SameSite = SameSiteMode.Strict, + Secure = true, IsEssential = true - // TODO: more options w/ configs, ill think bout that later }); } } \ No newline at end of file diff --git a/src/FastBlog.Web/HtmxAttribute.cs b/src/FastBlog.Web/HtmxAttribute.cs index bb708b4..0457e45 100644 --- a/src/FastBlog.Web/HtmxAttribute.cs +++ b/src/FastBlog.Web/HtmxAttribute.cs @@ -1,6 +1,9 @@ namespace FastBlog.Web; -public class HtmxAttribute -{ - -} \ No newline at end of file +/// +/// Marks methods used by HTMX requests
+/// Use Redirect() with caution!
+/// Does not affect behaviour
+///
+[AttributeUsage(AttributeTargets.Method)] +public sealed class HtmxAttribute : Attribute; \ No newline at end of file diff --git a/src/FastBlog.Web/Middlewares/SimpleAuthMiddleware.cs b/src/FastBlog.Web/Middlewares/SimpleAuthMiddleware.cs index 5b0d2e8..c363823 100644 --- a/src/FastBlog.Web/Middlewares/SimpleAuthMiddleware.cs +++ b/src/FastBlog.Web/Middlewares/SimpleAuthMiddleware.cs @@ -1,4 +1,5 @@ -using FastBlog.Core.Models.Users; +using System.Diagnostics.CodeAnalysis; +using FastBlog.Core.Models.Users; using FastBlog.Core.Services; namespace FastBlog.Web.Middlewares; @@ -12,7 +13,7 @@ public class SimpleAuthMiddleware(SessionService service, RequestDelegate next) if (sessionToken is null && meta is not null) { - context.Response.Redirect("/user/login"); + context.Response.Redirect("/"); return; } @@ -24,10 +25,10 @@ public class SimpleAuthMiddleware(SessionService service, RequestDelegate next) var result = await service.Get(sessionToken!); - if (result.IsError) + if (result.IsError || result.AsOk is null) { - context.Response.Cookies.Delete("session"); - context.Response.Redirect("/user/login"); + context.Response.Cookies.Delete("session-t"); + context.Response.Redirect("/users/login"); return; } @@ -46,7 +47,7 @@ internal class SimpleAuthAttribute : Attribute; public static class SimpleAuthExtensions { - public static bool TryGetUser(this HttpContext context, out User? user) + public static bool TryGetUser(this HttpContext context, [NotNullWhen(true)] out User? user) { user = context.Features.Get(); return user is not null; diff --git a/src/FastBlog.Web/Program.cs b/src/FastBlog.Web/Program.cs index 9e1bb97..df5cb94 100644 --- a/src/FastBlog.Web/Program.cs +++ b/src/FastBlog.Web/Program.cs @@ -1,5 +1,7 @@ using FastBlog.Core; using FastBlog.Web; +using FastBlog.Web.Middlewares; +using Microsoft.AspNetCore.RateLimiting; var builder = WebApplication.CreateBuilder(args); @@ -11,8 +13,33 @@ else builder.Services.AddCore(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("Display")); +builder.Services.AddRateLimiter(rateLimiterOptions => +{ + rateLimiterOptions.AddFixedWindowLimiter(policyName: "fixed", options => + { + options.PermitLimit = 30; + options.Window = TimeSpan.FromSeconds(300); + options.QueueLimit = 0; + }) + .OnRejected += (context, _) => + { + context.HttpContext.Response.StatusCode = 200; + + if (context.HttpContext.Request.Headers.ContainsKey("HX-Request")) + { + context.HttpContext.Response.Headers.Append("HX-Redirect", "/too-many-requests"); + } + else + { + context.HttpContext.Response.Redirect("/too-many-requests"); + } + return ValueTask.CompletedTask; + }; +}); + var app = builder.Build(); + if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); @@ -23,8 +50,12 @@ app.UseStaticFiles(); app.UseRouting(); +app.UseRateLimiter(); + app.UseAuthorization(); +app.UseMiddleware(); + app.MapControllerRoute( name: "default", pattern: "{controller=Blogs}/{action=Index}/{slug?}"); diff --git a/src/FastBlog.Web/Views/Blogs/Unpublished.cshtml b/src/FastBlog.Web/Views/Blogs/Unpublished.cshtml index c2a127a..7d0840d 100644 --- a/src/FastBlog.Web/Views/Blogs/Unpublished.cshtml +++ b/src/FastBlog.Web/Views/Blogs/Unpublished.cshtml @@ -1,7 +1,7 @@ @model FastBlog.Core.Models.Blogs.Blog @{ - ViewBag.Title = Model.Metadata.Title; + ViewData["Title"] = Model.Metadata.Title; } @if(DateTime.UtcNow < Model.Metadata.CreatedAt) diff --git a/src/FastBlog.Web/Views/Files/Index.cshtml b/src/FastBlog.Web/Views/Files/Index.cshtml index 540235e..1cde965 100644 --- a/src/FastBlog.Web/Views/Files/Index.cshtml +++ b/src/FastBlog.Web/Views/Files/Index.cshtml @@ -3,7 +3,7 @@ @{ - ViewBag.Title = "Files"; + ViewData["Title"] = "Files"; }
@@ -22,15 +22,6 @@ - - -
- -
- -

- - diff --git a/src/FastBlog.Web/Views/Shared/_Layout.cshtml b/src/FastBlog.Web/Views/Shared/_Layout.cshtml index e89fd30..5a989b4 100644 --- a/src/FastBlog.Web/Views/Shared/_Layout.cshtml +++ b/src/FastBlog.Web/Views/Shared/_Layout.cshtml @@ -1,16 +1,14 @@ -@using Microsoft.Extensions.Options +@using FastBlog.Web.Middlewares +@using Microsoft.Extensions.Options @inject IOptions options; - @{ -ViewContext.HttpContext.Response.Headers.Add("Vary", "Hx-Request"); + ViewContext.HttpContext.Response.Headers.Add("Vary", "Hx-Request"); } - @if (ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true")) { -@RenderBody() -return; + @RenderBody() + return; } - @@ -24,20 +22,52 @@ return; - -