diff --git a/.gitignore b/.gitignore index 30bfd99..fd106d0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ htmlcov/ playwright-report/ test-results/ .env +/driving-log-runtime/ *.sqlite3 *.sqlite3-wal *.sqlite3-shm diff --git a/deploy/README.md b/deploy/README.md index 521036a..e59fad2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,7 +1,9 @@ # Rocky deployment -The application listens only on `127.0.0.1:8766`. Tailscale Serve is the -network authorization boundary; there is deliberately no application login. +The application listens only on `127.0.0.1:8766`. When it is exposed through +Tailscale Funnel, its application login is the access boundary. It has two +accounts: Sean Ahern and Jen Ahern. The account that starts or saves a drive is +recorded as that drive's supervising driver. Install the checked-out stack tip: @@ -9,10 +11,24 @@ Install the checked-out stack tip: sudo dnf install -y epel-release sudo dnf install -y python3.13 python3.13-pip sqlite poppler-utils ./driving-log bootstrap -./driving-log install --public-host HOSTNAME:8443 --public-scheme http +./driving-log install --public-scheme https loginctl enable-linger "$USER" +``` + +`install` finds this machine's Tailscale DNS name, creates the git-ignored +`driving-log-runtime/` directory, copies a legacy home-directory log there if +one exists, writes the private environment file, and installs/reloads the user +systemd units. It does not start the service yet. + +Before starting, set a password for each account. Each command prompts twice; +the tool salts and hashes the password directly into the private environment +file, so nothing needs to be copied or pasted: + +```sh +./driving-log --set-password Sean +./driving-log --set-password Jen ./driving-log start -tailscale serve --bg --http=8443 http://127.0.0.1:8766 +tailscale funnel --bg --https=8443 http://127.0.0.1:8766 ``` Re-running `./driving-log install` after an update reloads the units and restarts @@ -20,14 +36,17 @@ the web service and archive timer when they are already active. This keeps the running application schema support in step with migrations performed by one-shot commands. -Before changing Serve, save `tailscale serve status --json` and verify the -existing HTTP port 80 handler still forwards to Wordle on -`127.0.0.1:8765`. Never use Funnel and never bind this application to a LAN or -tailnet address. - -The environment file is mode `0600` at -`~/.config/driving-log/environment`. Mutable state is mode `0700` beneath -`~/.local/state/driving-log`; the database and archives are mode `0600`. +Before changing Funnel, save `tailscale serve status --json` and verify the +existing HTTP port 80 handler still forwards to Wordle on `127.0.0.1:8765`. +Never bind this application to a LAN or tailnet address. The service refuses to +start unless both password hashes are configured; the installer creates the +separate session-signing secret automatically. + +The git-ignored `driving-log-runtime/` directory holds the mode-`0600` +environment file and database, plus mode-`0700` archive and restore-request +directories. The installer safely copies an existing legacy home-directory +database and its archives there once; it leaves the original intact as a +fallback. Configure `DRIVING_LOG_EXTERNAL_ARCHIVE_DIR` to protect against loss of the Rocky disk. diff --git a/deploy/systemd/driving-log-archive.service b/deploy/systemd/driving-log-archive.service index d22f55d..2e27320 100644 --- a/deploy/systemd/driving-log-archive.service +++ b/deploy/systemd/driving-log-archive.service @@ -4,7 +4,7 @@ Description=Create and verify Daniel driving-log archive [Service] Type=oneshot WorkingDirectory=%h/work/driving-record -EnvironmentFile=-%h/.config/driving-log/environment +EnvironmentFile=-%h/work/driving-record/driving-log-runtime/environment ExecStart=%h/work/driving-record/driving-log archive create ExecStart=%h/work/driving-record/driving-log archive replicate NoNewPrivileges=true diff --git a/deploy/systemd/driving-log-restore@.service b/deploy/systemd/driving-log-restore@.service index 761758f..cb45e39 100644 --- a/deploy/systemd/driving-log-restore@.service +++ b/deploy/systemd/driving-log-restore@.service @@ -5,11 +5,11 @@ Conflicts=driving-log-web.service [Service] Type=oneshot WorkingDirectory=%h/work/driving-record -EnvironmentFile=-%h/.config/driving-log/environment +EnvironmentFile=-%h/work/driving-record/driving-log-runtime/environment ExecStart=%h/work/driving-record/driving-log archive restore-request %i ExecStopPost=systemctl --user start driving-log-web.service NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict -ReadWritePaths=%h/.local/state/driving-log +ReadWritePaths=%h/work/driving-record/driving-log-runtime UMask=0077 diff --git a/deploy/systemd/driving-log-web.service b/deploy/systemd/driving-log-web.service index c8c8eab..7200841 100644 --- a/deploy/systemd/driving-log-web.service +++ b/deploy/systemd/driving-log-web.service @@ -5,14 +5,14 @@ After=network.target tailscaled.service [Service] Type=simple WorkingDirectory=%h/work/driving-record -EnvironmentFile=-%h/.config/driving-log/environment +EnvironmentFile=-%h/work/driving-record/driving-log-runtime/environment ExecStart=%h/work/driving-record/driving-log serve Restart=on-failure RestartSec=3 NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict -ReadWritePaths=%h/.local/state/driving-log +ReadWritePaths=%h/work/driving-record/driving-log-runtime UMask=0077 [Install] diff --git a/driving_log/app.py b/driving_log/app.py index df78091..a3427be 100644 --- a/driving_log/app.py +++ b/driving_log/app.py @@ -21,6 +21,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: async def lifespan(_: FastAPI) -> AsyncIterator[None]: configure_logging() selected.ensure_directories() + selected.validate_authentication() with application_lock(selected.state_dir, exclusive=False): # Liveness remains available while readiness explains the safe refusal. with suppress(Exception): diff --git a/driving_log/auth.py b/driving_log/auth.py new file mode 100644 index 0000000..1bb8507 --- /dev/null +++ b/driving_log/auth.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +import time +from dataclasses import dataclass + +COOKIE_NAME = "driving_log_session" +SESSION_LIFETIME_SECONDS = 60 * 60 * 24 * 30 +ACCOUNTS = {"sean": "Sean Ahern", "jen": "Jen Ahern"} +SCRYPT_N = 2**17 +SCRYPT_MAXMEM = 256 * 1024 * 1024 + + +@dataclass(frozen=True) +class Authenticator: + sean_password_hash: str + jen_password_hash: str + session_secret: str + + def authenticate(self, account: str, password: str) -> str | None: + password_hash = { + "sean": self.sean_password_hash, + "jen": self.jen_password_hash, + }.get(account) + if not password_hash or not verify_password(password, password_hash): + return None + return ACCOUNTS[account] + + def make_session(self, user: str) -> str: + payload = { + "exp": int(time.time()) + SESSION_LIFETIME_SECONDS, + "nonce": secrets.token_urlsafe(16), + "user": user, + "credential": self._credential_fingerprint(user), + } + encoded = _encode(payload) + signature = hmac.new( + self.session_secret.encode(), encoded.encode(), hashlib.sha256 + ).hexdigest() + return f"{encoded}.{signature}" + + def session_user(self, token: str | None) -> str | None: + if not token or "." not in token: + return None + encoded, supplied_signature = token.rsplit(".", 1) + expected_signature = hmac.new( + self.session_secret.encode(), encoded.encode(), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(supplied_signature, expected_signature): + return None + try: + payload = json.loads(_decode(encoded)) + user = payload["user"] + expires_at = payload["exp"] + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return None + if ( + not isinstance(user, str) + or user not in ACCOUNTS.values() + or not isinstance(expires_at, int) + or expires_at < time.time() + or payload.get("credential") != self._credential_fingerprint(user) + ): + return None + return user + + def _credential_fingerprint(self, user: str) -> str: + password_hash = { + "Sean Ahern": self.sean_password_hash, + "Jen Ahern": self.jen_password_hash, + }[user] + return hashlib.sha256(password_hash.encode()).hexdigest()[:16] + + +def password_hash(password: str) -> str: + if not password: + raise ValueError("password must not be empty") + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + password.encode(), salt=salt, n=SCRYPT_N, r=8, p=1, maxmem=SCRYPT_MAXMEM + ) + salt_text = base64.urlsafe_b64encode(salt).decode() + digest_text = base64.urlsafe_b64encode(digest).decode() + return f"scrypt${SCRYPT_N}$8$1${salt_text}${digest_text}" + + +def verify_password(password: str, encoded: str) -> bool: + try: + algorithm, n_text, r_text, p_text, salt_text, digest_text = encoded.split("$") + if algorithm != "scrypt": + return False + salt = base64.urlsafe_b64decode(salt_text) + expected = base64.urlsafe_b64decode(digest_text) + actual = hashlib.scrypt( + password.encode(), + salt=salt, + n=int(n_text), + r=int(r_text), + p=int(p_text), + dklen=len(expected), + maxmem=SCRYPT_MAXMEM, + ) + except (ValueError, TypeError): + return False + return hmac.compare_digest(actual, expected) + + +def _encode(value: dict[str, object]) -> str: + return ( + base64.urlsafe_b64encode(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + .decode() + .rstrip("=") + ) + + +def _decode(value: str) -> str: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode() diff --git a/driving_log/cli.py b/driving_log/cli.py index 488144e..1e1090f 100644 --- a/driving_log/cli.py +++ b/driving_log/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import getpass import json import os import shutil @@ -11,6 +12,7 @@ from driving_log import __version__ from driving_log.archive import create_archive, restore_archive, verify_archive +from driving_log.auth import password_hash from driving_log.config import Settings from driving_log.csv_backup import export_csv, import_csv from driving_log.db import Database @@ -31,7 +33,8 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="driving-log") parser.add_argument("--version", action="version", version=__version__) - sub = parser.add_subparsers(dest="command", required=True) + parser.add_argument("--set-password", choices=("Sean", "Jen"), metavar="NAME") + sub = parser.add_subparsers(dest="command") serve = sub.add_parser("serve") serve.add_argument("--host") serve.add_argument("--port", type=int) @@ -42,7 +45,7 @@ def build_parser() -> argparse.ArgumentParser: for command in ("start", "stop", "restart", "status", "logs"): sub.add_parser(command) install = sub.add_parser("install") - install.add_argument("--public-host", required=True) + install.add_argument("--public-host") install.add_argument("--public-scheme", choices=("http", "https"), default="http") install.add_argument("--external-archive-dir", type=Path) live = sub.add_parser("live") @@ -186,13 +189,62 @@ def doctor(settings: Settings) -> dict[str, object]: return result +def tailscale_public_host() -> str: + status = subprocess.run( + ["tailscale", "status", "--json"], text=True, capture_output=True, check=False + ) + if status.returncode: + raise ValueError("could not read Tailscale status; pass --public-host HOST:8443") + try: + dns_name = json.loads(status.stdout)["Self"]["DNSName"] + except (KeyError, TypeError, json.JSONDecodeError) as exc: + raise ValueError( + "could not find the Tailscale DNS name; pass --public-host HOST:8443" + ) from exc + if not isinstance(dns_name, str) or not dns_name: + raise ValueError("could not find the Tailscale DNS name; pass --public-host HOST:8443") + return f"{dns_name.rstrip('.')}:8443" + + def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) + parser = build_parser() + args = parser.parse_args(argv) settings = Settings.from_env() + if args.set_password: + if args.command: + parser.error("--set-password cannot be combined with another command") + password = getpass.getpass(f"New password for {args.set_password}: ") + confirmation = getpass.getpass("Confirm password: ") + if password != confirmation: + raise ValueError("passwords do not match") + if not password: + raise ValueError("password must not be empty") + environment_path = settings.state_dir / "environment" + if not environment_path.exists(): + raise ValueError("run driving-log install before setting a password") + environment = { + key: value + for line in environment_path.read_text(encoding="utf-8").splitlines() + if "=" in line and not line.startswith("#") + for key, value in [line.split("=", 1)] + } + key = f"DRIVING_LOG_{args.set_password.upper()}_PASSWORD_HASH" + environment[key] = password_hash(password) + temporary = environment_path.with_suffix(".tmp") + temporary.write_text( + "".join(f"{key}={value}\n" for key, value in sorted(environment.items())), + encoding="utf-8", + ) + temporary.chmod(0o600) + os.replace(temporary, environment_path) + print(f"Password saved for {args.set_password}. Restart the service to use it.") + return 0 + if not args.command: + parser.error("choose a command or use --set-password NAME") if args.command == "install": install_result = install_user_units( Path(__file__).parent.parent, - public_host=args.public_host, + public_host=args.public_host or tailscale_public_host(), public_scheme=args.public_scheme, external_archive_dir=args.external_archive_dir, ) diff --git a/driving_log/config.py b/driving_log/config.py index 7608229..6f4321d 100644 --- a/driving_log/config.py +++ b/driving_log/config.py @@ -22,13 +22,17 @@ class Settings: public_scheme: str = "http" operation_secret: str = "development-only-operation-secret" development_allow_non_loopback: bool = False + auth_required: bool = True + sean_password_hash: str = "" + jen_password_hash: str = "" + session_secret: str = "" @classmethod def from_env(cls) -> Settings: state = Path( os.environ.get( "DRIVING_LOG_STATE_DIR", - str(Path.home() / ".local" / "state" / "driving-log"), + str(Path(__file__).resolve().parent.parent / "driving-log-runtime"), ) ).expanduser() host = os.environ.get("DRIVING_LOG_HOST", "127.0.0.1") @@ -40,6 +44,7 @@ def from_env(cls) -> Settings: ) public_host = os.environ.get("DRIVING_LOG_PUBLIC_HOST", "127.0.0.1:8766") public_scheme = os.environ.get("DRIVING_LOG_PUBLIC_SCHEME", "http") + auth_required_text = os.environ.get("DRIVING_LOG_AUTH_REQUIRED") if public_scheme not in {"http", "https"}: raise ValueError("DRIVING_LOG_PUBLIC_SCHEME must be http or https") return cls( @@ -60,8 +65,25 @@ def from_env(cls) -> Settings: "development-only-operation-secret", ), development_allow_non_loopback=development_override, + auth_required=( + auth_required_text != "0" + if auth_required_text is not None + else not public_host.startswith(("127.0.0.1", "localhost", "testserver")) + ), + sean_password_hash=os.environ.get("DRIVING_LOG_SEAN_PASSWORD_HASH", ""), + jen_password_hash=os.environ.get("DRIVING_LOG_JEN_PASSWORD_HASH", ""), + session_secret=os.environ.get("DRIVING_LOG_SESSION_SECRET", ""), ) + def validate_authentication(self) -> None: + if self.auth_required and not all( + (self.sean_password_hash, self.jen_password_hash, self.session_secret) + ): + raise ValueError( + "authentication is required; configure DRIVING_LOG_SEAN_PASSWORD_HASH, " + "DRIVING_LOG_JEN_PASSWORD_HASH, and DRIVING_LOG_SESSION_SECRET" + ) + def ensure_directories(self) -> None: for path in (self.state_dir, self.archive_dir, self.restore_dir): path.mkdir(mode=0o700, parents=True, exist_ok=True) diff --git a/driving_log/operations.py b/driving_log/operations.py index b11bf24..a4b0dc9 100644 --- a/driving_log/operations.py +++ b/driving_log/operations.py @@ -6,6 +6,7 @@ import os import secrets import shutil +import sqlite3 import subprocess import time import uuid @@ -38,11 +39,10 @@ def install_user_units( public_scheme: str = "http", external_archive_dir: Path | None = None, ) -> dict[str, str]: - config_dir = Path.home() / ".config" / "driving-log" unit_dir = Path.home() / ".config" / "systemd" / "user" - config_dir.mkdir(mode=0o700, parents=True, exist_ok=True) unit_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - state_dir = Path.home() / ".local" / "state" / "driving-log" + state_dir = repo_root / "driving-log-runtime" + legacy_state_dir = Path.home() / ".local" / "state" / "driving-log" for directory in ( state_dir, state_dir / "archives", @@ -50,10 +50,15 @@ def install_user_units( ): directory.mkdir(mode=0o700, parents=True, exist_ok=True) directory.chmod(0o700) - environment_path = config_dir / "environment" + migrated = _migrate_legacy_runtime(legacy_state_dir, state_dir) + environment_path = state_dir / "environment" + legacy_environment_path = Path.home() / ".config" / "driving-log" / "environment" existing: dict[str, str] = {} - if environment_path.exists(): - for line in environment_path.read_text(encoding="utf-8").splitlines(): + source_environment_path = ( + environment_path if environment_path.exists() else legacy_environment_path + ) + if source_environment_path.exists(): + for line in source_environment_path.read_text(encoding="utf-8").splitlines(): if "=" in line and not line.startswith("#"): key, value = line.split("=", 1) existing[key] = value @@ -62,13 +67,17 @@ def install_user_units( or existing.get("DRIVING_LOG_FORM_SECRET") or secrets.token_urlsafe(48) ) + session_secret = existing.get("DRIVING_LOG_SESSION_SECRET") or secrets.token_urlsafe(48) environment = { **existing, "DRIVING_LOG_HOST": "127.0.0.1", "DRIVING_LOG_PORT": "8766", + "DRIVING_LOG_STATE_DIR": str(state_dir.resolve()), "DRIVING_LOG_PUBLIC_HOST": public_host, "DRIVING_LOG_PUBLIC_SCHEME": public_scheme, + "DRIVING_LOG_AUTH_REQUIRED": "1", "DRIVING_LOG_OPERATION_SECRET": secret, + "DRIVING_LOG_SESSION_SECRET": session_secret, } selected_external = ( str(external_archive_dir.expanduser().resolve()) @@ -97,9 +106,32 @@ def install_user_units( "environment": str(environment_path), "unit_directory": str(unit_dir), "public_host": public_host, + "legacy_runtime_migrated": str(migrated).lower(), } +def _migrate_legacy_runtime(legacy_state_dir: Path, state_dir: Path) -> bool: + """Copy an existing default runtime safely before switching to the repo runtime.""" + legacy_database = legacy_state_dir / "driving-log.sqlite3" + database = state_dir / "driving-log.sqlite3" + if not legacy_database.exists() or database.exists(): + return False + source = sqlite3.connect(legacy_database) + destination = sqlite3.connect(database) + try: + source.backup(destination) + finally: + destination.close() + source.close() + database.chmod(0o600) + for directory in ("archives", "restore-requests"): + source_dir = legacy_state_dir / directory + destination_dir = state_dir / directory + if source_dir.exists(): + shutil.copytree(source_dir, destination_dir, dirs_exist_ok=True) + return True + + def service_snapshot() -> dict[str, object]: result: dict[str, object] = {} for unit in ("driving-log-web.service", "driving-log-archive.timer"): diff --git a/driving_log/static/app.css b/driving_log/static/app.css index 8241e2f..297b773 100644 --- a/driving_log/static/app.css +++ b/driving_log/static/app.css @@ -36,6 +36,13 @@ header a { color: var(--text); text-decoration: none; } .brand { font-weight: 800; } nav { display: flex; gap: 1rem; font-size: .88rem; } main { width: min(100%, 42rem); margin: auto; padding: 1rem 1rem max(2rem, env(safe-area-inset-bottom)); } +.account-footer { + display: flex; align-items: center; justify-content: space-between; gap: 1rem; + width: min(100%, 42rem); margin: 0 auto; padding: 1rem max(1rem, env(safe-area-inset-right)) + max(1rem, env(safe-area-inset-bottom)); color: var(--muted); font-size: .88rem; +} +.account-footer form { margin: 0; } +.account-footer button { width: auto; min-height: 2.5rem; padding: .45rem .75rem; } h1 { font-size: 1.75rem; margin: .4rem 0 1.2rem; } h2 { font-size: 1.2rem; } .progress-card { diff --git a/driving_log/templates/base.html b/driving_log/templates/base.html index 146eb4f..75536a4 100644 --- a/driving_log/templates/base.html +++ b/driving_log/templates/base.html @@ -16,11 +16,17 @@
Daniel Driving Log
{% block content %}{% endblock %}
+ {% if current_user %} + + {% endif %} diff --git a/driving_log/templates/drive_form.html b/driving_log/templates/drive_form.html index 5e9d4ab..a975acc 100644 --- a/driving_log/templates/drive_form.html +++ b/driving_log/templates/drive_form.html @@ -6,9 +6,20 @@

{{ title }}

{% if drive %}{% endif %} {% include "_time_editor.html" %} - + {% if current_user %} + {% set selected_supervisor = drive.supervisor_name if drive else current_user %} + + {% else %} + + {% endif %}
Road type {% set selected_road_type = drive.road_type if drive else (prefill.road_type if prefill else "unknown") %} diff --git a/driving_log/templates/imports.html b/driving_log/templates/imports.html index 49bc8d0..1625bc6 100644 --- a/driving_log/templates/imports.html +++ b/driving_log/templates/imports.html @@ -4,6 +4,7 @@

Imports and exports

Download CSV backup DMV driving record + Archives
diff --git a/driving_log/templates/live.html b/driving_log/templates/live.html index 149d26f..a2a4104 100644 --- a/driving_log/templates/live.html +++ b/driving_log/templates/live.html @@ -34,7 +34,17 @@

Live drive

{% include "_weather_picker.html" %} - + {% if current_user %} + + {% else %} + + {% endif %}
diff --git a/driving_log/templates/login.html b/driving_log/templates/login.html new file mode 100644 index 0000000..22ba85b --- /dev/null +++ b/driving_log/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +

Sign in

+ {% if login_error %}

Sign-in details were not recognized.

{% endif %} +
+ +
+ Supervising driver + + +
+ + +
+{% endblock %} diff --git a/driving_log/web.py b/driving_log/web.py index 5020918..90dec6e 100644 --- a/driving_log/web.py +++ b/driving_log/web.py @@ -1,10 +1,13 @@ from __future__ import annotations +import asyncio import hashlib import sqlite3 +import time from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import cast +from urllib.parse import urlsplit from zoneinfo import ZoneInfo from fastapi import FastAPI, HTTPException, Request @@ -15,6 +18,7 @@ from starlette.middleware.base import RequestResponseEndpoint from driving_log.archive import ArchiveSchemaTooNewError, create_archive +from driving_log.auth import ACCOUNTS, COOKIE_NAME, SESSION_LIFETIME_SECONDS, Authenticator from driving_log.config import Settings from driving_log.csv_backup import export_csv, import_csv from driving_log.db import Database @@ -105,6 +109,7 @@ def _duration_parts(minutes: int) -> dict[str, int]: ("weather", "Weather"), ("duration", "Duration"), ) +AUTH_SUPERVISORS = tuple(ACCOUNTS.values()) def _part_of_day(value: str | datetime, timezone_name: str) -> str: @@ -240,7 +245,7 @@ def _theme_context(now: datetime | None = None) -> dict[str, object]: def _actor(request: Request) -> str | None: - return request.headers.get("Tailscale-User-Login") + return cast(str | None, getattr(request.state, "user", None)) def register_web(app: FastAPI, settings: Settings, database: Database) -> None: @@ -254,9 +259,27 @@ def register_web(app: FastAPI, settings: Settings, database: Database) -> None: live = LiveDriveService(database) profiles = SupervisorProfileService(database) dmv = DmvExportService(database) + authenticator = Authenticator( + settings.sean_password_hash, settings.jen_password_hash, settings.session_secret + ) + login_failures: dict[tuple[str, str], tuple[int, float]] = {} + login_failures_lock = asyncio.Lock() @app.middleware("http") - async def prevent_stale_html(request: Request, call_next: RequestResponseEndpoint) -> Response: + async def authenticate_and_prevent_stale_html( + request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if settings.auth_required: + request.state.user = authenticator.session_user(request.cookies.get(COOKIE_NAME)) + public_path = request.url.path in {"/login", "/health/live", "/health/ready"} + requires_login = not public_path and not request.url.path.startswith("/static/") + if requires_login and not request.state.user: + if "application/json" in request.headers.get("accept", ""): + return JSONResponse({"detail": "authentication required"}, status_code=401) + next_path = request.url.path + if request.url.query: + next_path += f"?{request.url.query}" + return RedirectResponse(f"/login?next={next_path}", status_code=303) response = await call_next(request) if response.headers.get("content-type", "").startswith("text/html"): response.headers["Cache-Control"] = "no-store" @@ -269,6 +292,7 @@ def common(request: Request, **values: object) -> dict[str, object]: "format_local_datetime": _format_local_datetime, "format_local_date": _format_local_date, "asset_version": asset_version, + "current_user": _actor(request), **_theme_context(), **values, } @@ -282,6 +306,71 @@ def redirect(path: str) -> RedirectResponse: def wants_json(request: Request) -> bool: return "application/json" in request.headers.get("accept", "") + def selected_supervisor( + request: Request, form: FormData, default: str | None = None + ) -> str | None: + value = str(form.get("supervisor_name", default or _actor(request) or "")) or None + if settings.auth_required and value not in AUTH_SUPERVISORS: + raise ValueError("Choose Sean Ahern or Jen Ahern as the supervising driver") + return value + + def safe_next(value: str | None) -> str: + if not value or "\\" in value or not value.startswith("/"): + return "/" + parsed = urlsplit(value) + return ( + value if not parsed.netloc and not parsed.scheme and not value.startswith("//") else "/" + ) + + @app.get("/login", response_class=HTMLResponse) + async def login_form(request: Request) -> Response: + if _actor(request): + return redirect(safe_next(request.query_params.get("next"))) + return templates.TemplateResponse( + request, + "login.html", + common(request, title="Sign in", next_path=safe_next(request.query_params.get("next"))), + ) + + @app.post("/login") + async def login(request: Request) -> Response: + form = await read_form(request) + account = str(form.get("account", "")) + client_host = request.client.host if request.client else "unknown" + user = authenticator.authenticate(account, str(form.get("password", ""))) + next_path = safe_next(str(form.get("next", ""))) + if not user: + key = (client_host, account) + async with login_failures_lock: + failures, _ = login_failures.get(key, (0, 0.0)) + login_failures[key] = (failures + 1, time.monotonic()) + await asyncio.sleep(min(2 ** min(failures, 3), 8)) + return templates.TemplateResponse( + request, + "login.html", + common(request, title="Sign in", next_path=next_path, login_error=True), + status_code=401, + ) + async with login_failures_lock: + login_failures.pop((client_host, account), None) + response = redirect(next_path) + response.set_cookie( + COOKIE_NAME, + authenticator.make_session(user), + max_age=SESSION_LIFETIME_SECONDS, + httponly=True, + secure=settings.public_scheme == "https", + samesite="lax", + path="/", + ) + return response + + @app.post("/logout") + async def logout() -> RedirectResponse: + response = redirect("/login") + response.delete_cookie(COOKIE_NAME, path="/") + return response + @app.exception_handler(ConflictError) async def conflict_handler(request: Request, exc: ConflictError) -> Response: if wants_json(request): @@ -722,6 +811,7 @@ async def drive_new(request: Request) -> HTMLResponse: end_fold=_local_fold(ended), time_zone=ZONE.key, action="/drives", + supervisor_options=AUTH_SUPERVISORS, other_intervals=other_intervals(None), **_duration_parts(30), ), @@ -733,7 +823,7 @@ async def drive_create(request: Request) -> RedirectResponse: drive = records.create( DriveInput( driver_name=str(form.get("driver_name", "Daniel Ahern")), - supervisor_name=str(form.get("supervisor_name", "")) or None, + supervisor_name=selected_supervisor(request, form), supervisor_dl_number=None, supervisor_dl_state=None, started_at_utc=_parse_local( @@ -782,6 +872,7 @@ async def drive_edit(request: Request, drive_id: str) -> HTMLResponse: end_fold=_local_fold(drive["ended_at_utc"]), time_zone=ZONE.key, action=f"/drives/{drive_id}/edit", + supervisor_options=AUTH_SUPERVISORS, other_intervals=other_intervals(drive_id), **_duration_parts(int(drive["duration_minutes"])), ), @@ -805,7 +896,9 @@ async def drive_update(request: Request, drive_id: str) -> RedirectResponse: ) value = DriveInput( driver_name=str(form.get("driver_name", current["driver_name"])), - supervisor_name=str(form.get("supervisor_name", "")) or None, + supervisor_name=selected_supervisor( + request, form, str(current["supervisor_name"] or "") + ), supervisor_dl_number=current["supervisor_dl_number"], supervisor_dl_state=current["supervisor_dl_state"], started_at_utc=start_utc, @@ -863,7 +956,13 @@ async def live_page(request: Request) -> HTMLResponse: return templates.TemplateResponse( request, "live.html", - common(request, title="Live drive", live=open_drive, **time_values), + common( + request, + title="Live drive", + live=open_drive, + supervisor_options=AUTH_SUPERVISORS, + **time_values, + ), ) @app.get("/live/state") @@ -875,7 +974,11 @@ async def live_state() -> dict[str, object]: @app.post("/live/start") async def live_start(request: Request) -> RedirectResponse: form = await read_form(request) - live.start(request_id=str(form["request_id"]), actor_identity=_actor(request)) + live.start( + request_id=str(form["request_id"]), + supervisor_name=selected_supervisor(request, form), + actor_identity=_actor(request), + ) return redirect("/live") @app.post("/live/{live_id}/end") @@ -933,7 +1036,9 @@ async def live_finalize(request: Request, live_id: str) -> RedirectResponse: weather=str(form.get("weather", "")), notes=str(form.get("notes", "")), end_location=str(form.get("end_location", "")), - supervisor_name=str(form.get("supervisor_name", "")) or None, + supervisor_name=selected_supervisor( + request, form, str(current["supervisor_name"] or "") + ), corrected_start_utc=corrected_start, corrected_end_utc=corrected_end, actor_identity=_actor(request), diff --git a/tests/test_foundation.py b/tests/test_foundation.py index 0e66662..02b6759 100644 --- a/tests/test_foundation.py +++ b/tests/test_foundation.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import hmac import os import sqlite3 import tempfile @@ -13,6 +15,7 @@ import httpx from driving_log.app import create_app +from driving_log.auth import Authenticator, _encode, password_hash, verify_password from driving_log.cli import doctor, main from driving_log.config import Settings from driving_log.db import BUSY_TIMEOUT_MS, Database, MigrationMismatchError, SchemaTooNewError @@ -38,6 +41,29 @@ def setUp(self) -> None: def tearDown(self) -> None: self.temp.cleanup() + def test_password_hashes_and_signed_sessions_reject_invalid_values(self) -> None: + encoded_password = password_hash("password") + self.assertTrue(verify_password("password", encoded_password)) + self.assertFalse(verify_password("password", "unknown$1$2$3$x$y")) + self.assertFalse(verify_password("password", "not-a-password-hash")) + with self.assertRaisesRegex(ValueError, "empty"): + password_hash("") + authenticator = Authenticator(encoded_password, encoded_password, "session-secret") + token = authenticator.make_session("Sean Ahern") + self.assertEqual(authenticator.session_user(token), "Sean Ahern") + rotated = Authenticator(password_hash("rotated"), encoded_password, "session-secret") + self.assertIsNone(rotated.session_user(token)) + self.assertIsNone(authenticator.session_user(f"{token}changed")) + + def signed(payload: dict[str, object]) -> str: + body = _encode(payload) + signature = hmac.new(b"session-secret", body.encode(), hashlib.sha256).hexdigest() + return f"{body}.{signature}" + + self.assertIsNone(authenticator.session_user(signed({"user": "Sean Ahern"}))) + self.assertIsNone(authenticator.session_user(signed({"user": "Other", "exp": 9999999999}))) + self.assertIsNone(authenticator.session_user(signed({"user": "Sean Ahern", "exp": 0}))) + def test_initializes_schema_and_production_pragmas(self) -> None: database = Database(self.path) database.initialize() @@ -284,6 +310,7 @@ def test_health_endpoints_and_doctor(self) -> None: host="127.0.0.1", port=8766, public_host="testserver", + auth_required=False, ) app = create_app(settings) diff --git a/tests/test_operations.py b/tests/test_operations.py index 20a759a..200bb11 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -14,10 +14,11 @@ from unittest import mock from driving_log.archive import create_archive, verify_archive -from driving_log.cli import doctor, main +from driving_log.cli import doctor, main, tailscale_public_host from driving_log.config import Settings from driving_log.db import Database from driving_log.operations import ( + _migrate_legacy_runtime, apply_retention, install_user_units, process_restore_request, @@ -38,6 +39,7 @@ def settings(root: Path, *, public_host: str = "testserver") -> Settings: host="127.0.0.1", port=8766, public_host=public_host, + auth_required=False, ) def test_replication_is_independently_verified(self) -> None: @@ -50,6 +52,21 @@ def test_replication_is_independently_verified(self) -> None: self.assertTrue(verify_archive(replicated)["verified"]) self.assertEqual(replicated.stat().st_mode & 0o777, 0o600) + def test_legacy_runtime_copy_keeps_the_original_and_uses_sqlite_backup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + legacy = root / "legacy" + Database(legacy / "driving-log.sqlite3").initialize() + (legacy / "archives").mkdir() + (legacy / "archives" / "saved.tar.gz").write_bytes(b"archive") + runtime = root / "repo" / "driving-log-runtime" + runtime.mkdir(parents=True) + self.assertTrue(_migrate_legacy_runtime(legacy, runtime)) + self.assertTrue((legacy / "driving-log.sqlite3").exists()) + self.assertTrue((runtime / "driving-log.sqlite3").exists()) + self.assertEqual((runtime / "archives" / "saved.tar.gz").read_bytes(), b"archive") + self.assertFalse(_migrate_legacy_runtime(legacy, runtime)) + def test_retention_keeps_fourteen_daily_and_eight_weekly(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -100,7 +117,8 @@ def test_systemd_service_binds_only_through_configured_loopback(self) -> None: readme = (root / "deploy/README.md").read_text() self.assertIn("127.0.0.1:8766", readme) self.assertIn("127.0.0.1:8765", readme) - self.assertNotIn("tailscale funnel", readme.lower()) + self.assertIn("tailscale funnel", readme.lower()) + self.assertIn("--set-password Sean", readme) def test_no_private_seed_is_tracked_by_ignore_contract(self) -> None: ignore = (Path(__file__).parent.parent / ".gitignore").read_text() @@ -108,9 +126,11 @@ def test_no_private_seed_is_tracked_by_ignore_contract(self) -> None: self.assertIn("records/log.txt", ignore) def test_install_writes_private_environment_and_all_units(self) -> None: - repo = Path(__file__).parent.parent + source_repo = Path(__file__).parent.parent with tempfile.TemporaryDirectory() as temporary: home = Path(temporary) + repo = home / "repo" + shutil.copytree(source_repo / "deploy", repo / "deploy") with ( mock.patch("driving_log.operations.Path.home", return_value=home), mock.patch("driving_log.operations.run_systemctl") as systemctl, @@ -125,12 +145,15 @@ def test_install_writes_private_environment_and_all_units(self) -> None: public_host="driving.example.ts.net:8443", ) environment = Path(result["environment"]) + self.assertEqual(environment, repo / "driving-log-runtime" / "environment") self.assertEqual(environment.stat().st_mode & 0o777, 0o600) contents = environment.read_text() self.assertIn("DRIVING_LOG_HOST=127.0.0.1", contents) self.assertIn("DRIVING_LOG_PORT=8766", contents) self.assertIn("DRIVING_LOG_PUBLIC_SCHEME=http", contents) + self.assertIn("DRIVING_LOG_AUTH_REQUIRED=1", contents) self.assertIn("DRIVING_LOG_OPERATION_SECRET=", contents) + self.assertIn("DRIVING_LOG_SESSION_SECRET=", contents) self.assertNotIn("DRIVING_LOG_FORM_SECRET=", contents) self.assertIn(f"DRIVING_LOG_EXTERNAL_ARCHIVE_DIR={home / 'external'}", contents) unit_dir = Path(result["unit_directory"]) @@ -323,6 +346,71 @@ def test_cli_dispatches_service_and_diagnostic_commands(self) -> None: ): self.assertEqual(main(["archive", "restore-request", str(uuid.uuid4())]), 0) + def test_set_password_writes_a_private_hash_to_the_runtime_environment(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + state = Path(temporary) + environment = state / "environment" + environment.write_text("DRIVING_LOG_SESSION_SECRET=session\n", encoding="utf-8") + with ( + mock.patch.dict("os.environ", {"DRIVING_LOG_STATE_DIR": temporary}, clear=False), + mock.patch( + "driving_log.cli.getpass.getpass", side_effect=["new-password", "new-password"] + ), + ): + self.assertEqual(main(["--set-password", "Sean"]), 0) + contents = environment.read_text(encoding="utf-8") + self.assertIn("DRIVING_LOG_SEAN_PASSWORD_HASH=scrypt$", contents) + self.assertNotIn("new-password", contents) + self.assertEqual(environment.stat().st_mode & 0o777, 0o600) + + def test_set_password_requires_matching_confirmation_and_runtime_environment(self) -> None: + with ( + tempfile.TemporaryDirectory() as temporary, + mock.patch.dict("os.environ", {"DRIVING_LOG_STATE_DIR": temporary}, clear=False), + mock.patch("driving_log.cli.getpass.getpass", side_effect=["first", "second"]), + self.assertRaisesRegex(ValueError, "do not match"), + ): + main(["--set-password", "Jen"]) + with ( + tempfile.TemporaryDirectory() as temporary, + mock.patch.dict("os.environ", {"DRIVING_LOG_STATE_DIR": temporary}, clear=False), + mock.patch("driving_log.cli.getpass.getpass", side_effect=["password", "password"]), + self.assertRaisesRegex(ValueError, "run driving-log install"), + ): + main(["--set-password", "Jen"]) + + def test_tailscale_public_host_rejects_unusable_status(self) -> None: + with ( + mock.patch( + "driving_log.cli.subprocess.run", + return_value=CompletedProcess(["tailscale"], 1, "", "unavailable"), + ), + self.assertRaisesRegex(ValueError, "could not read"), + ): + tailscale_public_host() + with ( + mock.patch( + "driving_log.cli.subprocess.run", + return_value=CompletedProcess(["tailscale"], 0, "{}", ""), + ), + self.assertRaisesRegex(ValueError, "could not find"), + ): + tailscale_public_host() + + def test_install_uses_the_tailscale_dns_name_when_public_host_is_omitted(self) -> None: + status = CompletedProcess( + ["tailscale"], 0, '{"Self":{"DNSName":"driving.tailnet.ts.net."}}', "" + ) + with ( + mock.patch("driving_log.cli.subprocess.run", return_value=status), + mock.patch("driving_log.cli.install_user_units", return_value={}) as install, + ): + self.assertEqual( + main(["install", "--public-scheme", "https"]), + 0, + ) + self.assertEqual(install.call_args.kwargs["public_host"], "driving.tailnet.ts.net:8443") + def test_cli_dispatches_seed_csv_and_archive_variants(self) -> None: with ( tempfile.TemporaryDirectory() as temporary, diff --git a/tests/test_web.py b/tests/test_web.py index 63b2528..613682c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import gzip import hashlib import io @@ -12,6 +13,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import cast +from unittest.mock import patch from zoneinfo import ZoneInfo import anyio @@ -19,6 +21,7 @@ from pypdf import PdfReader from driving_log.app import create_app +from driving_log.auth import password_hash from driving_log.config import DEFAULT_TIMEZONE, Settings from driving_log.db import utc_now_text from driving_log.migrations import LATEST_SCHEMA_VERSION @@ -49,6 +52,7 @@ def setUp(self) -> None: host="127.0.0.1", port=8766, public_host="testserver", + auth_required=False, ) self.app = create_app(self.settings) @@ -58,6 +62,145 @@ def tearDown(self) -> None: def run_async(self, function: object) -> None: anyio.run(function) # type: ignore[arg-type] + def test_login_defaults_and_limits_supervisor_choices(self) -> None: + settings = Settings( + state_dir=self.settings.state_dir, + database_path=self.settings.database_path, + archive_dir=self.settings.archive_dir, + restore_dir=self.settings.restore_dir, + host=self.settings.host, + port=self.settings.port, + public_host=self.settings.public_host, + auth_required=True, + sean_password_hash=password_hash("sean-password"), + jen_password_hash=password_hash("jen-password"), + session_secret="test-session-secret", + ) + app = create_app(settings) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + follow_redirects=False, + ) as client, + ): + protected = await client.get("/drives") + self.assertEqual(protected.status_code, 303) + self.assertEqual(protected.headers["location"], "/login?next=/drives") + failed = await client.post( + "/login", data={"account": "jen", "password": "wrong", "next": "/"} + ) + self.assertEqual(failed.status_code, 401) + unsafe_next = await client.post( + "/login", + data={ + "account": "jen", + "password": "jen-password", + "next": "/\\evil.example.com", + }, + ) + self.assertEqual(unsafe_next.status_code, 303) + self.assertEqual(unsafe_next.headers["location"], "/") + signed_in = await client.post( + "/login", data={"account": "jen", "password": "jen-password", "next": "/"} + ) + self.assertEqual(signed_in.status_code, 303) + self.assertIn("driving_log_session", signed_in.headers["set-cookie"]) + dashboard = await client.get("/") + self.assertIn("Signed in as Jen Ahern", dashboard.text) + self.assertLess( + dashboard.text.index(""), dashboard.text.index("Signed in as Jen Ahern") + ) + new_drive = await client.get("/drives/new") + self.assertIn('name="supervisor_name"', new_drive.text) + self.assertIn('