diff --git a/.env.example b/.env.example index 3278c1c..51133b7 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,4 @@ KEYCHAIN_DB_PATH=apps/api/data/keychain.db KEYCHAIN_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 KEYCHAIN_COOKIE_SECURE=false KEYCHAIN_SESSION_TTL_SECONDS=1209600 +KEYCHAIN_EXTENSION_ID= diff --git a/README.md b/README.md index 4b321cc..c2887dc 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ KEYCHAIN_ALLOWED_ORIGINS=http://localhost:5173 \ npm run dev ``` -По умолчанию web-приложение запускается в безопасном `demo mode`, чтобы можно было посмотреть UI без API. Для реальных регистраций выставьте `VITE_DEMO_MODE=false` в `apps/web/.env.local`. Demo mode использует только синтетические записи, не реальные пароли. +По умолчанию web-приложение запускается в безопасном `demo mode`, чтобы можно было посмотреть UI без API. Для реальных регистраций выставьте `VITE_DEMO_MODE=false` в `apps/web/.env.local`. Demo mode использует только синтетические записи, не реальные пароли. Production deployment через PostgreSQL + Docker Compose описан в [`deploy/README.md`](deploy/README.md). Сборка: diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 3f62373..7b56e3c 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -25,6 +25,7 @@ from pydantic import BaseModel, Field, field_validator ROOT = Path(__file__).resolve().parents[1] DB_PATH = Path(os.getenv("KEYCHAIN_DB_PATH", str(ROOT / "data" / "keychain.db"))) DB_PATH.parent.mkdir(parents=True, exist_ok=True) +DATABASE_URL = os.getenv("KEYCHAIN_DATABASE_URL", "").strip() SESSION_TTL = int(os.getenv("KEYCHAIN_SESSION_TTL_SECONDS", "1209600")) COOKIE_SECURE = os.getenv("KEYCHAIN_COOKIE_SECURE", "false").lower() == "true" COOKIE_SAMESITE = "lax" @@ -38,6 +39,14 @@ configured_origins = [ ).split(",") if origin.strip() ] +configured_extension_id = os.getenv("KEYCHAIN_EXTENSION_ID", "").strip() +extension_origin_regex = ( + rf"^chrome-extension://{re.escape(configured_extension_id)}$" if configured_extension_id else None +) + +if DATABASE_URL: + import psycopg + from psycopg.rows import dict_row password_hasher = PasswordHasher() email_pattern = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") @@ -57,24 +66,36 @@ def hash_token(token: str) -> str: @contextmanager -def db() -> Iterator[sqlite3.Connection]: - connection = sqlite3.connect(DB_PATH, timeout=10) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA foreign_keys = ON") +def db() -> Iterator[Any]: + if DATABASE_URL: + connection = psycopg.connect(DATABASE_URL, row_factory=dict_row) + else: + connection = sqlite3.connect(DB_PATH, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") try: yield connection connection.commit() + except BaseException: + connection.rollback() + raise finally: connection.close() +def execute(connection: Any, query: str, params: tuple[Any, ...] = ()) -> Any: + """Keep the small dev SQLite adapter and production psycopg adapter aligned.""" + if DATABASE_URL: + query = query.replace("?", "%s") + return connection.execute(query, params) + + def init_db() -> None: with db() as connection: - connection.executescript( - """ + schema = """ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE COLLATE NOCASE, + email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL @@ -96,8 +117,13 @@ def init_db() -> None: ); CREATE INDEX IF NOT EXISTS sessions_token_idx ON sessions(token_hash); CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id); - """ - ) + """ + if DATABASE_URL: + for statement in schema.split(";"): + if statement.strip(): + execute(connection, statement) + else: + connection.executescript(schema) init_db() @@ -137,7 +163,7 @@ class VaultPayload(BaseModel): return value -def public_user(row: sqlite3.Row) -> dict[str, str]: +def public_user(row: Any) -> dict[str, str]: return { "id": row["id"], "email": row["email"], @@ -162,7 +188,7 @@ def issue_session(response: Response, user_id: str) -> str: now = utc_now() expires = now + timedelta(seconds=SESSION_TTL) with db() as connection: - connection.execute( + execute(connection, "INSERT INTO sessions (id, user_id, token_hash, csrf_hash, created_at, expires_at, last_seen_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", ( @@ -202,15 +228,15 @@ def current_session(request: Request) -> sqlite3.Row: raise HTTPException(status_code=401, detail="Authentication required") now = utc_now() with db() as connection: - session = connection.execute( + session = execute(connection, "SELECT * FROM sessions WHERE token_hash = ?", (hash_token(raw_token),) ).fetchone() if session is None: raise HTTPException(status_code=401, detail="Session is invalid") if datetime.fromisoformat(session["expires_at"]) <= now: - connection.execute("DELETE FROM sessions WHERE id = ?", (session["id"],)) + execute(connection, "DELETE FROM sessions WHERE id = ?", (session["id"],)) raise HTTPException(status_code=401, detail="Session has expired") - connection.execute( + execute(connection, "UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now.isoformat(), session["id"]) ) return session @@ -225,9 +251,9 @@ def require_csrf(request: Request, session: sqlite3.Row) -> None: raise HTTPException(status_code=403, detail="CSRF token is invalid") -def user_for_session(session: sqlite3.Row) -> sqlite3.Row: +def user_for_session(session: Any) -> Any: with db() as connection: - user = connection.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() + user = execute(connection, "SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone() if user is None: raise HTTPException(status_code=401, detail="Account no longer exists") return user @@ -237,7 +263,7 @@ app = FastAPI(title="Keychain API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=configured_origins, - allow_origin_regex=r"^chrome-extension://[a-zA-Z0-9]+$", + allow_origin_regex=extension_origin_regex, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "OPTIONS"], allow_headers=["Content-Type", "X-CSRF-Token"], @@ -256,7 +282,12 @@ async def security_headers(request: Request, call_next): @app.get("/api/health") def health() -> dict[str, str]: - return {"status": "ok"} + try: + with db() as connection: + execute(connection, "SELECT 1").fetchone() + except Exception as error: + raise HTTPException(status_code=503, detail="Database is not ready") from error + return {"status": "ok", "database": "ready"} @app.post("/api/auth/register", status_code=status.HTTP_201_CREATED) @@ -267,21 +298,22 @@ def register(payload: AuthPayload, request: Request, response: Response) -> dict now = iso_now() try: with db() as connection: - connection.execute( + execute(connection, "INSERT INTO users (id, email, display_name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", (user_id, payload.email, display_name, password_hasher.hash(payload.password), now), ) - connection.execute( + execute(connection, "INSERT INTO vaults (user_id, envelope, revision, updated_at) VALUES (?, ?, 0, ?)", (user_id, None, now), ) - except sqlite3.IntegrityError as error: - if "email" in str(error).lower(): + except Exception as error: + error_text = str(error).lower() + if "email" in error_text or "unique" in error_text or "duplicate" in error_text: raise HTTPException(status_code=409, detail="An account with this email already exists") from error raise csrf_token = issue_session(response, user_id) with db() as connection: - user = connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + user = execute(connection, "SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() return {"user": public_user(user), "csrf_token": csrf_token} @@ -289,7 +321,7 @@ def register(payload: AuthPayload, request: Request, response: Response) -> dict def login(payload: AuthPayload, request: Request, response: Response) -> dict[str, Any]: enforce_auth_rate_limit(request) with db() as connection: - user = connection.execute("SELECT * FROM users WHERE email = ?", (payload.email,)).fetchone() + user = execute(connection, "SELECT * FROM users WHERE email = ?", (payload.email,)).fetchone() if user is None: raise HTTPException(status_code=401, detail="Email or password is incorrect") try: @@ -298,7 +330,7 @@ def login(payload: AuthPayload, request: Request, response: Response) -> dict[st raise HTTPException(status_code=401, detail="Email or password is incorrect") from error if password_hasher.check_needs_rehash(user["password_hash"]): with db() as connection: - connection.execute( + execute(connection, "UPDATE users SET password_hash = ? WHERE id = ?", (password_hasher.hash(payload.password), user["id"]), ) @@ -315,7 +347,7 @@ def me(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]: def logout(request: Request, response: Response, session: sqlite3.Row = Depends(current_session)) -> dict[str, str]: require_csrf(request, session) with db() as connection: - connection.execute("DELETE FROM sessions WHERE id = ?", (session["id"],)) + execute(connection, "DELETE FROM sessions WHERE id = ?", (session["id"],)) response.delete_cookie(SESSION_COOKIE, path="/") response.delete_cookie(CSRF_COOKIE, path="/") return {"status": "signed_out"} @@ -325,7 +357,7 @@ def logout(request: Request, response: Response, session: sqlite3.Row = Depends( def get_vault(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]: user = user_for_session(session) with db() as connection: - vault = connection.execute("SELECT * FROM vaults WHERE user_id = ?", (user["id"],)).fetchone() + vault = execute(connection, "SELECT * FROM vaults WHERE user_id = ?", (user["id"],)).fetchone() if vault is None: raise HTTPException(status_code=404, detail="Vault not found") return { @@ -343,7 +375,7 @@ def put_vault( ) -> dict[str, Any]: require_csrf(request, session) with db() as connection: - vault = connection.execute( + vault = execute(connection, "SELECT revision FROM vaults WHERE user_id = ?", (session["user_id"],) ).fetchone() if vault is None: @@ -352,7 +384,7 @@ def put_vault( raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving") new_revision = payload.revision + 1 updated_at = iso_now() - connection.execute( + updated = execute(connection, "UPDATE vaults SET envelope = ?, revision = ?, updated_at = ? WHERE user_id = ?", ( json.dumps(payload.envelope, separators=(",", ":"), ensure_ascii=False), @@ -361,4 +393,6 @@ def put_vault( session["user_id"], ), ) + if updated.rowcount != 1: + raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving") return {"revision": new_revision, "updated_at": updated_at} diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 063226f..6d7ac13 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -1,3 +1,4 @@ fastapi>=0.115,<1.0 uvicorn[standard]>=0.30,<1.0 argon2-cffi>=23.1,<26.0 +psycopg[binary]>=3.2,<4.0 diff --git a/apps/web/.env.production b/apps/web/.env.production new file mode 100644 index 0000000..39932f8 --- /dev/null +++ b/apps/web/.env.production @@ -0,0 +1,2 @@ +VITE_API_URL=/api +VITE_DEMO_MODE=false diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 61cef24..d5e5a08 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,5 +1,3 @@ -@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&display=swap"); - :root { color-scheme: light; --page: #f5f7fb; diff --git a/deploy/Caddyfile.keychain b/deploy/Caddyfile.keychain new file mode 100644 index 0000000..37ba140 --- /dev/null +++ b/deploy/Caddyfile.keychain @@ -0,0 +1,22 @@ +keychain.agent.snw.su { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + Permissions-Policy "camera=(), microphone=(), geolocation=()" + } + + handle /api/* { + reverse_proxy 127.0.0.1:8799 + } + + handle { + root * /srv/keychain/web + try_files {path} /index.html + file_server + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..c6d919c --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,28 @@ +# Production deployment + +The production shape is Docker Compose (PostgreSQL + FastAPI) behind the host Caddy instance. Caddy owns HTTPS and serves the Vue build from `/srv/keychain/web`; the API is only published on `127.0.0.1:8799`. + +The deployment secret file lives outside Git at `/etc/keychain/keychain.env` with mode `0600`. It must contain a random URL-safe `KEYCHAIN_DB_PASSWORD` and may contain `KEYCHAIN_EXTENSION_ID` after the unpacked extension receives its stable ID. + +```sh +install -d -m 0750 /etc/keychain +openssl rand -hex 32 +install -m 0600 /dev/null /etc/keychain/keychain.env +# Put KEYCHAIN_DB_PASSWORD= in the file. + +npm ci +VITE_API_URL=/api VITE_DEMO_MODE=false npm run build --workspace=@keychain/web +install -d -m 0755 /srv/keychain/web +cp -a apps/web/dist/. /srv/keychain/web/ + +docker compose --env-file /etc/keychain/keychain.env -f deploy/docker-compose.prod.yml up -d --build +``` + +Append `deploy/Caddyfile.keychain` as a new block to `/etc/caddy/Caddyfile`, validate, then restart Caddy. Existing Caddy blocks must remain unchanged. Verify: + +```sh +curl -fsS https://keychain.agent.snw.su/api/health +docker compose --env-file /etc/keychain/keychain.env -f deploy/docker-compose.prod.yml ps +``` + +For rollback, restore the previous `/srv/keychain/web` contents and redeploy the previous Git commit. Do not remove the named `keychain-postgres` volume. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..b84da04 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,44 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: keychain + POSTGRES_USER: keychain_app + POSTGRES_PASSWORD: ${KEYCHAIN_DB_PASSWORD:?KEYCHAIN_DB_PASSWORD is required} + volumes: + - keychain-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keychain_app -d keychain"] + interval: 5s + timeout: 5s + retries: 12 + security_opt: + - no-new-privileges:true + + api: + build: + context: ../apps/api + restart: unless-stopped + environment: + KEYCHAIN_ENV: production + KEYCHAIN_DATABASE_URL: postgresql://keychain_app:${KEYCHAIN_DB_PASSWORD}@postgres:5432/keychain + KEYCHAIN_ALLOWED_ORIGINS: https://keychain.agent.snw.su + KEYCHAIN_EXTENSION_ID: ${KEYCHAIN_EXTENSION_ID:-} + KEYCHAIN_COOKIE_SECURE: "true" + KEYCHAIN_SESSION_TTL_SECONDS: "1209600" + depends_on: + postgres: + condition: service_healthy + ports: + - "127.0.0.1:8799:8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"] + interval: 10s + timeout: 5s + retries: 12 + security_opt: + - no-new-privileges:true + +volumes: + keychain-postgres: