Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/OpenCashFlow.API/AppStart/05_Auth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ public static WebApplicationBuilder AppStartConfigureAuth(this WebApplicationBui

options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (string.IsNullOrEmpty(context.Token))
{
context.Token = context.Request.Cookies[OpenCashFlow.Contracts.Core.Configuration.AuthCookieName];
}
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
context.Response.StatusCode = 401;
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/AuditLogController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController]
[IgnoreAntiforgeryToken]
[Authorize(Roles = "CompanyAdmin,InstanceAdmin")]
[Route("v{version:apiVersion}/Admin/AuditLog")]
[ApiVersion("1.0")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController]
[IgnoreAntiforgeryToken]
[Route("v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/CompanyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController, Authorize(Policy = "CompanyMember")]
[IgnoreAntiforgeryToken]
[Route("v{version:apiVersion}/")]
[ApiVersion("1.0")]
public partial class CompanyController(ICompanyService CompanyService, ILogger<CompanyController> logger) : Controller
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/DevEmailController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace OpenCashFlow.API.Controllers
{
#if DEBUG
[ApiController]
[IgnoreAntiforgeryToken]
[Route("dev/email")]
[AllowAnonymous]
[ApiExplorerSettings(IgnoreApi = true)]
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/EmployeeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController, Authorize]
[IgnoreAntiforgeryToken]
[Route("v{version:apiVersion}/")]
[ApiVersion("1.0")]
public partial class EmployeeController(IEmployeeService EmployeeService, ILogger<EmployeeController> logger) : Controller
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/PaymentController.Cash.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController]
[IgnoreAntiforgeryToken]
[Authorize(Roles = "CompanyAdmin,InstanceAdmin")]
[Route("v{version:apiVersion}/admin/cash")]
[ApiVersion("1.0")]
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController, Authorize(Policy = "CompanyMember")]
[IgnoreAntiforgeryToken]
[Route("v{version:apiVersion}/")]
[ApiVersion("1.0")]
public partial class PaymentController(IPaymentService PaymentService, IAuditLogService auditLogService, ILogger<PaymentController> logger) : Controller
Expand Down
2 changes: 1 addition & 1 deletion src/OpenCashFlow.API/Controllers/RolesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController, Authorize]
[IgnoreAntiforgeryToken]
[Route("v{version:apiVersion}/")]
[ApiVersion("1.0")]
public class RolesController(IRoleService roleService) : Controller
Expand All @@ -21,4 +22,3 @@ public async Task<ActionResult<IEnumerable<Role_List_DTO>>> GetVisibleRoles(Canc
}
}
}

1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/SetupController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController]
[IgnoreAntiforgeryToken]
[AllowAnonymous]
[Route("v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
Expand Down
1 change: 1 addition & 0 deletions src/OpenCashFlow.API/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
namespace OpenCashFlow.API.Controllers
{
[ApiController]
[IgnoreAntiforgeryToken]
[Authorize(Roles = "CompanyAdmin")]
[Route("v{version:apiVersion}/Admin/Users")]
[ApiVersion("1.0")]
Expand Down
44 changes: 44 additions & 0 deletions src/OpenCashFlow.WebApp/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,50 @@ public async Task<IActionResult> ChangePassword(PasswordVM vm)
return await LoadEditAccountView(userId.Value);
}

[HttpPost]
[ValidateAntiForgeryToken]
[Route("Account/RefreshSession")]
public async Task<IActionResult> RefreshSession(CancellationToken cancellationToken)
{
var tokenResult = await _authAPIService.RegenerateTokenAsync(cancellationToken);
if (!tokenResult.Success || tokenResult.Data == null || string.IsNullOrWhiteSpace(tokenResult.Data.Token))
{
_logger.LogWarning("Session refresh failed: {Message}", tokenResult.Message);
return Unauthorized(new { success = false });
}

var newToken = tokenResult.Data.Token;
var expirationTime = TryGetJwtExpiration(newToken)
?? DateTimeOffset.UtcNow.AddMinutes(OpenCashFlow.Contracts.Core.Configuration.WebSessionDurationMinutes);

var authCookieOptions = new CookieOptions
{
Domain = _configuration["Account:CookieDomain"],
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Expires = expirationTime
};

HttpContext.Response.Cookies.Append(OpenCashFlow.Contracts.Core.Configuration.AuthCookieName, newToken, authCookieOptions);

var infoCookieOptions = new CookieOptions
{
Domain = _configuration["Account:CookieDomain"],
HttpOnly = false,
Secure = true,
SameSite = SameSiteMode.Lax,
Expires = expirationTime
};

HttpContext.Response.Cookies.Append(
OpenCashFlow.Contracts.Core.Configuration.AuthCookieName + ".Info",
expirationTime.ToUnixTimeSeconds().ToString(),
infoCookieOptions);

return Ok(new { success = true });
}

private async Task<IActionResult> LoadEditAccountView(Guid userId)
{
var detail = await _employeeAPIService.GetEmployeeByIDAsync(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public async Task<IActionResult> GetCashLedger(int skip = 0, int take = 50, Canc
public class AdjustRequest { public decimal? Delta { get; set; } public string Reason { get; set; } = string.Empty; }

[HttpPost("CashLedger/Adjust")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Adjust([FromBody] AdjustRequest request, CancellationToken ct)
{
var giClaim = User?.FindFirst("TenantID")?.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public IActionResult GetPaymentsList()
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> FilterPayments(Payment_Filter_DTO filters)
{
try
Expand Down Expand Up @@ -65,6 +66,7 @@ public IActionResult EditPaymentModal(Guid paymentID)
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreatePayment(Payment_Create_DTO dto)
{
if (!ModelState.IsValid)
Expand Down Expand Up @@ -99,6 +101,7 @@ public async Task<IActionResult> CreatePayment(Payment_Create_DTO dto)
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdatePayment(Payment_Update_DTO editDto)
{
if (!ModelState.IsValid)
Expand All @@ -118,6 +121,7 @@ public async Task<IActionResult> UpdatePayment(Payment_Update_DTO editDto)
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeletePayment(Guid paymentID)
{
if (paymentID == Guid.Empty)
Expand Down Expand Up @@ -162,6 +166,7 @@ public async Task<IActionResult> GetCashBalance(CancellationToken cancellationTo
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GetCalendarEvents([FromBody] Payment_Filter_DTO filters)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@
$.ajax({
url: '/Company/CashLedger/Adjust',
method: 'POST',
headers: window.openCashFlowAntiForgeryHeaders
? window.openCashFlowAntiForgeryHeaders()
: {},
contentType: 'application/json',
data: JSON.stringify({ delta: delta, reason: reason }),
success: function (resp) {
Expand Down
9 changes: 9 additions & 0 deletions src/OpenCashFlow.WebApp/Views/Payments/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
</script>
@* <script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script> *@
<script>
function antiForgeryHeaders() {
return window.openCashFlowAntiForgeryHeaders
? window.openCashFlowAntiForgeryHeaders()
: {};
}

window.parseAsUtc = function parseAsUtc(input) {
if (!input) return new Date();

Expand Down Expand Up @@ -578,6 +584,7 @@
$.ajax({
url: '/internal/Payment/DeletePayment',
method: 'POST',
headers: antiForgeryHeaders(),
data: { paymentID: paymentID },
success: function(resp) {
// Re-enable the button and restore the text
Expand Down Expand Up @@ -718,6 +725,7 @@
$.ajax({
url: '/internal/Payment/FilterPayments',
method: 'POST',
headers: antiForgeryHeaders(),
data: formData,
success: function(resp) {
if (resp.success) {
Expand Down Expand Up @@ -862,6 +870,7 @@
$.ajax({
url: '/internal/Payment/FilterPayments',
method: 'POST',
headers: antiForgeryHeaders(),
data: {
FromDate: dateIso + 'T00:00:00',
ToDate: dateIso + 'T23:59:59',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
$.ajax({
url: '/internal/Payment/DeletePayment',
method: 'POST',
headers: window.openCashFlowAntiForgeryHeaders
? window.openCashFlowAntiForgeryHeaders()
: {},
data: { paymentID: paymentID },
success: function (resp) {
if (resp.success) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<environment exclude="Development">
<script src="~/js/tabler.min.js" asp-append-version="true"></script>
</environment>
<script src="~/js/components/antiforgery.js" asp-append-version="true"></script>

@if (User?.Identity?.IsAuthenticated ?? false)
{
Expand All @@ -14,5 +15,5 @@
data-disconnect-url="@Url.Content("~/Account/Disconnect")"
data-auth-info-cookie-name="@(Configuration.AuthCookieName).Info"
data-inactivity-minutes="@(Configuration.WebSessionDurationMinutes)"
data-refresh-url="@($"{_configuration["Account:API"]}/v1/Authentication/refresh")"></script>
data-refresh-url="@Url.Content("~/Account/RefreshSession")"></script>
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<environment exclude="Development">
<script src="~/js/tabler.min.js" asp-append-version="true"></script>
</environment>
<script src="~/js/components/antiforgery.js" asp-append-version="true"></script>

@if (User?.Identity?.IsAuthenticated ?? false)
{
Expand All @@ -14,5 +15,5 @@
data-disconnect-url="@Url.Content("~/Account/Disconnect")"
data-auth-info-cookie-name="@(Configuration.AuthCookieName).Info"
data-inactivity-minutes="@(Configuration.WebSessionDurationMinutes)"
data-refresh-url="@($"{_configuration["Account:API"]}/v1/Authentication/refresh")"></script>
data-refresh-url="@Url.Content("~/Account/RefreshSession")"></script>
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
@inject IHttpContextAccessor httpContextAccessor
@inject IWebHostEnvironment Env
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery

@{
bool isFront = ViewData["isFront"] is bool isFrontValue && isFrontValue;
ViewData["main"] = isFront ? "front-" : "";
ViewData["frontVar"] = isFront ? "Front" : "";
string bodyClass = isFront ? "body-marketing body-gradient" : "";
string? requestVerificationToken = Antiforgery.GetAndStoreTokens(Context).RequestToken;
}

<!DOCTYPE html>
Expand All @@ -25,6 +27,7 @@
<meta name="robots" content="noindex,nofollow,noarchive" />
}
<meta name="description" content="" />
<meta name="request-verification-token" content="@requestVerificationToken" />

<!-- Favicons -->
<link rel="icon" type="image/png" href="~/favicon.png" />
Expand Down
19 changes: 19 additions & 0 deletions src/OpenCashFlow.WebApp/wwwroot/js/components/antiforgery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
(function () {
"use strict";

function getToken() {
const meta = document.querySelector('meta[name="request-verification-token"]');
if (meta && meta.content) {
return meta.content;
}

const input = document.querySelector('input[name="__RequestVerificationToken"]');
return input ? input.value : "";
}

window.openCashFlowAntiForgeryToken = getToken;
window.openCashFlowAntiForgeryHeaders = function () {
const token = getToken();
return token ? { "RequestVerificationToken": token } : {};
};
})();
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@
try {
const response = await fetch(refreshUrl, {
method: "POST",
credentials: "include"
credentials: "same-origin",
headers: window.openCashFlowAntiForgeryHeaders
? window.openCashFlowAntiForgeryHeaders()
: {}
});

if (!response.ok) {
Expand Down
29 changes: 29 additions & 0 deletions tests/OpenCashFlow.Test/Tests/API/Payments_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.DependencyInjection;
using OpenCashFlow.Infrastructure.Persistence;
using OpenCashFlow.Contracts.DTOs;
using OpenCashFlow.Contracts.Core;
using OpenCashFlow.Infrastructure.Persistence.Entities;
using System.Net;
using System.Net.Http.Headers;
Expand Down Expand Up @@ -116,6 +117,34 @@ public async Task CreatePayment_WithoutAuthentication_ShouldFail()

}

[Trait("Layer", "API")]
[Trait("Feature", "Payments")]
[Trait("Type", "Security")]
[Trait("Priority", "High")]
[Fact(DisplayName = "POST /v1/payments should reject JWT supplied only by cookie")]
public async Task CreatePayment_WithJwtOnlyInCookie_ShouldFail()
{
var userId = Guid.Parse("00000000-0000-0000-0000-000000000001");
var token = await _factory.GenerateJwtTokenAsync(userId);
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Cookie", $"{Configuration.AuthCookieName}={token}");

var payment = new Payment_Create_DTO
{
TenantID = Guid.Parse("00000000-0000-0000-0000-000000000001"),
UserID = userId,
Amount = 150,
EntryType = nameof(EntryTypeEnum.Income),
PaymentMethodID = Guid.Parse("00000000-0000-0000-0000-000000000001"),
DocumentTypeID = Guid.Parse("00000000-0000-0000-0000-000000000001"),
Description = "Cookie-only JWT should not authenticate API requests"
};

var response = await client.PostAsJsonAsync("/v1/Payment", payment);

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

// Payment creation [FAIL] (using an unauthorized employee)
[Trait("Layer", "API")]
[Trait("Feature", "Payments")]
Expand Down
Loading