유대선
프로젝트로
·트러블슈팅·3

Closed an API 2FA bypass and shipped a one-command production deploy

After verifying a separate batch of access-control fixes, I closed the remaining auth gaps — an API-login 2FA bypass with no brute-force limit, example/weak secrets accepted at startup, and a hardcoded admin password — then added a Docker Compose + Caddy stack that auto-migrates and auto-issues TLS for both a dedicated server and an all-in-one install.

The goal this session was concrete: get DocVault to a state where it can actually be deployed for a real user — a friend's Windows PC reporting to a remote server over the internet — without falling over in daily use.

Where things stood

A separate review pass had already produced a batch of access-control fixes: admin routes gated by role, folder permissions enforced on file operations, agent endpoints failing closed, CSV exports rebuilt with encoding/csv. I verified those against the diff and the test suite — go build and go vet clean, go test ./... green (including the previously-failing osquery Windows-path tests) — before building on top of them.

Verification also surfaced one gap those commits did not close.

Bug: the JSON login bypassed 2FA

POST /api/auth/login (internal/auth/handler.go) validated the password and immediately issued full access + refresh tokens. It never checked totp_enabled. The web login flow (internal/web/pages.go) did enforce TOTP, so 2FA looked enabled — but anyone with a password could skip the second factor entirely through the API endpoint. The same handler also had no rate limiting; the login throttle lived only in the web path.

Fix (5e6b325): Login now selects totp_secret/totp_enabled, and when 2FA is on it requires a totp_code checked through ValidateTOTP — returning 401 {"requires_2fa": true} when the code is absent. A new LoginRateLimitMiddleware (internal/web/ratelimit.go) wraps /api/auth/login: it records 401/403 as failures and clears the counter on a 2xx, locking an IP after repeated misses.

Hardening the rest of the startup path

Three smaller but deployment-relevant fixes landed in the same commit:

The actual deploy story

The real blocker for "install it for a friend" was that there was no working one-command deploy. The old docker-compose.yml ran only serve against an empty database (no migration step), shipped the example secrets, and the Dockerfile pinned Go 1.22 while go.mod wants 1.26.1.

The new stack:

Crucially the same compose file plus .env covers two scenarios: a dedicated remote server with a public domain, and — later — an all-in-one install directly on the friend's PC (DOCVAULT_DOMAIN=localhost). Only the agent's DOCVAULT_SERVER_URL changes between them.

Honest status

go build, go vet, and go test ./... all pass. What I could not do here: this environment has no Docker, so docker compose build/up was never run — the container build (and that golang:1.26-alpine exists) still has to be confirmed on the actual server during smoke testing. The 2FA-rejection and rate-limit behaviours are verified at the code/compile level; the end-to-end check — a real 2FA account refused without a code — happens once the stack is up against a live database.

Still open before this is "safe on the public internet": encrypting TOTP secrets and recovery codes at rest, encrypting backups, and the agent-side reliability work (retry, offline queue, periodic re-enroll) so a dropped connection never silently loses events.