From d93690a36b73a578163977b7a4b2c6bac7d114d1 Mon Sep 17 00:00:00 2001 From: Tero Date: Sat, 29 Aug 2026 10:54:04 +0300 Subject: [PATCH] Add qr code for mfa --- README.md | 2 +- __pycache__/app.cpython-314.pyc | Bin 19904 -> 20722 bytes app.py | 15 ++++++++++++++- requirements.txt | 1 + static/css/site.css | 2 +- templates/mfa.html | 5 +++-- 6 files changed, 20 insertions(+), 5 deletions(-) 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 e83b9b0fc8311e0de7486de8e9cc84ebae4902a7..021daca687bee400f332d2430b60e1774eaa111d 100644 GIT binary patch delta 4586 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 delta 4016 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 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 }}.