-
-
Notifications
You must be signed in to change notification settings - Fork 0
Security.md
This document covers authentication, authorization, secrets management, and security best practices.
OpenCashFlow uses JSON Web Tokens (JWT) for stateless authentication.
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "user-id-guid",
"email": "user@example.com",
"tenant": "company-id-guid",
"role": "Administrator",
"permissions": ["CUST_PAYM_VIEW", "CUST_PAYM_NEW"],
"iat": 1700000000,
"exp": 1700000900,
"iss": "https://api.opencashflow.local",
"aud": "https://app.opencashflow.local"
},
"signature": "..."
}| Token Type | Duration | Purpose |
|---|---|---|
| Access Token | 15 minutes | API authentication |
| Refresh Token | 7 days | Obtain new access tokens |
| Remember Me | 30 days | Extended sessions |
Tokens are stored in HTTP-only cookies to prevent XSS attacks:
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;1. User submits credentials
│
▼
2. API validates credentials against database
│
▼
3. Generate JWT with user claims
│
▼
4. Set HTTP-only cookie with token
│
▼
5. Subsequent requests include cookie automatically
│
▼
6. API validates token signature and expiry
│
▼
7. Request proceeds if valid
Passwords are hashed using PBKDF2 with a unique salt per user:
public class PasswordHasher
{
public (string hash, string salt) HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(32);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
iterations: 100000,
hashAlgorithm: HashAlgorithmName.SHA256,
outputLength: 32
);
return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
}
}- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
MFA support is built into the user model:
public class AspNetUser
{
public bool TwoFactorEnabled { get; set; }
// TOTP secret stored encrypted
}| Role | Description | Typical Permissions |
|---|---|---|
| Administrator | Full company access | All permissions |
| Employee | Standard user | View and create payments |
| GIManagers | System administrators | Cross-tenant access (hidden) |
Users are assigned roles via the AspNetUserRoles join table:
public class AspNetUserRole
{
public Guid UserID { get; set; }
public Guid RoleID { get; set; }
}Permissions follow a hierarchical naming convention:
{SECTION}_{SUBSECTION}_{ACTION}
public static class Permissions
{
// Company Management
public const string COMP = "COMP";
public const string COMP_VIEW = "COMP_VIEW";
public const string COMP_EDIT = "COMP_EDIT";
// Payments
public const string CUST_PAYM = "CUST_PAYM";
public const string CUST_PAYM_VIEW = "CUST_PAYM_VIEW";
public const string CUST_PAYM_NEW = "CUST_PAYM_NEW";
public const string CUST_PAYM_EDIT = "CUST_PAYM_EDIT";
public const string CUST_PAYM_DELE = "CUST_PAYM_DELE";
// Employees
public const string EMPL = "EMPL";
public const string EMPL_VIEW = "EMPL_VIEW";
public const string EMPL_NEW = "EMPL_NEW";
public const string EMPL_EDIT = "EMPL_EDIT";
public const string EMPL_DELE = "EMPL_DELE";
// Billing
public const string BILL = "BILL";
public const string BILL_VIEW = "BILL_VIEW";
public const string BILL_EDIT = "BILL_EDIT";
}Permissions are evaluated in this order:
- Check if user has explicit denied permission → Deny
- Check if user has explicit assigned permission → Allow
- Check if user's role has the permission → Allow/Deny
- Default → Deny
public bool HasPermission(AspNetUser user, string permission)
{
// Check explicit denials first
if (user.DeniedPermissions?.Contains(permission) == true)
return false;
// Check explicit assignments
if (user.AssignedPermissions?.Contains(permission) == true)
return true;
// Check role permissions
return user.Roles.Any(r => r.Permissions.Contains(permission));
}[ApiController]
[Authorize]
public class PaymentController : ControllerBase
{
[HttpGet]
[RequirePermission("CUST_PAYM_VIEW")]
public async Task<IActionResult> GetAll() { }
[HttpPost]
[RequirePermission("CUST_PAYM_NEW")]
public async Task<IActionResult> Create() { }
[HttpDelete("{id}")]
[RequirePermission("CUST_PAYM_DELE")]
public async Task<IActionResult> Delete(Guid id) { }
}The SubscriptionAuthorizationMiddleware ensures users have active subscriptions:
public class SubscriptionAuthorizationMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var tenantId = context.User.GetTenantId();
var subscription = await _subscriptionService.GetActiveAsync(tenantId);
if (subscription == null || subscription.Status != "active")
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new
{
success = false,
message = "Active subscription required"
});
return;
}
await _next(context);
}
}Every database query filters by TenantID to prevent cross-tenant data access:
public async Task<IEnumerable<Payment>> GetPaymentsAsync(Guid tenantId)
{
return await _context.Payment_DS
.Where(p => p.TenantID == tenantId) // Tenant filter
.Where(p => !p.IsDeleted)
.ToListAsync();
}Before any operation, validate the user belongs to the tenant:
private Guid GetCurrentTenantId()
{
var claim = User.FindFirst("tenant");
if (claim == null)
throw new UnauthorizedException("No tenant claim found");
return Guid.Parse(claim.Value);
}These files must never be committed to version control:
| File/Pattern | Contains |
|---|---|
.env |
Environment variables |
appsettings.*.json (local) |
Local configuration |
*.pfx, *.pem, *.key
|
Certificates and keys |
credentials.json |
API credentials |
secrets.json |
User secrets |
# Environment files
.env
.env.local
.env.*.local
# User secrets
secrets.json
# Certificates
*.pfx
*.pem
*.key
*.crt
# Local settings (except Development for reference)
appsettings.Production.json
appsettings.Staging.json
# IDE settings with potential secrets
.idea/
.vs/Rotate these secrets before production deployment:
- JWT Secret Key - Generate new 64+ character key
- Database Password - Use strong, unique password
- Stripe API Keys - Switch from test to live keys
- SMTP Password - Use application-specific password
Store secrets in environment variables, not configuration files:
# Production secrets (set in deployment environment)
export JWTSETTINGS__SECRETKEY="your-production-secret-key"
export DEFAULT_CONN_STRING="Host=prod-db;Password=prod-password"
export STRIPE__SECRETKEY="sk_live_..."builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(allowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});Implement rate limiting to prevent abuse:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.PermitLimit = 100;
opt.QueueLimit = 0;
});
});Always validate input before processing:
public class Payment_Create_DTO
{
[Required]
[Range(0.01, double.MaxValue)]
public decimal Amount { get; set; }
[Required]
[StringLength(10)]
[RegularExpression("^(Income|Outcome)$")]
public string EntryType { get; set; }
[StringLength(500)]
public string? Description { get; set; }
}Always use parameterized queries (EF Core handles this automatically):
// Safe - parameterized
var payments = await _context.Payment_DS
.Where(p => p.TenantID == tenantId)
.ToListAsync();
// NEVER do this - vulnerable to SQL injection
var query = $"SELECT * FROM Payments WHERE TenantID = '{tenantId}'";-
Razor automatically encodes output:
@Model.UserInput <!-- Automatically HTML encoded --> -
For raw HTML (rare), explicitly mark:
@Html.Raw(Model.SafeHtml) <!-- Only for trusted content --> -
Content Security Policy header:
app.Use(async (context, next) => { context.Response.Headers.Add("Content-Security-Policy", "default-src 'self'; script-src 'self'"); await next(); });
if (builder.Environment.IsDevelopment())
{
options.RequireHttpsMetadata = false; // Allow HTTP in development
}// Force HTTPS in production
app.UseHttpsRedirection();
app.UseHsts();
// JWT validation requires HTTPS
options.RequireHttpsMetadata = true;app.Use(async (context, next) =>
{
// Prevent clickjacking
context.Response.Headers.Add("X-Frame-Options", "DENY");
// Prevent MIME type sniffing
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
// Enable XSS filter
context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
// Referrer policy
context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
await next();
});All data changes are tracked:
public class Admin_AuditLog
{
public Guid AuditLogID { get; set; }
public string Action { get; set; } // Create, Update, Delete
public string EntityName { get; set; } // Payment, Company, etc.
public Guid EntityID { get; set; }
public string? OldValues { get; set; } // JSON of previous state
public string? NewValues { get; set; } // JSON of new state
public Guid UserID { get; set; }
public Guid TenantID { get; set; }
public DateTime Timestamp { get; set; }
public string? IPAddress { get; set; }
}- Rotate all default secrets
- Enable HTTPS everywhere
- Configure proper CORS origins
- Set up rate limiting
- Enable security headers
- Review and minimize exposed endpoints
- Ensure proper error handling (no stack traces in production)
- Configure Sentry for error monitoring
- Run OWASP ZAP security scan
- Review all user input validation
- Monitor authentication failures
- Review audit logs regularly
- Keep dependencies updated
- Rotate secrets periodically
- Review access permissions
- Test backup restoration
GitHub Actions includes OWASP ZAP scanning:
# .github/workflows/zap-baseline.yml
- name: OWASP ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.7.0
with:
target: 'https://staging.opencashflow.com'Run manually for full scan:
docker run -t owasp/zap2docker-stable zap-full-scan.py \
-t https://staging.opencashflow.comIf you discover a security vulnerability:
- Do NOT create a public GitHub issue
- Email security concerns to the maintainers directly
- Include steps to reproduce
- Allow time for a fix before public disclosure
See SECURITY.md in the repository root for full details.
Project status
OpenCashFlow is under active development.
APIs, database schema, and UI may change until the first stable release.
Built with
.NET · ASP.NET Core · Entity Framework Core · PostgreSQL · Tabler
© 2026 OpenCashFlow
- Developer Preview
- Not production-ready
- First-run setup included