Add qr code for mfa
This commit is contained in:
@@ -15,7 +15,7 @@ Open `http://localhost:8000`, create the first account, and publish a field note
|
|||||||
|
|
||||||
## Accounts and MFA
|
## 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
|
## Production notes
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,12 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from base64 import b64encode
|
||||||
|
from io import BytesIO
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
|
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
|
||||||
import pyotp
|
import pyotp
|
||||||
|
import qrcode
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
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()
|
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"))
|
@app.route("/mfa/setup", methods=("GET", "POST"))
|
||||||
def mfa_setup():
|
def mfa_setup():
|
||||||
user = pending_mfa_user()
|
user = pending_mfa_user()
|
||||||
@@ -232,7 +243,9 @@ def mfa_setup():
|
|||||||
session["mfa_verified"] = True
|
session["mfa_verified"] = True
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
flash("That verification code was not accepted. Try the current code.", "error")
|
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"))
|
@app.route("/mfa/verify", methods=("GET", "POST"))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
Flask==3.1.1
|
Flask==3.1.1
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
|
qrcode[pil]==8.2
|
||||||
|
|||||||
+1
-1
@@ -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; }
|
.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; }
|
.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); }
|
.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; } }
|
@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
@@ -6,8 +6,9 @@
|
|||||||
<span class="signal-label">ACCOUNT SECURITY</span>
|
<span class="signal-label">ACCOUNT SECURITY</span>
|
||||||
{% if mode == 'setup' %}
|
{% if mode == 'setup' %}
|
||||||
<h1>Bind your<br>authenticator.</h1>
|
<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>
|
<p>Scan this code with an iPhone or Android authenticator app, then enter its current six-digit code.</p>
|
||||||
<code class="mfa-secret">{{ secret }}</code>
|
<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 %}
|
{% else %}
|
||||||
<h1>Confirm the<br>signal.</h1>
|
<h1>Confirm the<br>signal.</h1>
|
||||||
<p>Open your authenticator application and enter the current code for {{ username }}.</p>
|
<p>Open your authenticator application and enter the current code for {{ username }}.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user