diff --git a/README.md b/README.md index c568e02..5c13ae6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Open `http://localhost:8000`, create the first account, and publish a field note ## Accounts and MFA -The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, enroll MFA, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish. +The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish. ## Production notes diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index e83b9b0..021daca 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ diff --git a/app.py b/app.py index f045e18..fcd060b 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,15 @@ import os import re import sqlite3 +from base64 import b64encode +from io import BytesIO from datetime import datetime, timezone from functools import wraps from pathlib import Path from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for import pyotp +import qrcode from werkzeug.security import check_password_hash, generate_password_hash BASE_DIR = Path(__file__).resolve().parent @@ -216,6 +219,14 @@ def pending_mfa_user(): return get_db().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() +def mfa_qr_code(secret, username): + uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="Eternity Project") + image = qrcode.make(uri) + buffer = BytesIO() + image.save(buffer, format="PNG") + return f"data:image/png;base64,{b64encode(buffer.getvalue()).decode('ascii')}" + + @app.route("/mfa/setup", methods=("GET", "POST")) def mfa_setup(): user = pending_mfa_user() @@ -232,7 +243,9 @@ def mfa_setup(): session["mfa_verified"] = True return redirect(next_url) flash("That verification code was not accepted. Try the current code.", "error") - return render_template("mfa.html", mode="setup", secret=secret, username=user["username"]) + return render_template( + "mfa.html", mode="setup", secret=secret, username=user["username"], qr_code=mfa_qr_code(secret, user["username"]) + ) @app.route("/mfa/verify", methods=("GET", "POST")) diff --git a/requirements.txt b/requirements.txt index 35018b6..d241df2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask==3.1.1 gunicorn==23.0.0 pyotp==2.9.0 +qrcode[pil]==8.2 diff --git a/static/css/site.css b/static/css/site.css index 8a71295..d40b57b 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -14,5 +14,5 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa .auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:rgba(241,241,233,.8); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:#fbfbf6; border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; } .article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; } .flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); } -.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:28px; padding:14px; border:1px solid var(--ink); background:var(--acid); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; } +.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; } @media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } } diff --git a/templates/mfa.html b/templates/mfa.html index 94cf1e2..798e156 100644 --- a/templates/mfa.html +++ b/templates/mfa.html @@ -6,8 +6,9 @@ ACCOUNT SECURITY {% if mode == 'setup' %}

Bind your
authenticator.

-

Add this secret to an authenticator app as a time-based one-time password account, then enter its current six-digit code.

- {{ secret }} +

Scan this code with an iPhone or Android authenticator app, then enter its current six-digit code.

+ Scan this QR code with your authenticator app to add your Eternity Project account +
Enter setup key manually{{ secret }}
{% else %}

Confirm the
signal.

Open your authenticator application and enter the current code for {{ username }}.