diff --git a/README.md b/README.md index fa39f4b..46a9bf9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ auth: sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}" # >= 32 chars users: - username: admin - passwordHash: "$argon2id$..." # from `triggershell users add` + passwordHash: "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" # from `triggershell users add` tokens: [] database: @@ -104,8 +104,8 @@ the script — always as a discrete argv element or env var, never interpolated | `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) | | `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode | | `triggershell doctor` | Print environment/config diagnostics | -| `triggershell users add ` | Hash a password and print a config snippet for `auth.users` | -| `triggershell users add-token ` | Generate an API token and print a config snippet for `auth.tokens` | +| `triggershell users add [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) | +| `triggershell users add-token [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) | ## Web App Guide @@ -177,8 +177,11 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the pieces fit togeth - Route Handlers check auth themselves (`requireAuth()`); `proxy.ts` is only a fast, optimistic redirect layer, not the security boundary. - `auth.sessionSecret` should live in `.env`, not the config file — `triggershell init` sets this - up for you. Only `passwordHash`/`tokenHash` values (one-way hashes, not the secrets themselves) - belong in the config. + up for you. `passwordHash`/`tokenHash` are one-way hashes (not the secrets themselves), so + storing them directly in the config is reasonably safe — the same trust model as `/etc/shadow` + or `.htpasswd` — but `triggershell users add`/`add-token` store them in `.env` via `${VAR}` by + default too, for cases where you don't want them readable by anyone with config access at all. + Pass `--inline` to get the old behavior of printing the raw hash to paste into the config. ## License diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index aeed6d5..89e1211 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -28,11 +28,17 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f | `enabled` | boolean | `true` | `false` disables login entirely | | `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here | | `sessionTtlHours` | number | `12` | Session cookie lifetime | -| `users` | array | `[]` | `{username, passwordHash}` — hash via `triggershell users add` | -| `tokens` | array | `[]` | `{name, tokenHash}` — hash via `triggershell users add-token` | +| `users` | array | `[]` | `{username, passwordHash}` — generate via `triggershell users add` | +| `tokens` | array | `[]` | `{name, tokenHash}` — generate via `triggershell users add-token` | If `enabled: true`, at least one user or token must be configured. +`passwordHash`/`tokenHash` are one-way hashes, so storing them directly in the config is +reasonably safe (same trust model as `/etc/shadow`). By default `triggershell users add`/ +`add-token` instead store the hash in `.env` and give you a `${VAR}` reference to put in the +config, named `TRIGGERSHELL_USER__PASSWORD_HASH` / `TRIGGERSHELL_TOKEN__HASH` — +pass `--inline` to those commands to get the raw hash printed for pasting into the config instead. + ## `database` | Field | Type | Default | diff --git a/examples/triggershell.yml b/examples/triggershell.yml index 15a2c08..51bbade 100644 --- a/examples/triggershell.yml +++ b/examples/triggershell.yml @@ -13,12 +13,14 @@ auth: sessionSecret: "${TRIGGERSHELL_SESSION_SECRET:-dev-only-insecure-session-secret-change-me-before-deploying}" sessionTtlHours: 12 users: - # Generate with `triggershell users add`. This demo hash is for the password "admin" - - # change it before using this example anywhere but your own machine. + # Generate with `triggershell users add` (add --inline for this raw-hash-in-config style; + # the default instead stores the hash in .env and gives you a ${VAR} reference). This demo + # hash is for the password "admin" - change it before using this example anywhere else. - username: admin passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$zQavMjKmTLUlSSy8e7doRQ$s6uQeh9rc4Jl+l0GHepH4S8zhcqGfIAbvrxk9F3rT4U" tokens: - # Generate with `triggershell users add-token`. This demo token is "ci-bot-demo-token". + # Generate with `triggershell users add-token` (see the --inline note above). This demo + # token is "ci-bot-demo-token". - name: ci-bot tokenHash: "sha256:131709125e3c04e7c1dd59bc840a997a772ba54ff7448184c558ebc550d9d245" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1d5c102 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,60 @@ +from pathlib import Path + +from dotenv import dotenv_values +from typer.testing import CliRunner + +from triggershell.cli import _slug, app + +runner = CliRunner() + + +def test_slug_normalizes_to_env_var_style() -> None: + assert _slug("ci-bot") == "CI_BOT" + assert _slug("Admin User") == "ADMIN_USER" + assert _slug("__weird--name__") == "WEIRD_NAME" + + +def test_users_add_stores_hash_in_dotenv(tmp_path: Path) -> None: + config_path = tmp_path / "triggershell.yml" + config_path.write_text("scripts: []\n") + + result = runner.invoke(app, ["users", "add", "admin", "--config", str(config_path)], input="s3cret\ns3cret\n") + + assert result.exit_code == 0, result.output + env_path = tmp_path / ".env" + assert env_path.exists() + values = dotenv_values(env_path) + assert values["TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH"].startswith("$argon2id$") + assert "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" in result.output + assert "$argon2id$" not in result.output.split("Stored")[0] # raw hash not printed pre-storage + + +def test_users_add_inline_does_not_touch_dotenv(tmp_path: Path) -> None: + config_path = tmp_path / "triggershell.yml" + config_path.write_text("scripts: []\n") + + result = runner.invoke( + app, ["users", "add", "admin", "--config", str(config_path), "--inline"], input="s3cret\ns3cret\n" + ) + + assert result.exit_code == 0, result.output + assert not (tmp_path / ".env").exists() + assert "$argon2id$" in result.output + + +def test_users_add_token_overwrites_existing_var(tmp_path: Path) -> None: + config_path = tmp_path / "triggershell.yml" + config_path.write_text("scripts: []\n") + + first = runner.invoke(app, ["users", "add-token", "ci-bot", "--config", str(config_path)]) + assert first.exit_code == 0, first.output + first_hash = dotenv_values(tmp_path / ".env")["TRIGGERSHELL_TOKEN_CI_BOT_HASH"] + + second = runner.invoke(app, ["users", "add-token", "ci-bot", "--config", str(config_path)]) + assert second.exit_code == 0, second.output + values = dotenv_values(tmp_path / ".env") + second_hash = values["TRIGGERSHELL_TOKEN_CI_BOT_HASH"] + + assert first_hash != second_hash + env_content = (tmp_path / ".env").read_text() + assert env_content.count("TRIGGERSHELL_TOKEN_CI_BOT_HASH") == 1 diff --git a/triggershell/cli.py b/triggershell/cli.py index 8922580..7ae47cd 100644 --- a/triggershell/cli.py +++ b/triggershell/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import os +import re import secrets from pathlib import Path from typing import Optional @@ -9,6 +10,7 @@ from typing import Optional import typer import yaml from argon2 import PasswordHasher +from dotenv import set_key from rich.console import Console from rich.table import Table @@ -267,35 +269,85 @@ def doctor() -> None: console.print(table) +def _slug(value: str) -> str: + return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper() + + +def _print_snippet(heading: str, entry: dict) -> None: + snippet = yaml.safe_dump([entry], sort_keys=False, width=1000) + console.print(f"\n[green]{heading}[/green]\n") + console.print(snippet, markup=False, highlight=False, soft_wrap=True) + + @users_app.command("add") def users_add( username: str = typer.Argument(..., help="Username to create."), - config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file (for display only)."), + config: Optional[Path] = typer.Option( + None, "-c", "--config", help="Path to the config file (used to locate .env)." + ), + inline: bool = typer.Option( + False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env." + ), ) -> None: - """Hash a password with argon2id and print a config snippet to paste under `auth.users`.""" + """Hash a password with argon2id and wire it up for `auth.users`.""" password = typer.prompt("Password", hide_input=True, confirmation_prompt=True) password_hash = PasswordHasher().hash(password) - snippet = yaml.safe_dump([{"username": username, "passwordHash": password_hash}], sort_keys=False, width=1000) - console.print("\n[green]Add this under `auth.users:` in your config file:[/green]\n") - console.print(snippet, markup=False, highlight=False, soft_wrap=True) + if inline: + _print_snippet( + "Add this under `auth.users:` in your config file:", + {"username": username, "passwordHash": password_hash}, + ) + return + + config_path = _resolve_config_path(config) + env_path = config_path.parent / ".env" + var_name = f"TRIGGERSHELL_USER_{_slug(username)}_PASSWORD_HASH" + set_key(str(env_path), var_name, password_hash) + + console.print(f"[green]Stored[/green] {var_name} in {env_path}") + _print_snippet( + "Add this under `auth.users:` in your config file:", + {"username": username, "passwordHash": f"${{{var_name}}}"}, + ) @users_app.command("add-token") def users_add_token( name: str = typer.Argument(..., help="A label for this token, e.g. 'ci-bot'."), + config: Optional[Path] = typer.Option( + None, "-c", "--config", help="Path to the config file (used to locate .env)." + ), + inline: bool = typer.Option( + False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env." + ), ) -> None: - """Generate an API token and print a config snippet to paste under `auth.tokens`.""" + """Generate an API token and wire its hash up for `auth.tokens`.""" token = secrets.token_hex(32) token_hash = f"sha256:{hashlib.sha256(token.encode()).hexdigest()}" - snippet = yaml.safe_dump([{"name": name, "tokenHash": token_hash}], sort_keys=False, width=1000) console.print("\n[yellow]Save this token now - it will not be shown again:[/yellow]") - console.print(f" [bold]{token}[/bold]\n") - console.print("[green]Add this under `auth.tokens:` in your config file:[/green]\n") - console.print(snippet, markup=False, highlight=False, soft_wrap=True) + console.print(f" [bold]{token}[/bold]") console.print(f"Use it as: [dim]Authorization: Bearer {token}[/dim]") + if inline: + _print_snippet( + "Add this under `auth.tokens:` in your config file:", + {"name": name, "tokenHash": token_hash}, + ) + return + + config_path = _resolve_config_path(config) + env_path = config_path.parent / ".env" + var_name = f"TRIGGERSHELL_TOKEN_{_slug(name)}_HASH" + set_key(str(env_path), var_name, token_hash) + + console.print(f"[green]Stored[/green] {var_name} in {env_path}") + _print_snippet( + "Add this under `auth.tokens:` in your config file:", + {"name": name, "tokenHash": f"${{{var_name}}}"}, + ) + if __name__ == "__main__": app()