Store password/token hashes in .env by default too
triggershell users add/add-token now generate a TRIGGERSHELL_USER_<name>_
PASSWORD_HASH / TRIGGERSHELL_TOKEN_<name>_HASH variable in .env (creating
or updating it idempotently) and print a ${VAR} snippet to paste into
auth.users/auth.tokens, instead of printing the raw hash. Pass --inline to
get the old behavior, since a hash - unlike sessionSecret - is safe to
store directly in the config (same trust model as /etc/shadow); this just
gives people who don't want it there at all an easy option.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <username>` | Hash a password and print a config snippet for `auth.users` |
|
||||
| `triggershell users add-token <name>` | Generate an API token and print a config snippet for `auth.tokens` |
|
||||
| `triggershell users add <username> [-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 <name> [-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
|
||||
|
||||
|
||||
@@ -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_<USERNAME>_PASSWORD_HASH` / `TRIGGERSHELL_TOKEN_<NAME>_HASH` —
|
||||
pass `--inline` to those commands to get the raw hash printed for pasting into the config instead.
|
||||
|
||||
## `database`
|
||||
|
||||
| Field | Type | Default |
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
+62
-10
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user