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
132 changes: 132 additions & 0 deletions Docs/setup/first-run-setup-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# First-Run Setup Smoke

Date: 2026-07-09

Branch: `feature/first-run-setup-wizard`

## Scope

This smoke validated the first-run setup wizard against a clean Docker Compose stack.

The smoke used the local Docker Compose services:

- WebApp: `http://localhost:5200`
- API: `http://localhost:5100`
- PostgreSQL: Docker Compose `db` service with a fresh `opencashflow_pgdata` volume

No deployment was performed.

## Commands Used

Clean stack:

```bash
docker compose down -v
docker compose up -d --build
```

Fast repeat of the WebApp form path after images were built:

```bash
docker compose down -v
docker compose up -d
```

Health and setup status:

```bash
curl -i http://localhost:5100/health
curl -i http://localhost:5200/
curl -i http://localhost:5200/Login
curl -i http://localhost:5100/v1/Setup/status
```

WebApp setup form:

```bash
curl -sS -c /private/tmp/ocf-web.cookies \
-o /private/tmp/ocf-web-setup.html \
http://localhost:5200/Setup
```

The antiforgery token was read from the setup form and posted back to `POST /Setup` with:

- company name: `Web Smoke Workshop SRL`
- admin email: `web-owner-smoke@example.local`
- admin first name: `Web`
- admin last name: `Owner`
- language: `it`
- currency: `EUR`
- country: `IT`
- timezone: `Europe/Rome`

The temporary password was captured from the setup completion response and was not written to this document.

API login and password change:

```bash
curl -sS -X POST http://localhost:5100/v1/Authentication/login \
-H "Content-Type: application/json" \
-d '{"username":"web-owner-smoke@example.local","password":"<temporary-password>"}'

curl -sS -i -X POST http://localhost:5100/v1/Authentication/change-password-required \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <first-login-token>" \
-d '{"newPassword":"<new-password>"}'

curl -sS -X POST http://localhost:5100/v1/Authentication/login \
-H "Content-Type: application/json" \
-d '{"username":"web-owner-smoke@example.local","password":"<new-password>"}'
```

Log check:

```bash
docker logs opencashflow-api
docker logs opencashflow-webapp
```

The generated temporary password was searched explicitly in API and WebApp logs and was not found.

## Results

| Check | Result |
| --- | --- |
| Clean DB/container state | Passed. `docker compose down -v` removed containers and `opencashflow_pgdata`. |
| Docker Compose startup | Passed. `api`, `webapp`, and `db` started. |
| API health | Passed. `GET /health` returned `{"status":"healthy","database":"ok"}`. |
| WebApp root | Passed. `GET /` returned `302` to `http://localhost:5200/Login`. |
| Unconfigured login redirect | Passed. `GET /Login` returned `302 Location: /Setup`. |
| Setup status before setup | Passed. API returned `requiresSetup=true`, `hasCompanies=false`, `hasAdminUsers=false`. |
| WebApp setup form | Passed. `GET /Setup` returned the first setup page with antiforgery token and no password input fields. |
| Complete setup through WebApp form | Passed. `POST /Setup` returned `200 OK` with the `Setup complete` page. |
| Temporary password shown once | Passed. Password appeared in the setup completion response. After setup, `GET /Setup` returned `302 Location: /Account/Login` and did not replay the password. |
| Login with temporary password | Passed. Login returned `success=true` and `requiresPasswordChange=true`. |
| Change password | Passed. `POST /v1/Authentication/change-password-required` returned `200 OK`. |
| Login with new password | Passed. Login returned `success=true` and `requiresPasswordChange=false`. |
| Setup locked after configuration | Passed. `GET /Setup` redirected to login after configuration. |
| Second setup POST | Passed. `POST /v1/Setup` returned `409 Conflict`. |
| Plaintext generated password in logs | Passed. Exact generated temporary password was not present in API or WebApp logs. |

## Issues Found

The API container logs this startup message:

```text
Cannot load library libgssapi_krb5.so.2
Error: libgssapi_krb5.so.2: cannot open shared object file: No such file or directory
```

The application still started and the health endpoint reported the database as healthy. This should be tracked separately because it creates noisy operational logs and may indicate a missing native package in the runtime image.

During one repeated invalid-login check immediately after several auth attempts, the auth rate limiter returned `503 Service Unavailable`. That is consistent with rate limiting behavior during smoke repetition, not a setup failure.

## Screenshots

No screenshots were captured. The smoke used HTTP checks and saved local response HTML under `/private/tmp` during the run.

## Remaining Gaps

- The smoke did not exercise a full browser UI interaction beyond HTTP form submission.
- The setup wizard currently creates the legacy company-level `CashBalance`, not a persisted Cash Custody `CashAccount`; the Cash Custody persistence model is not implemented yet.
- The `libgssapi_krb5.so.2` startup log should be investigated in a separate Docker/runtime hardening task.
93 changes: 93 additions & 0 deletions Docs/setup/first-run-setup-wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# First-Run Setup Wizard

OpenCashFlow includes a first-run setup wizard for fresh self-hosted installations.

The wizard configures application data only. It does not create PostgreSQL users, create databases, change database permissions, or perform infrastructure provisioning. Database connectivity must already be configured through Docker Compose, environment variables, or the host deployment configuration.

## Local Docker Flow

```bash
cp .env.example .env
docker compose up --build
```

Then open:

```text
http://localhost:5200
```

If no company and no administrator exist, the WebApp redirects to:

```text
/Setup
```

## Setup Inputs

The first-run setup form asks for:

- company name;
- owner/admin first name;
- optional owner/admin last name;
- admin email;
- language;
- currency;
- country;
- timezone.

The setup wizard does not ask for an administrator password. OpenCashFlow generates a strong temporary password server-side.

## What Setup Creates

On a fresh instance, setup creates:

- the first company/tenant;
- the first administrator user;
- standard self-hosted roles;
- the administrator role assignments;
- the company staff link for the administrator;
- the administrator contact email;
- an initial zero cash balance for the company.

Cash Custody domain contracts exist, but there is no persisted multi-cash-account schema yet. Until that implementation lands, setup seeds the current legacy company-level `CashBalance` record rather than a new `CashAccount`.

## Temporary Password

After successful setup, the WebApp shows the generated temporary administrator password exactly once in the setup POST response.

Store it immediately. Refreshing or revisiting setup after completion does not show the password again.

The password is:

- generated server-side with a cryptographic random generator;
- hashed before storage;
- never stored as plaintext;
- never logged intentionally by setup code;
- marked as temporary by setting the first admin to require password change after login.

## Setup Lock

Setup is only available while the instance has no company and no administrator user.

After setup completes:

- `GET /v1/Setup/status` reports `RequiresSetup = false`;
- `POST /v1/Setup` returns a conflict instead of creating another instance;
- the WebApp `/Setup` page redirects to login.

If the database is partially configured, for example a company exists but no admin user exists, setup refuses to continue. That state requires an explicit recovery procedure rather than silent repair.

## First Login

Use the administrator email and the temporary password shown after setup completes.

After login, OpenCashFlow redirects the user to the password-change screen because the first administrator is created with `UserMustChangePassword = true`.

## Security Notes

- Do not expose an unconfigured instance publicly.
- Set real secrets and database credentials before any public or shared deployment.
- Treat `.env.example` as a template only.
- Do not paste generated setup passwords into issue reports, logs, screenshots, or support channels.
- If the temporary password is lost before first login, use a controlled password reset or database recovery procedure.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ Local endpoints:
The default Docker Compose configuration is for local evaluation. Change secrets, database credentials, TLS, backups,
reverse proxy configuration, and operational settings before exposing any instance.

On a fresh database, opening the WebApp redirects to `/Setup`. The first-run wizard creates the first company and admin
user, generates a temporary password, shows it once, and then requires a password change after login. See
[Docs/setup/first-run-setup-wizard.md](Docs/setup/first-run-setup-wizard.md).

### Run Manually

Configure `DEFAULT_CONN_STRING` or `ConnectionStrings:DefaultConnectionString`, then run:
Expand Down
15 changes: 13 additions & 2 deletions src/OpenCashFlow.API/Controllers/SetupController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenCashFlow.Application.Setup.CompleteSetup;
using OpenCashFlow.Application.Setup.GetSetupStatus;
using OpenCashFlow.Contracts.DTOs;
Expand All @@ -23,6 +24,7 @@ public async Task<ActionResult<SetupStatus_DTO>> Status(CancellationToken cancel
}

[HttpPost]
[EnableRateLimiting("auth-limiter")]
public async Task<IActionResult> Create([FromBody] SetupRequest_DTO request, CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
Expand All @@ -33,7 +35,6 @@ public async Task<IActionResult> Create([FromBody] SetupRequest_DTO request, Can
var result = await completeSetupUseCase.ExecuteAsync(new CompleteSetupCommand(
request.CompanyName,
request.AdminEmail,
request.AdminPassword,
request.AdminFirstName,
request.AdminLastName,
request.Language,
Expand All @@ -43,7 +44,7 @@ public async Task<IActionResult> Create([FromBody] SetupRequest_DTO request, Can

if (result.Success && result.Status is not null)
{
return CreatedAtAction(nameof(Status), ToDto(result.Status));
return CreatedAtAction(nameof(Status), ToCompletedDto(result.Status, request.AdminEmail, result.TemporaryAdminPassword!));
}

return result.Failure switch
Expand All @@ -64,5 +65,15 @@ private static SetupStatus_DTO ToDto(SetupStatusResult status)
HasAdminUsers = status.HasAdminUsers
};
}

private static SetupCompleted_DTO ToCompletedDto(SetupStatusResult status, string adminEmail, string temporaryAdminPassword)
{
return new SetupCompleted_DTO
{
Status = ToDto(status),
AdminEmail = adminEmail.Trim(),
TemporaryAdminPassword = temporaryAdminPassword
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ namespace OpenCashFlow.Application.Setup.CompleteSetup;
public sealed record CompleteSetupCommand(
string CompanyName,
string AdminEmail,
string AdminPassword,
string AdminFirstName,
string? AdminLastName,
string Language,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ public sealed record CompleteSetupResult(
bool Success,
CompleteSetupFailure Failure,
string? Message,
SetupStatusResult? Status)
SetupStatusResult? Status,
string? TemporaryAdminPassword)
{
public static CompleteSetupResult Ok(SetupStatusResult status)
=> new(true, CompleteSetupFailure.None, null, status);
public static CompleteSetupResult Ok(SetupStatusResult status, string temporaryAdminPassword)
=> new(true, CompleteSetupFailure.None, null, status, temporaryAdminPassword);

public static CompleteSetupResult Fail(CompleteSetupFailure failure, string message)
=> new(false, failure, message, null);
=> new(false, failure, message, null, null);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using OpenCashFlow.Application.Setup.Ports;
using System.Security.Cryptography;

namespace OpenCashFlow.Application.Setup.CompleteSetup;

Expand Down Expand Up @@ -28,24 +29,52 @@ public async Task<CompleteSetupResult> ExecuteAsync(CompleteSetupCommand command
return CompleteSetupResult.Fail(CompleteSetupFailure.PartiallyConfigured, "Setup cannot continue because this instance is partially configured.");
}

if (!IsStrongPassword(command.AdminPassword))
var temporaryAdminPassword = GenerateTemporaryPassword();
var completedStatus = await setupWriter.CompleteAsync(command, temporaryAdminPassword, cancellationToken);
return CompleteSetupResult.Ok(completedStatus, temporaryAdminPassword);
}

private static string GenerateTemporaryPassword()
{
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const string lower = "abcdefghijkmnopqrstuvwxyz";
const string digits = "23456789";
const string symbols = "!@#$%^&*()-_=+";
const string all = upper + lower + digits + symbols;

Span<char> password =
[
Pick(upper),
Pick(lower),
Pick(digits),
Pick(symbols),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all),
Pick(all)
];

for (var i = password.Length - 1; i > 0; i--)
{
return CompleteSetupResult.Fail(
CompleteSetupFailure.WeakPassword,
"Admin password must be at least 8 characters and include upper, lower, digit, and special characters.");
var j = RandomNumberGenerator.GetInt32(i + 1);
(password[i], password[j]) = (password[j], password[i]);
}

var completedStatus = await setupWriter.CompleteAsync(command, cancellationToken);
return CompleteSetupResult.Ok(completedStatus);
return new string(password);
}

private static bool IsStrongPassword(string password)
private static char Pick(string source)
{
return !string.IsNullOrWhiteSpace(password)
&& password.Length >= 8
&& password.Any(char.IsUpper)
&& password.Any(char.IsLower)
&& password.Any(char.IsDigit)
&& password.Any(ch => !char.IsLetterOrDigit(ch));
return source[RandomNumberGenerator.GetInt32(source.Length)];
}
}
5 changes: 4 additions & 1 deletion src/OpenCashFlow.Application/Setup/Ports/ISetupWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ namespace OpenCashFlow.Application.Setup.Ports;

public interface ISetupWriter
{
Task<SetupStatusResult> CompleteAsync(CompleteSetupCommand command, CancellationToken cancellationToken = default);
Task<SetupStatusResult> CompleteAsync(
CompleteSetupCommand command,
string temporaryAdminPassword,
CancellationToken cancellationToken = default);
}
Loading
Loading