Compare commits

...

3 Commits

Author SHA1 Message Date
the1mason 1bba76b71a Added user list, updated stuff 2025-04-05 03:04:46 +05:00
the1mason f503166d9c Added self profile 2025-04-05 01:20:12 +05:00
the1mason 7a72645437 Full-working auth, adding profile 2025-01-09 22:29:56 +05:00
25 changed files with 536 additions and 100 deletions

View File

@ -6,6 +6,7 @@ namespace FastBlog.Core.Abstractions.Repositories.Users;
public interface IUserRepository
{
Task<User?> Get(int id);
Task<UserPassword?> GetWithPassword(int id);
Task<UserPassword?> GetByName(string username);
Task<bool> SetPassword(int id, string newPassword);
Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest);

View File

@ -91,7 +91,7 @@ public static class DependencyInjection
{
var userService = provider.GetRequiredService<UserService>();
var existingUserResult = await userService.GetUsers(new PagedRequest(1, 0));
if (existingUserResult.Amount is 1)
if (existingUserResult.Amount >= 1)
return;
var newUser = new NewUser

View File

@ -34,4 +34,16 @@
<PackageReference Include="SQLite" Version="3.13.0" />
</ItemGroup>
<ItemGroup>
<Compile Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<None Remove="obj\**" />
</ItemGroup>
</Project>

View File

@ -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);
}

View File

@ -15,16 +15,23 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
return await connection.QueryFirstOrDefaultAsync<User>(sql, new { id });
}
public async Task<UserPassword?> 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<UserPassword>(sql, new { id });
}
public async Task<UserPassword?> 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<UserPassword>(sql, new { username });
}
public async Task<bool> 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<bool> 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;
}

View File

@ -8,16 +8,41 @@ public sealed class UserService(IUserRepository repository)
{
public Task<User?> Get(int id) => repository.Get(id);
public async Task<User?> GetByName(string username)
{
return User.FromUserPassword(await repository.GetByName(username));
}
public async Task<User?> 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<bool> SetPassword(int id, string newPassword) =>
repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword));
public sealed record ChangePasswordResult(bool Success, string Message);
public async Task<ChangePasswordResult> 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<ChangePasswordResult> 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<PagedResponse<User>> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest);

View File

@ -13,6 +13,7 @@ public class FilesController(FileService fileService) : Controller
private const long MaxFileSize = 8L * 1024L * 1024L * 1024L;
[HttpGet]
[SimpleAuth]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(int metaId)
{
var result = await fileService.DeleteFile(metaId);
@ -36,6 +39,7 @@ public class FilesController(FileService fileService) : Controller
[HttpPost]
[SimpleAuth]
public async Task<IActionResult> Upload([FromForm(Name = "sourcePath")] string? sourcePath)
{
// TODO: Validate path to prevent directory traversal

View File

@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
namespace FastBlog.Web.Controllers;
public class TooManyRequestsController : Controller
{
[HttpGet("/too-many-requests")]
public IActionResult Index()
{
return View();
}
}

View File

@ -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<string> Errors);
[HttpGet("/users/login")]
public async Task<IActionResult> Login()
public IActionResult LogIn()
{
if (HttpContext.IsUser())
return Redirect("/");
return View(new LoginModel(null, null, []));
}
[Htmx]
[EnableRateLimiting("fixed")]
[HttpPost("/users/login")]
public async Task<IActionResult> Login(LoginRequest request)
public async Task<IActionResult> LogIn(LoginRequest request)
{
if (HttpContext.IsUser())
return Redirect("/");
List<string> errors = [];
// validation
if (string.IsNullOrWhiteSpace(request.Login)
@ -36,7 +52,7 @@ 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);
@ -48,11 +64,124 @@ public sealed class UsersController(UserService userService, SessionService sess
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);
HttpContext.Response.Headers["HX-Redirect"] = "/";
return Ok();
}
[SimpleAuth]
[Htmx]
[HttpDelete("/users/logout")]
public async ValueTask<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
});
}
}

View File

@ -0,0 +1,9 @@
namespace FastBlog.Web;
/// <summary>
/// Marks methods used by HTMX requests<br/>
/// Use Redirect() with caution!<br/>
/// Does not affect behaviour<br/>
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public sealed class HtmxAttribute : Attribute;

View File

@ -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<User>();
return user is not null;

View File

@ -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<DisplayOptions>(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<SimpleAuthMiddleware>();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Blogs}/{action=Index}/{slug?}");

View File

@ -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)

View File

@ -3,7 +3,7 @@
@{
ViewBag.Title = "Files";
ViewData["Title"] = "Files";
}
<div class="container" id="file-list">
@ -22,15 +22,6 @@
<input type="file" name="file" required/>
<label for="upload-path">Path to upload (optional)</label>
<input type="text" id="upload-path" name="sourcePath" placeholder="/subfolders/"/>
<input type="radio" id="age1" name="age" value="30">
<label for="age1">0 - 30</label><br>
<input type="radio" id="age2" name="age" value="60">
<label for="age2">31 - 60</label><br>
<input type="radio" id="age3" name="age" value="100">
<label for="age3">61 - 100</label><br><br>
<input type="submit" value="Submit">
<button type="submit" class="btn-fw">
Upload
</button>

View File

@ -1,16 +1,14 @@
@using Microsoft.Extensions.Options
@using FastBlog.Web.Middlewares
@using Microsoft.Extensions.Options
@inject IOptions<DisplayOptions> 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;
}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
@ -24,20 +22,52 @@ return;
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
</head>
<body>
<script src="~/js/htmx.min.js"></script>
<header class="nav-container">
<script src="~/lib/htmx/htmx.2.min.js"></script>
<header>
@if (Context.IsUser())
{
<div class="nav-admin">
<nav>
<div>
<li>
<a href="~/edit">New</a>
</li>
<li>
<a href="~/files">Files</a>
</li>
<li>
<a href="~/users">Users</a>
</li>
</div>
<div>
<li>
<a href="/users/me">Profile</a>
</li>
<li>
<a href="#" hx-delete="/users/logout">Logout</a>
</li>
</div>
</nav>
</div>
}
<div class="nav-container">
<nav>
<ul>
<li><a href="~/"><h1>@options.Value.Title</h1></a></li>
<li><a href="~/list">Blog</a></li>
<li><a href="~/edit">New</a></li>
<li><a href="~/files">Files</a></li>
<li>
<a href="~/">
<h1>@options.Value.Title</h1>
</a>
</li>
<li>
<a href="~/list">Blog</a>
</li>
</ul>
<ul>
@foreach (var link in options.Value.Links)
{
<li><a href="@link.Value">@link.Key</a></li>
<li>
<a href="@link.Value">@link.Key</a>
</li>
}
</ul>
</nav>

View File

@ -0,0 +1,12 @@
@{
ViewData["Title"] = "Rate limit exceeded";
}
<div class="container">
<article>
<header>Rate limit exceeded</header>
<div>
<p>You have sent too many requests</p>
</div>
</article>
</div>

View File

@ -0,0 +1,76 @@
@using FastBlog.Web.Helpers
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Users.User>
@{
ViewData["Title"] = "Users";
}
<div class="container" id="user-list">
<br/>
<article>
<header>
<h3>Create a user</h3>
</header>
@if (ViewData.TryGetValue("error", out var errorMsg))
{
<div class="alert alert-caution">@errorMsg</div>
}
<form
hx-post="/users"
hx-swap="outerHTML"
hx-target="#user-list">
<label for="login">Login</label>
<input required type="text" id="login" name="login" placeholder="username"/>
<label for="password">Password</label>
<input required type="password" id="password" name="password"/>
<button type="submit" class="btn-fw">
Create
</button>
</form>
</article>
<article>
<header>
<h3>Manage users</h3>
</header>
<body>
@if (Model.Data.Length is 0)
{
<h4>No users have been found</h4>
}
@foreach (var user in Model.Data)
{
<div class="grid">
<div style="display: flex; font-size: 16px">
<p class="no-margin" style="margin-right: 10px; font-weight: bold">
<a href="~/users/@user.Id/">@user.Username</a>
</p>
</div>
</div>
<hr/>
}
</body>
<footer>
<div>
<button
hx-get="/users?page=@(Model.Offset - 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
Previous
</button>
<button
hx-get="/users?page=@(Model.Offset + 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>
</footer>
</article>
</div>

View File

@ -0,0 +1,37 @@
@model FastBlog.Web.Controllers.UsersController.LoginModel
@{
ViewData["Title"] = "Login";
}
<div id="login-form" class="container">
<article >
<form
hx-post="/users/login"
hx-swap="outerHTML"
hx-target="#login-form">
<div>
<label for="login">Login:</label>
<input type="text" id="login" name="login" value="@Model.Login"
minlength="3"
maxlength="10"
required>
</div>
<div>
<label for="login">Password:</label>
<input type="password" id="password" name="password" value="@Model.Password"
minlength="3"
required>
</div>
<div>
<button type="submit">
<span>Create</span>
</button>
</div>
@foreach (var error in Model.Errors)
{
<div class="alert alert-caution">
<p>@error</p>
</div>
}
</form>
</article>
</div>

View File

@ -1,35 +0,0 @@
@model FastBlog.Web.Controllers.UsersController.LoginModel
@{
ViewBag.Title = "Login";
}
<div id="login-form" class="container">
<form
hx-post="/users/login"
hx-swap="outerHTML"
hx-target="#login-form">
<div>
<label for="login">Login:</label>
<input type="text" id="login" name="login" value="@Model.Login"
minlength="3"
maxlength="10"
required>
</div>
<div>
<label for="login">Password:</label>
<input type="password" id="password" name="password" value="@Model.Login"
minlength="3"
required>
</div>
<div>
<button type="submit">
<span>Create</span>
</button>
</div>
@foreach (var error in Model.Errors)
{
<div class="alert-caution">error</div>
}
</form>
</div>

View File

@ -0,0 +1,86 @@
@using FastBlog.Web.Middlewares
@model FastBlog.Web.Controllers.UsersController.UserViewModel
@{
ViewData["Title"] = $"{Model.User.Username} - Профиль";
bool isSelf = Context.TryGetUser(out var user) && user.Id == Model.User.Id;
}
<div class="container">
<div class="grid">
<article>
<header>
<h2>@Model.User.Username</h2>
</header>
<div class="grid">
@if (isSelf)
{
<p>This is your profile, @Model.User.Username</p>
}
else
{
<div>
<p>Now editing @Model.User.Username's profile</p>
@if (!Model.User.Deleted)
{
<button hx-delete="/users/@Model.User.Id">Delete profile</button>
}
</div>
}
</div>
</article>
@if (isSelf)
{
<article>
<header>Password</header>
<div>
<form action="~/users/@Model.User.Id/change-password" method="post">
<div>
<label for="oldPassword">Old password:</label>
<input type="password" id="oldPassword" name="oldPassword" required>
</div>
<div>
<label for="newPassword">New password:</label>
<input type="password" id="newPassword" name="newPassword" required>
</div>
<div>
<button type="submit">
<span>Update</span>
</button>
</div>
</form>
@if (Model.Message is not null)
{
<div class="alert @(Model.Success ? "alert-tip" : "alert-caution")">
<p>@Model.Message</p>
</div>
}
</div>
</article>
}
else
{
<article>
<header>Password</header>
<div>
<form action="~/users/@Model.User.Id/set-password" method="post">
<div>
<label for="newPassword">New password:</label>
<input type="password" id="newPassword" name="newPassword" required>
</div>
<div>
<button type="submit">
<span>Set</span>
</button>
</div>
</form>
@if (Model.Message is not null)
{
<div class="alert @(Model.Success ? "alert-tip" : "alert-caution")">
<p>@Model.Message</p>
</div>
}
</div>
</article>
}
</div>
</div>

File diff suppressed because one or more lines are too long

View File

@ -93,6 +93,23 @@ body {
margin-bottom: 60px;
}
body>header {
margin: 0;
padding-block: 0;
}
.nav-admin {
margin: 0;
padding: 0 20px;
background: #0a121b;
border-bottom: solid 1px rgba(255, 255, 255, 0.09);
}
.nav-admin li {
margin: 0;
padding: 5px
}
.nav-container {
margin: 0 20px 20px;
padding-top: 0;

File diff suppressed because one or more lines are too long