Security hardening: fix IDOR vulnerabilities, add rate limiting, SSRF protection, and encryption upgrades
- Fix ReconciliationController and BankSync LinkAccount/UpdateLinkedAccount IDOR (verify account ownership) - Add ASP.NET Core rate limiting: strict on auth endpoints, global on all API routes - Harden SSRF validation on Ollama URL (IPv6-mapped bypass, link-local, 0.0.0.0 blocking) - Upgrade encryption from AES-CBC to AES-GCM with HKDF key derivation (backward-compatible decrypt) - Add startup validation that rejects default JWT/encryption keys in Production - Add security headers (X-Frame-Options, CSP, HSTS, Cache-Control, nosniff) to API and nginx - Mask account numbers in API responses (show last 4 digits only) - Add password strength validation (min 8 chars) and BCrypt work factor 12 - Hash refresh tokens (SHA-256) before database storage - Set JWT ClockSkew to zero for immediate expiry enforcement - Add file upload size limit (10 MB) and filename sanitization - Cap transaction search PageSize to 200 - Fix FileWatcherService cross-user account matching in multi-user deployments - Sanitize console.error calls to prevent token leakage in browser logs - Parameterize raw SQL in SimpleFinConnectionMigration - Make AuthService.GenerateAuthResponse fully async Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ services:
|
|||||||
dockerfile: src/Purrse.Api/Dockerfile
|
dockerfile: src/Purrse.Api/Dockerfile
|
||||||
container_name: purrse-api
|
container_name: purrse-api
|
||||||
environment:
|
environment:
|
||||||
- ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=purrse;Username=purrse;Password=${DB_PASSWORD:-purrse_dev}
|
- ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=purrse;Username=purrse;Password=${DB_PASSWORD:-purrse_dev};Ssl Mode=Prefer;Trust Server Certificate=true
|
||||||
- Jwt__Key=${JWT_KEY:-PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!}
|
- Jwt__Key=${JWT_KEY:-PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!}
|
||||||
- Jwt__Issuer=Purrse
|
- Jwt__Issuer=Purrse
|
||||||
- Jwt__Audience=Purrse
|
- Jwt__Audience=Purrse
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Permitted-Cross-Domain-Policies "none" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.plaid.com; connect-src 'self' wss:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ async function loadConversations() {
|
|||||||
conversations.value = data
|
conversations.value = data
|
||||||
conversationsLoaded.value = true
|
conversationsLoaded.value = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load conversations', err)
|
console.error('Failed to load conversations:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ async function loadConversation(id: string) {
|
|||||||
messages.value = data.messages
|
messages.value = data.messages
|
||||||
await scrollToBottom()
|
await scrollToBottom()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load conversation', err)
|
console.error('Failed to load conversation:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ async function deleteConversation(id: string) {
|
|||||||
startNewChat()
|
startNewChat()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete conversation', err)
|
console.error('Failed to delete conversation:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ async function loadConversations() {
|
|||||||
const { data } = await chatApi.getConversations()
|
const { data } = await chatApi.getConversations()
|
||||||
conversations.value = data
|
conversations.value = data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load conversations', err)
|
console.error('Failed to load conversations:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ async function loadConversation(id: string) {
|
|||||||
messages.value = data.messages
|
messages.value = data.messages
|
||||||
await scrollToBottom()
|
await scrollToBottom()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load conversation', err)
|
console.error('Failed to load conversation:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ async function deleteConversation(id: string) {
|
|||||||
startNewChat()
|
startNewChat()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete conversation', err)
|
console.error('Failed to delete conversation:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ async function fetchReport<T>(request: Promise<{ data: T }>): Promise<T | null>
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err?.response?.status
|
const status = err?.response?.status
|
||||||
if (status && status >= 500) {
|
if (status && status >= 500) {
|
||||||
console.error('Report request failed:', err)
|
console.error('Report request failed:', err instanceof Error ? err.message : 'Unknown error')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// 4xx (including 404) treated as no data
|
// 4xx (including 404) treated as no data
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Purrse.Core.DTOs;
|
using Purrse.Core.DTOs;
|
||||||
using Purrse.Core.Interfaces.Services;
|
using Purrse.Core.Interfaces.Services;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
@@ -8,6 +9,7 @@ namespace Purrse.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[EnableRateLimiting("auth")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ public class ImportsController : ControllerBase
|
|||||||
public ImportsController(IImportService importService) => _importService = importService;
|
public ImportsController(IImportService importService) => _importService = importService;
|
||||||
|
|
||||||
[HttpPost("upload")]
|
[HttpPost("upload")]
|
||||||
|
[RequestSizeLimit(10_000_000)] // 10 MB
|
||||||
public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file)
|
public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
if (file == null || file.Length == 0)
|
||||||
return BadRequest(new { error = "No file uploaded" });
|
return BadRequest(new { error = "No file uploaded" });
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await _importService.UploadAsync(UserId, accountId, file.FileName, stream);
|
var safeFileName = Path.GetFileName(file.FileName);
|
||||||
|
var result = await _importService.UploadAsync(UserId, accountId, safeFileName, stream);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ public class ReconciliationController : ControllerBase
|
|||||||
[HttpPost("{accountId}/complete")]
|
[HttpPost("{accountId}/complete")]
|
||||||
public async Task<ActionResult> Complete(Guid accountId, [FromQuery] Guid reconciliationId)
|
public async Task<ActionResult> Complete(Guid accountId, [FromQuery] Guid reconciliationId)
|
||||||
{
|
{
|
||||||
|
// Verify the account belongs to the authenticated user
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId)
|
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId)
|
||||||
?? throw new KeyNotFoundException("Reconciliation not found");
|
?? throw new KeyNotFoundException("Reconciliation not found");
|
||||||
|
|
||||||
@@ -80,6 +84,10 @@ public class ReconciliationController : ControllerBase
|
|||||||
[HttpPost("{accountId}/cancel")]
|
[HttpPost("{accountId}/cancel")]
|
||||||
public async Task<ActionResult> Cancel(Guid accountId, [FromQuery] Guid reconciliationId)
|
public async Task<ActionResult> Cancel(Guid accountId, [FromQuery] Guid reconciliationId)
|
||||||
{
|
{
|
||||||
|
// Verify the account belongs to the authenticated user
|
||||||
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
|
||||||
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted)
|
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted)
|
||||||
?? throw new KeyNotFoundException("Reconciliation not found");
|
?? throw new KeyNotFoundException("Reconciliation not found");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Purrse.Api.Hubs;
|
using Purrse.Api.Hubs;
|
||||||
@@ -33,7 +35,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse",
|
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse",
|
||||||
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "Purrse",
|
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "Purrse",
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
|
|
||||||
// SignalR JWT support
|
// SignalR JWT support
|
||||||
@@ -114,6 +117,48 @@ builder.Services.AddControllers()
|
|||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
// Strict policy for auth endpoints (login, register, change-password)
|
||||||
|
options.AddPolicy("auth", context =>
|
||||||
|
RateLimitPartition.GetSlidingWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new SlidingWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 10,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
SegmentsPerWindow = 2
|
||||||
|
}));
|
||||||
|
|
||||||
|
// General policy for all other API endpoints
|
||||||
|
options.AddPolicy("api", context =>
|
||||||
|
{
|
||||||
|
var userId = context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||||
|
?? context.Connection.RemoteIpAddress?.ToString()
|
||||||
|
?? "unknown";
|
||||||
|
return RateLimitPartition.GetSlidingWindowLimiter(userId, _ => new SlidingWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 120,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
SegmentsPerWindow = 4
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate secrets are not defaults in Production
|
||||||
|
if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
if (jwtKey == "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!")
|
||||||
|
throw new InvalidOperationException("JWT key must be changed from the default value in Production. Set the Jwt:Key configuration or JWT_KEY environment variable.");
|
||||||
|
|
||||||
|
var encryptionKey = builder.Configuration["BankSync:EncryptionKey"];
|
||||||
|
if (encryptionKey == "CHANGE_ME_32_CHAR_KEY_FOR_AES256!")
|
||||||
|
throw new InvalidOperationException("BankSync encryption key must be changed from the default value in Production. Set the BankSync:EncryptionKey configuration.");
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Auto-migrate database
|
// Auto-migrate database
|
||||||
@@ -151,11 +196,24 @@ await pluginService.DiscoverAndLoadPluginsAsync();
|
|||||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||||
|
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||||
|
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
||||||
|
context.Response.Headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
|
||||||
|
context.Response.Headers["Cache-Control"] = "no-store";
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers().RequireRateLimiting("api");
|
||||||
app.MapHub<NotificationHub>("/hubs/notifications");
|
app.MapHub<NotificationHub>("/hubs/notifications");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -114,9 +114,9 @@ public class AccountService : IAccountService
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RecalculateBalanceAsync(Guid accountId)
|
public async Task RecalculateBalanceAsync(Guid userId, Guid accountId)
|
||||||
{
|
{
|
||||||
var account = await _db.Accounts.FindAsync(accountId);
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId);
|
||||||
if (account == null) return;
|
if (account == null) return;
|
||||||
|
|
||||||
var sum = await _db.Transactions
|
var sum = await _db.Transactions
|
||||||
@@ -129,9 +129,16 @@ public class AccountService : IAccountService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static AccountResponse MapToResponse(Account a) => new(
|
private static AccountResponse MapToResponse(Account a) => new(
|
||||||
a.Id, a.Name, a.Type, a.Institution, a.AccountNumber,
|
a.Id, a.Name, a.Type, a.Institution, MaskAccountNumber(a.AccountNumber),
|
||||||
a.Balance, a.CreditLimit, a.InterestRate,
|
a.Balance, a.CreditLimit, a.InterestRate,
|
||||||
a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt,
|
a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt,
|
||||||
a.LoanDetail != null
|
a.LoanDetail != null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private static string? MaskAccountNumber(string? accountNumber)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(accountNumber) || accountNumber.Length <= 4)
|
||||||
|
return accountNumber;
|
||||||
|
return new string('*', accountNumber.Length - 4) + accountNumber[^4..];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public class AiCategorizationService : IAiCategorizationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.IsEnabled = request.IsEnabled;
|
settings.IsEnabled = request.IsEnabled;
|
||||||
|
ValidateOllamaUrl(request.OllamaUrl);
|
||||||
settings.OllamaUrl = request.OllamaUrl;
|
settings.OllamaUrl = request.OllamaUrl;
|
||||||
settings.ModelName = request.ModelName;
|
settings.ModelName = request.ModelName;
|
||||||
settings.ConfidenceThreshold = request.ConfidenceThreshold;
|
settings.ConfidenceThreshold = request.ConfidenceThreshold;
|
||||||
@@ -521,4 +522,63 @@ public class AiCategorizationService : IAiCategorizationService
|
|||||||
|
|
||||||
return new ClassifyUncategorizedResponse(uncategorized.Count, classified, uncategorized.Count - classified);
|
return new ClassifyUncategorizedResponse(uncategorized.Count, classified, uncategorized.Count - classified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateOllamaUrl(string url)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
throw new InvalidOperationException("Invalid Ollama URL");
|
||||||
|
|
||||||
|
if (uri.Scheme != "http" && uri.Scheme != "https")
|
||||||
|
throw new InvalidOperationException("Ollama URL must use http or https");
|
||||||
|
|
||||||
|
var host = uri.Host.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Allow well-known safe hostnames
|
||||||
|
if (host == "localhost" || host == "host.docker.internal")
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Block known dangerous hostnames
|
||||||
|
if (host == "metadata.google.internal")
|
||||||
|
throw new InvalidOperationException("Ollama URL points to a blocked address");
|
||||||
|
|
||||||
|
if (System.Net.IPAddress.TryParse(host, out var ip))
|
||||||
|
{
|
||||||
|
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:169.254.169.254) to IPv4
|
||||||
|
if (ip.IsIPv4MappedToIPv6)
|
||||||
|
ip = ip.MapToIPv4();
|
||||||
|
|
||||||
|
if (!IsAllowedIp(ip))
|
||||||
|
throw new InvalidOperationException("Ollama URL must point to a local or private network address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow Docker service names (simple hostnames without dots)
|
||||||
|
if (!host.Contains('.'))
|
||||||
|
return;
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Ollama URL must point to a local or private network address");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllowedIp(System.Net.IPAddress ip)
|
||||||
|
{
|
||||||
|
// Only allow IPv4 private/loopback — block all public and IPv6 link-local
|
||||||
|
if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
return System.Net.IPAddress.IsLoopback(ip);
|
||||||
|
|
||||||
|
var bytes = ip.GetAddressBytes();
|
||||||
|
|
||||||
|
// Block 0.0.0.0, link-local (169.254.x.x), and any non-private
|
||||||
|
if (bytes[0] == 0) return false; // 0.0.0.0
|
||||||
|
if (bytes[0] == 169 && bytes[1] == 254) return false; // link-local / cloud metadata
|
||||||
|
|
||||||
|
// Allow loopback (127.x.x.x)
|
||||||
|
if (bytes[0] == 127) return true;
|
||||||
|
|
||||||
|
// Allow private ranges: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
|
||||||
|
if (bytes[0] == 10) return true;
|
||||||
|
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||||
|
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
|
public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
|
||||||
{
|
{
|
||||||
|
ValidatePassword(request.Password);
|
||||||
|
|
||||||
if (await _db.Users.AnyAsync(u => u.Username == request.Username))
|
if (await _db.Users.AnyAsync(u => u.Username == request.Username))
|
||||||
throw new InvalidOperationException("Username already exists");
|
throw new InvalidOperationException("Username already exists");
|
||||||
if (await _db.Users.AnyAsync(u => u.Email == request.Email))
|
if (await _db.Users.AnyAsync(u => u.Email == request.Email))
|
||||||
@@ -36,7 +38,7 @@ public class AuthService : IAuthService
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Username = request.Username,
|
Username = request.Username,
|
||||||
Email = request.Email,
|
Email = request.Email,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password, workFactor: 12),
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
await _categoryService.SeedDefaultCategoriesAsync(user.Id);
|
await _categoryService.SeedDefaultCategoriesAsync(user.Id);
|
||||||
|
|
||||||
return GenerateAuthResponse(user);
|
return await GenerateAuthResponseAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResponse> LoginAsync(LoginRequest request)
|
public async Task<AuthResponse> LoginAsync(LoginRequest request)
|
||||||
@@ -59,16 +61,17 @@ public class AuthService : IAuthService
|
|||||||
user.LastLoginAt = DateTime.UtcNow;
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return GenerateAuthResponse(user);
|
return await GenerateAuthResponseAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResponse> RefreshTokenAsync(string refreshToken)
|
public async Task<AuthResponse> RefreshTokenAsync(string refreshToken)
|
||||||
{
|
{
|
||||||
|
var hashedToken = HashRefreshToken(refreshToken);
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u =>
|
var user = await _db.Users.FirstOrDefaultAsync(u =>
|
||||||
u.RefreshToken == refreshToken && u.RefreshTokenExpiresAt > DateTime.UtcNow)
|
u.RefreshToken == hashedToken && u.RefreshTokenExpiresAt > DateTime.UtcNow)
|
||||||
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
|
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
|
||||||
|
|
||||||
return GenerateAuthResponse(user);
|
return await GenerateAuthResponseAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RevokeRefreshTokenAsync(Guid userId)
|
public async Task RevokeRefreshTokenAsync(Guid userId)
|
||||||
@@ -117,7 +120,8 @@ public class AuthService : IAuthService
|
|||||||
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||||
throw new InvalidOperationException("Current password is incorrect");
|
throw new InvalidOperationException("Current password is incorrect");
|
||||||
|
|
||||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
|
ValidatePassword(request.NewPassword);
|
||||||
|
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword, workFactor: 12);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +134,16 @@ public class AuthService : IAuthService
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthResponse GenerateAuthResponse(User user)
|
private async Task<AuthResponse> GenerateAuthResponseAsync(User user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(12);
|
var expiresAt = DateTime.UtcNow.AddHours(12);
|
||||||
var token = GenerateJwtToken(user, expiresAt);
|
var token = GenerateJwtToken(user, expiresAt);
|
||||||
var refreshToken = GenerateRefreshToken();
|
var refreshToken = GenerateRefreshToken();
|
||||||
|
|
||||||
user.RefreshToken = refreshToken;
|
// Store only a hash of the refresh token so a DB breach doesn't expose reusable tokens
|
||||||
|
user.RefreshToken = HashRefreshToken(refreshToken);
|
||||||
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||||
_db.SaveChanges();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName);
|
return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName);
|
||||||
}
|
}
|
||||||
@@ -170,4 +175,16 @@ public class AuthService : IAuthService
|
|||||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||||
return Convert.ToBase64String(bytes);
|
return Convert.ToBase64String(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidatePassword(string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password) || password.Length < 8)
|
||||||
|
throw new InvalidOperationException("Password must be at least 8 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashRefreshToken(string refreshToken)
|
||||||
|
{
|
||||||
|
var bytes = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken));
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,9 +283,16 @@ public class BankSyncService : IBankSyncService
|
|||||||
_db.Accounts.Add(newAccount);
|
_db.Accounts.Add(newAccount);
|
||||||
linkedAccount.AccountId = newAccount.Id;
|
linkedAccount.AccountId = newAccount.Id;
|
||||||
}
|
}
|
||||||
|
else if (request.AccountId.HasValue)
|
||||||
|
{
|
||||||
|
// Verify the target account belongs to the authenticated user
|
||||||
|
var targetAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.AccountId.Value && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
linkedAccount.AccountId = targetAccount.Id;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
linkedAccount.AccountId = request.AccountId;
|
linkedAccount.AccountId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
@@ -305,7 +312,17 @@ public class BankSyncService : IBankSyncService
|
|||||||
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
|
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
|
||||||
?? throw new KeyNotFoundException("Linked account not found");
|
?? throw new KeyNotFoundException("Linked account not found");
|
||||||
|
|
||||||
linkedAccount.AccountId = request.AccountId;
|
// Verify the target account belongs to the authenticated user
|
||||||
|
if (request.AccountId.HasValue)
|
||||||
|
{
|
||||||
|
var targetAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.AccountId.Value && a.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Account not found");
|
||||||
|
linkedAccount.AccountId = targetAccount.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
linkedAccount.AccountId = null;
|
||||||
|
}
|
||||||
linkedAccount.IsEnabled = request.IsEnabled;
|
linkedAccount.IsEnabled = request.IsEnabled;
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ public class ChatService : IChatService
|
|||||||
// Load AI settings
|
// Load AI settings
|
||||||
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||||
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
|
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
|
||||||
|
ValidateOllamaUrl(ollamaUrl);
|
||||||
var modelName = settings?.ModelName ?? "llama3.1:8b";
|
var modelName = settings?.ModelName ?? "llama3.1:8b";
|
||||||
var chatContextSize = settings?.ChatContextSize ?? 16384;
|
var chatContextSize = settings?.ChatContextSize ?? 16384;
|
||||||
var chatBotName = settings?.ChatBotName;
|
var chatBotName = settings?.ChatBotName;
|
||||||
@@ -1281,4 +1282,53 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
return int.TryParse(value?.ToString(), out var result) ? result : null;
|
return int.TryParse(value?.ToString(), out var result) ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateOllamaUrl(string url)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
throw new InvalidOperationException("Invalid Ollama URL");
|
||||||
|
|
||||||
|
if (uri.Scheme != "http" && uri.Scheme != "https")
|
||||||
|
throw new InvalidOperationException("Ollama URL must use http or https");
|
||||||
|
|
||||||
|
var host = uri.Host.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (host == "localhost" || host == "host.docker.internal")
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (host == "metadata.google.internal")
|
||||||
|
throw new InvalidOperationException("Ollama URL points to a blocked address");
|
||||||
|
|
||||||
|
if (System.Net.IPAddress.TryParse(host, out var ip))
|
||||||
|
{
|
||||||
|
if (ip.IsIPv4MappedToIPv6)
|
||||||
|
ip = ip.MapToIPv4();
|
||||||
|
|
||||||
|
if (!IsAllowedIp(ip))
|
||||||
|
throw new InvalidOperationException("Ollama URL must point to a local or private network address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!host.Contains('.'))
|
||||||
|
return;
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Ollama URL must point to a local or private network address");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllowedIp(System.Net.IPAddress ip)
|
||||||
|
{
|
||||||
|
if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
return System.Net.IPAddress.IsLoopback(ip);
|
||||||
|
|
||||||
|
var bytes = ip.GetAddressBytes();
|
||||||
|
|
||||||
|
if (bytes[0] == 0) return false;
|
||||||
|
if (bytes[0] == 169 && bytes[1] == 254) return false;
|
||||||
|
if (bytes[0] == 127) return true;
|
||||||
|
if (bytes[0] == 10) return true;
|
||||||
|
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||||
|
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,28 +7,44 @@ namespace Purrse.Api.Services;
|
|||||||
public class EncryptionService : IEncryptionService
|
public class EncryptionService : IEncryptionService
|
||||||
{
|
{
|
||||||
private readonly byte[] _key;
|
private readonly byte[] _key;
|
||||||
|
private readonly byte[] _legacyKey;
|
||||||
|
|
||||||
|
// Version prefix allows migrating cipher formats without breaking existing data
|
||||||
|
private const byte VersionAesGcm = 0x02;
|
||||||
|
|
||||||
|
private const int GcmNonceSize = 12;
|
||||||
|
private const int GcmTagSize = 16;
|
||||||
|
private const int AesCbcIvSize = 16;
|
||||||
|
|
||||||
public EncryptionService(IConfiguration configuration)
|
public EncryptionService(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var keyString = configuration["BankSync:EncryptionKey"]
|
var keyString = configuration["BankSync:EncryptionKey"]
|
||||||
?? throw new InvalidOperationException("BankSync:EncryptionKey is not configured");
|
?? throw new InvalidOperationException("BankSync:EncryptionKey is not configured");
|
||||||
_key = SHA256.HashData(Encoding.UTF8.GetBytes(keyString));
|
|
||||||
|
// New key: HKDF-derived for AES-GCM
|
||||||
|
var inputKey = Encoding.UTF8.GetBytes(keyString);
|
||||||
|
_key = HKDF.DeriveKey(HashAlgorithmName.SHA256, inputKey, 32, info: Encoding.UTF8.GetBytes("purrse-encryption-v2"));
|
||||||
|
|
||||||
|
// Legacy key: plain SHA256 for decrypting old AES-CBC data
|
||||||
|
_legacyKey = SHA256.HashData(inputKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Encrypt(string plainText)
|
public string Encrypt(string plainText)
|
||||||
{
|
{
|
||||||
using var aes = Aes.Create();
|
|
||||||
aes.Key = _key;
|
|
||||||
aes.GenerateIV();
|
|
||||||
|
|
||||||
using var encryptor = aes.CreateEncryptor();
|
|
||||||
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||||
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
var nonce = RandomNumberGenerator.GetBytes(GcmNonceSize);
|
||||||
|
var cipherBytes = new byte[plainBytes.Length];
|
||||||
|
var tag = new byte[GcmTagSize];
|
||||||
|
|
||||||
// Prepend IV to ciphertext
|
using var aesGcm = new AesGcm(_key, GcmTagSize);
|
||||||
var result = new byte[aes.IV.Length + cipherBytes.Length];
|
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
|
||||||
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
|
|
||||||
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
|
// Format: [version(1)] [nonce(12)] [tag(16)] [ciphertext]
|
||||||
|
var result = new byte[1 + nonce.Length + tag.Length + cipherBytes.Length];
|
||||||
|
result[0] = VersionAesGcm;
|
||||||
|
Buffer.BlockCopy(nonce, 0, result, 1, nonce.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, result, 1 + nonce.Length, tag.Length);
|
||||||
|
Buffer.BlockCopy(cipherBytes, 0, result, 1 + nonce.Length + tag.Length, cipherBytes.Length);
|
||||||
|
|
||||||
return Convert.ToBase64String(result);
|
return Convert.ToBase64String(result);
|
||||||
}
|
}
|
||||||
@@ -37,10 +53,38 @@ public class EncryptionService : IEncryptionService
|
|||||||
{
|
{
|
||||||
var fullCipher = Convert.FromBase64String(cipherText);
|
var fullCipher = Convert.FromBase64String(cipherText);
|
||||||
|
|
||||||
using var aes = Aes.Create();
|
// Version prefix identifies the cipher format
|
||||||
aes.Key = _key;
|
if (fullCipher.Length > 0 && fullCipher[0] == VersionAesGcm)
|
||||||
|
return DecryptAesGcm(fullCipher);
|
||||||
|
|
||||||
var iv = new byte[aes.BlockSize / 8];
|
// No version prefix = legacy AES-CBC data
|
||||||
|
return DecryptLegacyAesCbc(fullCipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DecryptAesGcm(byte[] fullCipher)
|
||||||
|
{
|
||||||
|
var nonce = new byte[GcmNonceSize];
|
||||||
|
var tag = new byte[GcmTagSize];
|
||||||
|
var cipherBytes = new byte[fullCipher.Length - 1 - GcmNonceSize - GcmTagSize];
|
||||||
|
|
||||||
|
Buffer.BlockCopy(fullCipher, 1, nonce, 0, GcmNonceSize);
|
||||||
|
Buffer.BlockCopy(fullCipher, 1 + GcmNonceSize, tag, 0, GcmTagSize);
|
||||||
|
Buffer.BlockCopy(fullCipher, 1 + GcmNonceSize + GcmTagSize, cipherBytes, 0, cipherBytes.Length);
|
||||||
|
|
||||||
|
var plainBytes = new byte[cipherBytes.Length];
|
||||||
|
|
||||||
|
using var aesGcm = new AesGcm(_key, GcmTagSize);
|
||||||
|
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plainBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DecryptLegacyAesCbc(byte[] fullCipher)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = _legacyKey;
|
||||||
|
|
||||||
|
var iv = new byte[AesCbcIvSize];
|
||||||
var cipher = new byte[fullCipher.Length - iv.Length];
|
var cipher = new byte[fullCipher.Length - iv.Length];
|
||||||
Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length);
|
Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length);
|
||||||
Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length);
|
Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length);
|
||||||
|
|||||||
@@ -78,16 +78,37 @@ public class FileWatcherService : BackgroundService
|
|||||||
var importService = scope.ServiceProvider.GetRequiredService<IImportService>();
|
var importService = scope.ServiceProvider.GetRequiredService<IImportService>();
|
||||||
|
|
||||||
// Find the target account by matching filename to account name
|
// Find the target account by matching filename to account name
|
||||||
|
// In multi-user setups, the filename format is "username--accountname.ext"
|
||||||
|
// If no separator, match against all accounts (single-user mode)
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name);
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name);
|
||||||
var account = await db.Accounts
|
string? targetUsername = null;
|
||||||
.Where(a => a.IsActive && !a.IsClosed)
|
string accountMatch = fileNameWithoutExt!;
|
||||||
.FirstOrDefaultAsync(a => a.Name.ToLower() == fileNameWithoutExt!.ToLower());
|
|
||||||
|
|
||||||
// Fall back to first active account
|
if (fileNameWithoutExt!.Contains("--"))
|
||||||
account ??= await db.Accounts
|
{
|
||||||
.Where(a => a.IsActive && !a.IsClosed)
|
var parts = fileNameWithoutExt.Split("--", 2);
|
||||||
.OrderBy(a => a.SortOrder)
|
targetUsername = parts[0];
|
||||||
.FirstOrDefaultAsync();
|
accountMatch = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountQuery = db.Accounts
|
||||||
|
.Include(a => a.User)
|
||||||
|
.Where(a => a.IsActive && !a.IsClosed);
|
||||||
|
|
||||||
|
if (targetUsername != null)
|
||||||
|
accountQuery = accountQuery.Where(a => a.User.Username.ToLower() == targetUsername.ToLower());
|
||||||
|
|
||||||
|
var account = await accountQuery
|
||||||
|
.FirstOrDefaultAsync(a => a.Name.ToLower() == accountMatch.ToLower());
|
||||||
|
|
||||||
|
// Fall back to first active account only if no username filter (single-user mode)
|
||||||
|
if (account == null && targetUsername == null)
|
||||||
|
{
|
||||||
|
account = await db.Accounts
|
||||||
|
.Where(a => a.IsActive && !a.IsClosed)
|
||||||
|
.OrderBy(a => a.SortOrder)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (account == null)
|
if (account == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public static class SimpleFinConnectionMigration
|
|||||||
"CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)");
|
"CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)");
|
||||||
|
|
||||||
var alreadyApplied = await db.Database
|
var alreadyApplied = await db.Database
|
||||||
.SqlQueryRaw<int>($"SELECT 1 AS \"Value\" FROM data_migrations WHERE name = '{MigrationName}'")
|
.SqlQueryRaw<int>("SELECT 1 AS \"Value\" FROM data_migrations WHERE name = {0}", MigrationName)
|
||||||
.AnyAsync();
|
.AnyAsync();
|
||||||
|
|
||||||
if (alreadyApplied)
|
if (alreadyApplied)
|
||||||
@@ -138,6 +138,6 @@ public static class SimpleFinConnectionMigration
|
|||||||
private static async Task MarkCompleteAsync(PurrseDbContext db)
|
private static async Task MarkCompleteAsync(PurrseDbContext db)
|
||||||
{
|
{
|
||||||
await db.Database.ExecuteSqlRawAsync(
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
$"INSERT INTO data_migrations (name, applied_at) VALUES ('{MigrationName}', NOW()) ON CONFLICT DO NOTHING");
|
"INSERT INTO data_migrations (name, applied_at) VALUES ({0}, NOW()) ON CONFLICT DO NOTHING", MigrationName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ public class TransactionService : ITransactionService
|
|||||||
|
|
||||||
private async Task<PagedResult<TransactionResponse>> PaginateAsync(IQueryable<Transaction> query, int page, int pageSize)
|
private async Task<PagedResult<TransactionResponse>> PaginateAsync(IQueryable<Transaction> query, int page, int pageSize)
|
||||||
{
|
{
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 200);
|
||||||
|
page = Math.Max(page, 1);
|
||||||
var totalCount = await query.CountAsync();
|
var totalCount = await query.CountAsync();
|
||||||
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ public interface IAccountService
|
|||||||
Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request);
|
Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request);
|
||||||
Task DeleteAsync(Guid userId, Guid accountId);
|
Task DeleteAsync(Guid userId, Guid accountId);
|
||||||
Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate);
|
Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate);
|
||||||
Task RecalculateBalanceAsync(Guid accountId);
|
Task RecalculateBalanceAsync(Guid userId, Guid accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user