diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-10-31 19:51:15 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-10-31 19:51:15 +0900 |
| commit | 0966df2df28ab212a6f4736a4e17a31d690a6173 (patch) | |
| tree | eed23188adfdee78bc2bbdbb6c2d8639cc8544db | |
| parent | f967c90db70a6cc983b579589185185d6be66aa6 (diff) | |
hetzner-admin: add passkey login (password login kept)
Replace Basic auth with a session-cookie login page. After password
login, register a passkey from the bottom of the page; later logins
can use it. Passkeys are stored in hetzner-admin/data/ (gitignored).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | CLAUDE.md | 3 | ||||
| -rw-r--r-- | hetzner-admin/Dockerfile | 2 | ||||
| -rw-r--r-- | hetzner-admin/app.py | 232 | ||||
| -rw-r--r-- | hetzner-admin/docker-compose.yml | 1 |
5 files changed, 227 insertions, 12 deletions
@@ -3,3 +3,4 @@ hetzner-admin/id_rsa hetzner-admin/id_rsa.pub __pycache__/ +hetzner-admin/data/ @@ -92,6 +92,9 @@ EOF bash /app/infra/hetzner-admin/install.sh # → SSH鍵生成 + authorized_keys登録(command= 制限付き)+ コンテナ起動 # → hetzner-admin.yyamashita.com でアクセス可能 +# ログイン: パスワード(.env)またはパスキー。初回はパスワードでログイン → +# 下部の「Register this device's passkey」で登録 → 以後「Login with Passkey」 +# パスキーは hetzner-admin/data/passkeys.json(gitignore)。全削除すれば登録し直し # VPN 初回セットアップ(サーバー上で実行) # 前提: ファイアウォールで UDP 500, 4500 を開放済み diff --git a/hetzner-admin/Dockerfile b/hetzner-admin/Dockerfile index 524612d..a729c34 100644 --- a/hetzner-admin/Dockerfile +++ b/hetzner-admin/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.12-slim WORKDIR /app -RUN pip install --no-cache-dir flask gunicorn paramiko +RUN pip install --no-cache-dir flask gunicorn paramiko "webauthn>=2.2,<3" COPY app.py . CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"] diff --git a/hetzner-admin/app.py b/hetzner-admin/app.py index abef6a9..90a1cca 100644 --- a/hetzner-admin/app.py +++ b/hetzner-admin/app.py @@ -1,10 +1,26 @@ import functools +import hmac +import json import os +import secrets +import time +from datetime import timedelta import paramiko -from flask import Flask, Response, render_template_string, request - -app = Flask(__name__) +from flask import Flask, redirect, render_template_string, request, session +from webauthn import ( + generate_authentication_options, + generate_registration_options, + options_to_json, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, +) ADMIN_USER = os.environ["ADMIN_USER"] ADMIN_PASS = os.environ["ADMIN_PASS"] @@ -12,6 +28,30 @@ SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal") SSH_PORT = int(os.environ.get("SSH_PORT", "22")) SSH_USER = os.environ.get("SSH_USER", "root") SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/id_rsa") +RP_ID = os.environ.get("RP_ID", "hetzner-admin.yyamashita.com") +ORIGIN = f"https://{RP_ID}" +DATA_DIR = os.environ.get("DATA_DIR", "/app/data") +PASSKEYS_PATH = os.path.join(DATA_DIR, "passkeys.json") +SECRET_PATH = os.path.join(DATA_DIR, "secret_key") + + +def load_secret(): + os.makedirs(DATA_DIR, exist_ok=True) + if not os.path.exists(SECRET_PATH): + with open(SECRET_PATH, "w") as f: + f.write(secrets.token_hex(32)) + with open(SECRET_PATH) as f: + return f.read().strip() + + +app = Flask(__name__) +app.secret_key = load_secret() +app.config.update( + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + PERMANENT_SESSION_LIFETIME=timedelta(days=30), +) COMMANDS = [ ("Docker Status", "docker ps"), @@ -25,9 +65,45 @@ COMMANDS = [ COMMAND_MAP = {label: cmd for label, cmd in COMMANDS} +# ブラウザ側: JSON ⇔ WebAuthn の変換はブラウザ組み込みの +# parseCreationOptionsFromJSON / parseRequestOptionsFromJSON / toJSON に任せる +PASSKEY_JS = """<script> +async function passkey(kind) { + try { + const opts = await (await fetch('/passkey/' + kind + '/options', {method: 'POST'})).json(); + const cred = kind === 'register' + ? await navigator.credentials.create({publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(opts)}) + : await navigator.credentials.get({publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(opts)}); + const res = await fetch('/passkey/' + kind + '/verify', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(cred.toJSON())}); + const msg = await res.text(); + if (!res.ok) throw new Error(msg); + if (kind === 'login') location.href = '/'; else alert(msg); + } catch (e) { + alert('Passkey error: ' + e.message); + } +} +</script>""" + +LOGIN_HTML = """<!DOCTYPE html> +<html> +<head><title>Server Admin</title><meta name="viewport" content="width=device-width"></head> +<body> +<h3>Server Admin</h3> +<p><button onclick="passkey('login')">Login with Passkey</button></p> +<form method="post" action="/login"> + <input name="username" placeholder="user" autocomplete="username"> + <input name="password" type="password" placeholder="password" autocomplete="current-password"> + <button>Login</button> +</form> +{% if error %}<p style="color:red">{{ error }}</p>{% endif %} +""" + PASSKEY_JS + """ +</body> +</html>""" + HTML = """<!DOCTYPE html> <html> -<head><title>Server Admin</title></head> +<head><title>Server Admin</title><meta name="viewport" content="width=device-width"></head> <body> <h3>Server Admin</h3> <p><a href="https://status.yyamashita.com">Status</a></p> @@ -45,23 +121,48 @@ HTML = """<!DOCTYPE html> <h4>Output:</h4> <pre>{{ output }}</pre> {% endif %} +<hr> +<p> + Passkeys registered: {{ passkey_count }} + <button onclick="passkey('register')">Register this device's passkey</button> + <a href="/logout">Logout</a> +</p> +""" + PASSKEY_JS + """ </body> </html>""" +def load_passkeys(): + try: + with open(PASSKEYS_PATH) as f: + return json.load(f) + except FileNotFoundError: + return [] + + +def save_passkeys(keys): + tmp = PASSKEYS_PATH + ".tmp" + with open(tmp, "w") as f: + json.dump(keys, f, indent=2) + os.replace(tmp, PASSKEYS_PATH) + + def requires_auth(f): @functools.wraps(f) def decorated(*args, **kwargs): - auth = request.authorization - if not auth or auth.username != ADMIN_USER or auth.password != ADMIN_PASS: - return Response( - "Unauthorized", 401, {"WWW-Authenticate": 'Basic realm="Admin"'} - ) + if not session.get("auth"): + return redirect("/login") return f(*args, **kwargs) return decorated +def log_in(): + session.clear() + session.permanent = True + session["auth"] = True + + def run_ssh(command): key = paramiko.RSAKey.from_private_key_file(SSH_KEY_PATH) client = paramiko.SSHClient() @@ -76,10 +177,119 @@ def run_ssh(command): client.close() +def render_index(output=None): + return render_template_string( + HTML, commands=COMMANDS, output=output, passkey_count=len(load_passkeys()) + ) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + user_ok = hmac.compare_digest(request.form.get("username", ""), ADMIN_USER) + pass_ok = hmac.compare_digest(request.form.get("password", ""), ADMIN_PASS) + if user_ok and pass_ok: + log_in() + return redirect("/") + error = "Wrong user or password" + return render_template_string(LOGIN_HTML, error=error) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/login") + + +@app.route("/passkey/register/options", methods=["POST"]) +@requires_auth +def passkey_register_options(): + opts = generate_registration_options( + rp_id=RP_ID, + rp_name="Server Admin", + user_name=ADMIN_USER, + user_id=ADMIN_USER.encode(), + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.REQUIRED + ), + exclude_credentials=[ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(k["id"])) + for k in load_passkeys() + ], + ) + session["challenge"] = bytes_to_base64url(opts.challenge) + return app.response_class(options_to_json(opts), mimetype="application/json") + + +@app.route("/passkey/register/verify", methods=["POST"]) +@requires_auth +def passkey_register_verify(): + challenge = session.pop("challenge", None) + if not challenge: + return "No challenge", 400 + try: + v = verify_registration_response( + credential=request.get_data(as_text=True), + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=RP_ID, + expected_origin=ORIGIN, + ) + except Exception as e: + return f"Registration failed: {e}", 400 + keys = load_passkeys() + keys.append( + { + "id": bytes_to_base64url(v.credential_id), + "public_key": bytes_to_base64url(v.credential_public_key), + "sign_count": v.sign_count, + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "user_agent": request.headers.get("User-Agent", ""), + } + ) + save_passkeys(keys) + return "Passkey registered" + + +@app.route("/passkey/login/options", methods=["POST"]) +def passkey_login_options(): + # allow_credentials を空にしてブラウザ側で保存済みパスキーを選ばせる + opts = generate_authentication_options(rp_id=RP_ID) + session["challenge"] = bytes_to_base64url(opts.challenge) + return app.response_class(options_to_json(opts), mimetype="application/json") + + +@app.route("/passkey/login/verify", methods=["POST"]) +def passkey_login_verify(): + challenge = session.pop("challenge", None) + if not challenge: + return "No challenge", 400 + cred = request.get_json(silent=True) or {} + keys = load_passkeys() + key = next((k for k in keys if k["id"] == cred.get("id")), None) + if not key: + return "Unknown passkey (register it first after password login)", 400 + try: + v = verify_authentication_response( + credential=cred, + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=RP_ID, + expected_origin=ORIGIN, + credential_public_key=base64url_to_bytes(key["public_key"]), + credential_current_sign_count=key["sign_count"], + ) + except Exception as e: + return f"Login failed: {e}", 400 + key["sign_count"] = v.new_sign_count + save_passkeys(keys) + log_in() + return "OK" + + @app.route("/") @requires_auth def index(): - return render_template_string(HTML, commands=COMMANDS, output=None) + return render_index() @app.route("/run", methods=["POST"]) @@ -94,4 +304,4 @@ def run(): output = run_ssh(command) except Exception as e: output = f"Error: {e}" - return render_template_string(HTML, commands=COMMANDS, output=output) + return render_index(output) diff --git a/hetzner-admin/docker-compose.yml b/hetzner-admin/docker-compose.yml index 12d5c0e..4ec4da6 100644 --- a/hetzner-admin/docker-compose.yml +++ b/hetzner-admin/docker-compose.yml @@ -5,6 +5,7 @@ services: env_file: .env volumes: - ./id_rsa:/app/id_rsa:ro + - ./data:/app/data extra_hosts: - "host.docker.internal:host-gateway" networks: |
