Add qr code for mfa

This commit is contained in:
2026-08-29 10:54:04 +03:00
parent b7eae7d6ce
commit d93690a36b
6 changed files with 20 additions and 5 deletions
+1 -1
View File
@@ -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
Binary file not shown.
+14 -1
View File
@@ -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"))
+1
View File
@@ -1,3 +1,4 @@
Flask==3.1.1
gunicorn==23.0.0
pyotp==2.9.0
qrcode[pil]==8.2
+1 -1
View File
@@ -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; } }
+3 -2
View File
@@ -6,8 +6,9 @@
<span class="signal-label">ACCOUNT SECURITY</span>
{% if mode == 'setup' %}
<h1>Bind your<br>authenticator.</h1>
<p>Add this secret to an authenticator app as a time-based one-time password account, then enter its current six-digit code.</p>
<code class="mfa-secret">{{ secret }}</code>
<p>Scan this code with an iPhone or Android authenticator app, then enter its current six-digit code.</p>
<img class="mfa-qr" src="{{ qr_code }}" alt="Scan this QR code with your authenticator app to add your Eternity Project account">
<details class="mfa-manual"><summary>Enter setup key manually</summary><code class="mfa-secret">{{ secret }}</code></details>
{% else %}
<h1>Confirm the<br>signal.</h1>
<p>Open your authenticator application and enter the current code for {{ username }}.</p>