feat: TOTP two-factor auth for admin login
Matches Remnawave's security-first positioning (passkeys/OAuth there) with the more universal standard instead — RFC 6238 TOTP, works with Google Authenticator/Authy/1Password/anything. Implemented from scratch on stdlib only (hashlib/hmac/struct/base64) — no new dependency — and verified against the official RFC 4226 HOTP test vectors (all 10 pass exactly) before wiring it into any auth path. Per-admin, optional: setup shows the secret + otpauth:// URI (no QR render, just copyable text — didn't want to fake a QR library), confirmed by entering a real code before it's persisted. Login is now two-step when 2FA is on: password first (returns a short-lived pending_token instead of a session if totp_secret is set), then a second call with the pending_token + code creates the real session. pending_totp rows are single-use and expire in 5 minutes, cleaned up alongside the existing admin_sessions cleanup in periodic_sync. Disabling 2FA requires re-entering the current password. Verified end-to-end: enable/disable round trip, pending-token resolve+single-use+expiry, code verification against the stored secret, wrong-code and wrong-password rejection — on top of the raw HOTP correctness check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9e6e314c94
commit
7098e59967
5 changed files with 282 additions and 11 deletions
118
admin.html
118
admin.html
|
|
@ -281,11 +281,19 @@
|
||||||
<div class="splash-title">MBS Panel</div>
|
<div class="splash-title">MBS Panel</div>
|
||||||
<div class="splash-tagline">made by savsis</div>
|
<div class="splash-tagline">made by savsis</div>
|
||||||
</div>
|
</div>
|
||||||
<h1>Вход</h1>
|
<div id="login-step-password">
|
||||||
<p>Логин и пароль администратора</p>
|
<h1>Вход</h1>
|
||||||
<input type="text" id="login-username" placeholder="Логин" value="admin" autocomplete="username" onkeydown="if(event.key==='Enter')document.getElementById('login-password').focus()">
|
<p>Логин и пароль администратора</p>
|
||||||
<input type="password" id="login-password" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()">
|
<input type="text" id="login-username" placeholder="Логин" value="admin" autocomplete="username" onkeydown="if(event.key==='Enter')document.getElementById('login-password').focus()">
|
||||||
<button class="btn block" onclick="login()">Войти</button>
|
<input type="password" id="login-password" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()">
|
||||||
|
<button class="btn block" onclick="login()">Войти</button>
|
||||||
|
</div>
|
||||||
|
<div id="login-step-totp" style="display:none">
|
||||||
|
<h1>Код из приложения</h1>
|
||||||
|
<p>Двухфакторка включена — введи 6-значный код</p>
|
||||||
|
<input type="text" id="login-totp-code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" onkeydown="if(event.key==='Enter')loginTotp()">
|
||||||
|
<button class="btn block" onclick="loginTotp()">Подтвердить</button>
|
||||||
|
</div>
|
||||||
<div id="login-err"></div>
|
<div id="login-err"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -557,6 +565,27 @@
|
||||||
<div id="admins-result"></div>
|
<div id="admins-result"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:20px">
|
||||||
|
<div class="section-head"><h2>Двухфакторная аутентификация</h2></div>
|
||||||
|
<p class="page-sub" style="margin-bottom:16px">Код из Google Authenticator/Authy/1Password при входе, в дополнение к паролю. Настраивается для твоего текущего логина.</p>
|
||||||
|
<div id="totp-status"></div>
|
||||||
|
<div id="totp-setup-box" style="display:none;margin-top:16px">
|
||||||
|
<p class="page-sub">Добавь в приложение-аутентификатор вручную (ключ) или скопируй ссылку:</p>
|
||||||
|
<div class="code-box"><span id="totp-secret-display"></span><button class="copy-btn" onclick="copyText(document.getElementById('totp-secret-display').textContent)">Копировать</button></div>
|
||||||
|
<div class="form-row" style="margin-top:12px">
|
||||||
|
<div><label class="f">Код из приложения</label><input type="text" id="totp-confirm-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
|
||||||
|
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="confirmEnableTotp()">Подтвердить</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="totp-disable-box" style="display:none;margin-top:16px">
|
||||||
|
<div class="form-row">
|
||||||
|
<div><label class="f">Пароль (подтвердить отключение)</label><input type="password" id="totp-disable-password"></div>
|
||||||
|
<div style="flex:0"><label class="f"> </label><button class="btn" style="background:var(--red)" onclick="confirmDisableTotp()">Отключить</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="totp-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section" style="margin-top:20px">
|
<div class="section" style="margin-top:20px">
|
||||||
<div class="section-head"><h2>Бэкап и восстановление</h2></div>
|
<div class="section-head"><h2>Бэкап и восстановление</h2></div>
|
||||||
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
|
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
|
||||||
|
|
@ -593,6 +622,11 @@ async function api(path, opts) {
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
document.getElementById("login-screen").style.display = "flex";
|
document.getElementById("login-screen").style.display = "flex";
|
||||||
document.getElementById("app").classList.remove("show");
|
document.getElementById("app").classList.remove("show");
|
||||||
|
document.getElementById("login-step-password").style.display = "block";
|
||||||
|
document.getElementById("login-step-totp").style.display = "none";
|
||||||
|
document.getElementById("login-totp-code").value = "";
|
||||||
|
document.getElementById("login-password").value = "";
|
||||||
|
pendingTotpToken = null;
|
||||||
}
|
}
|
||||||
function showApp() {
|
function showApp() {
|
||||||
document.getElementById("login-screen").style.display = "none";
|
document.getElementById("login-screen").style.display = "none";
|
||||||
|
|
@ -603,18 +637,39 @@ function showApp() {
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingTotpToken = null;
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
const username = document.getElementById("login-username").value.trim();
|
const username = document.getElementById("login-username").value.trim();
|
||||||
const password = document.getElementById("login-password").value;
|
const password = document.getElementById("login-password").value;
|
||||||
const err = document.getElementById("login-err");
|
const err = document.getElementById("login-err");
|
||||||
err.textContent = "";
|
err.textContent = "";
|
||||||
try {
|
try {
|
||||||
await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username, password }) });
|
const res = await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username, password }) });
|
||||||
|
if (res.needs_totp) {
|
||||||
|
pendingTotpToken = res.pending_token;
|
||||||
|
document.getElementById("login-step-password").style.display = "none";
|
||||||
|
document.getElementById("login-step-totp").style.display = "block";
|
||||||
|
document.getElementById("login-totp-code").focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
showApp();
|
showApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = "Неверный логин или пароль";
|
err.textContent = "Неверный логин или пароль";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginTotp() {
|
||||||
|
const code = document.getElementById("login-totp-code").value.trim();
|
||||||
|
const err = document.getElementById("login-err");
|
||||||
|
err.textContent = "";
|
||||||
|
try {
|
||||||
|
await api("/admin/api/login/totp", { method: "POST", body: JSON.stringify({ pending_token: pendingTotpToken, code }) });
|
||||||
|
showApp();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = "Неверный код";
|
||||||
|
}
|
||||||
|
}
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await api("/admin/api/logout", { method: "POST" });
|
await api("/admin/api/logout", { method: "POST" });
|
||||||
showLogin();
|
showLogin();
|
||||||
|
|
@ -631,7 +686,7 @@ function showView(name) {
|
||||||
if (name === "nodes") loadNodes();
|
if (name === "nodes") loadNodes();
|
||||||
if (name === "traffic") loadTraffic();
|
if (name === "traffic") loadTraffic();
|
||||||
if (name === "payments") loadPayments();
|
if (name === "payments") loadPayments();
|
||||||
if (name === "settings") { loadBotSettings(); loadAdmins(); }
|
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
|
|
@ -950,6 +1005,55 @@ async function deleteAdmin(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTotpStatus() {
|
||||||
|
const status = document.getElementById("totp-status");
|
||||||
|
const setupBox = document.getElementById("totp-setup-box");
|
||||||
|
const disableBox = document.getElementById("totp-disable-box");
|
||||||
|
setupBox.style.display = "none";
|
||||||
|
disableBox.style.display = "none";
|
||||||
|
const s = await api("/admin/api/2fa/status");
|
||||||
|
if (s.enabled) {
|
||||||
|
status.innerHTML = '<p class="page-sub"><span class="badge ok">включена</span></p>';
|
||||||
|
status.innerHTML += '<button class="muted-btn" onclick="document.getElementById(\'totp-disable-box\').style.display=\'block\'">Отключить</button>';
|
||||||
|
} else {
|
||||||
|
status.innerHTML = '<p class="page-sub"><span class="badge bad">выключена</span></p>';
|
||||||
|
status.innerHTML += '<button class="btn" onclick="startEnableTotp()">Включить 2FA</button>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEnableTotp() {
|
||||||
|
const res = await api("/admin/api/2fa/setup", { method: "POST" });
|
||||||
|
document.getElementById("totp-secret-display").textContent = res.secret;
|
||||||
|
document.getElementById("totp-setup-box").dataset.secret = res.secret;
|
||||||
|
document.getElementById("totp-setup-box").style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEnableTotp() {
|
||||||
|
const secret = document.getElementById("totp-setup-box").dataset.secret;
|
||||||
|
const code = document.getElementById("totp-confirm-code").value.trim();
|
||||||
|
const result = document.getElementById("totp-result");
|
||||||
|
try {
|
||||||
|
await api("/admin/api/2fa/enable", { method: "POST", body: JSON.stringify({ secret, code }) });
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">2FA включена</p>';
|
||||||
|
loadTotpStatus();
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Неверный код</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDisableTotp() {
|
||||||
|
const password = document.getElementById("totp-disable-password").value;
|
||||||
|
const result = document.getElementById("totp-result");
|
||||||
|
try {
|
||||||
|
await api("/admin/api/2fa/disable", { method: "POST", body: JSON.stringify({ password }) });
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">2FA отключена</p>';
|
||||||
|
document.getElementById("totp-disable-password").value = "";
|
||||||
|
loadTotpStatus();
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Неверный пароль</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function downloadBackup() {
|
function downloadBackup() {
|
||||||
window.location.href = "/admin/api/backup";
|
window.location.href = "/admin/api/backup";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
api.py
68
api.py
|
|
@ -15,6 +15,7 @@ import db
|
||||||
import links
|
import links
|
||||||
import nodeprov
|
import nodeprov
|
||||||
import payments
|
import payments
|
||||||
|
import totp
|
||||||
import xray_manager
|
import xray_manager
|
||||||
from config import (
|
from config import (
|
||||||
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
|
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
|
||||||
|
|
@ -442,6 +443,25 @@ def admin_login(response: Response, body: dict = Body(...)):
|
||||||
admin = db.verify_admin_login(username, password)
|
admin = db.verify_admin_login(username, password)
|
||||||
if not admin:
|
if not admin:
|
||||||
raise HTTPException(401, "wrong username or password")
|
raise HTTPException(401, "wrong username or password")
|
||||||
|
if admin.get("totp_secret"):
|
||||||
|
pending_token = db.create_pending_totp(admin["id"])
|
||||||
|
return {"ok": True, "needs_totp": True, "pending_token": pending_token}
|
||||||
|
token = db.create_admin_session(admin["id"])
|
||||||
|
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/login/totp")
|
||||||
|
def admin_login_totp(response: Response, body: dict = Body(...)):
|
||||||
|
pending_token = body.get("pending_token") or ""
|
||||||
|
code = (body.get("code") or "").strip()
|
||||||
|
pending = db.resolve_pending_totp(pending_token)
|
||||||
|
if not pending:
|
||||||
|
raise HTTPException(401, "login session expired, log in again")
|
||||||
|
admin = db.get_admin_by_id(pending["admin_id"])
|
||||||
|
if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code):
|
||||||
|
raise HTTPException(401, "wrong code")
|
||||||
|
db.delete_pending_totp(pending_token)
|
||||||
token = db.create_admin_session(admin["id"])
|
token = db.create_admin_session(admin["id"])
|
||||||
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
@ -486,10 +506,7 @@ def admin_create_admin(request: Request, body: dict = Body(...)):
|
||||||
|
|
||||||
@app.delete("/admin/api/admins/{admin_id}")
|
@app.delete("/admin/api/admins/{admin_id}")
|
||||||
def admin_delete_admin(admin_id: int, request: Request):
|
def admin_delete_admin(admin_id: int, request: Request):
|
||||||
token = request.cookies.get(ADMIN_COOKIE)
|
current = _require_current_admin(request)
|
||||||
current = db.get_session_admin(token)
|
|
||||||
if not current:
|
|
||||||
raise HTTPException(401, "unauthorized")
|
|
||||||
if current["id"] == admin_id:
|
if current["id"] == admin_id:
|
||||||
raise HTTPException(400, "cannot delete your own account while logged in as it")
|
raise HTTPException(400, "cannot delete your own account while logged in as it")
|
||||||
try:
|
try:
|
||||||
|
|
@ -499,6 +516,49 @@ def admin_delete_admin(admin_id: int, request: Request):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_current_admin(request: Request):
|
||||||
|
token = request.cookies.get(ADMIN_COOKIE)
|
||||||
|
current = db.get_session_admin(token)
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(401, "unauthorized")
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/2fa/status")
|
||||||
|
def admin_2fa_status(request: Request):
|
||||||
|
current = _require_current_admin(request)
|
||||||
|
admin = db.get_admin_by_id(current["id"])
|
||||||
|
return {"enabled": bool(admin and admin.get("totp_secret"))}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/2fa/setup")
|
||||||
|
def admin_2fa_setup(request: Request):
|
||||||
|
current = _require_current_admin(request)
|
||||||
|
secret = totp.generate_secret()
|
||||||
|
return {"secret": secret, "uri": totp.uri(secret, current["username"])}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/2fa/enable")
|
||||||
|
def admin_2fa_enable(request: Request, body: dict = Body(...)):
|
||||||
|
current = _require_current_admin(request)
|
||||||
|
secret = body.get("secret") or ""
|
||||||
|
code = (body.get("code") or "").strip()
|
||||||
|
if not secret or not totp.verify(secret, code):
|
||||||
|
raise HTTPException(400, "wrong code")
|
||||||
|
db.set_admin_totp_secret(current["id"], secret)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/2fa/disable")
|
||||||
|
def admin_2fa_disable(request: Request, body: dict = Body(...)):
|
||||||
|
current = _require_current_admin(request)
|
||||||
|
password = body.get("password") or ""
|
||||||
|
if not db.verify_admin_password_by_id(current["id"], password):
|
||||||
|
raise HTTPException(401, "wrong password")
|
||||||
|
db.set_admin_totp_secret(current["id"], None)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/api/settings/bot")
|
@app.get("/admin/api/settings/bot")
|
||||||
def admin_get_bot_settings(request: Request):
|
def admin_get_bot_settings(request: Request):
|
||||||
|
|
|
||||||
1
bot.py
1
bot.py
|
|
@ -369,6 +369,7 @@ async def periodic_sync():
|
||||||
log.exception("payment reconciliation failed")
|
log.exception("payment reconciliation failed")
|
||||||
try:
|
try:
|
||||||
db.delete_expired_admin_sessions()
|
db.delete_expired_admin_sessions()
|
||||||
|
db.delete_expired_pending_totp()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("expired admin session cleanup failed")
|
log.exception("expired admin session cleanup failed")
|
||||||
await asyncio.sleep(90)
|
await asyncio.sleep(90)
|
||||||
|
|
|
||||||
61
db.py
61
db.py
|
|
@ -61,6 +61,13 @@ CREATE TABLE IF NOT EXISTS admins (
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_totp (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
admin_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tg_id INTEGER NOT NULL,
|
tg_id INTEGER NOT NULL,
|
||||||
|
|
@ -124,6 +131,10 @@ _NEW_ADMIN_SESSION_COLUMNS = {
|
||||||
"admin_id": "INTEGER",
|
"admin_id": "INTEGER",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_NEW_ADMIN_COLUMNS = {
|
||||||
|
"totp_secret": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _migrate():
|
def _migrate():
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -140,6 +151,10 @@ def _migrate():
|
||||||
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
|
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
|
||||||
if name not in scols:
|
if name not in scols:
|
||||||
conn.execute(f"ALTER TABLE admin_sessions ADD COLUMN {name} {decl}")
|
conn.execute(f"ALTER TABLE admin_sessions ADD COLUMN {name} {decl}")
|
||||||
|
acols = {r["name"] for r in conn.execute("PRAGMA table_info(admins)").fetchall()}
|
||||||
|
for name, decl in _NEW_ADMIN_COLUMNS.items():
|
||||||
|
if name not in acols:
|
||||||
|
conn.execute(f"ALTER TABLE admins ADD COLUMN {name} {decl}")
|
||||||
if needs_sort_order_backfill:
|
if needs_sort_order_backfill:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
|
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
|
||||||
|
|
@ -483,6 +498,52 @@ def delete_admin(admin_id: int):
|
||||||
conn.execute("DELETE FROM admin_sessions WHERE admin_id=?", (admin_id,))
|
conn.execute("DELETE FROM admin_sessions WHERE admin_id=?", (admin_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_by_id(admin_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT id, username, created_at, totp_secret FROM admins WHERE id=?", (admin_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin_password_by_id(admin_id: int, password: str) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT password_hash FROM admins WHERE id=?", (admin_id,)).fetchone()
|
||||||
|
return bool(row) and _verify_password(password, row["password_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
def set_admin_totp_secret(admin_id: int, secret: str | None):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE admins SET totp_secret=? WHERE id=?", (secret, admin_id))
|
||||||
|
|
||||||
|
|
||||||
|
def create_pending_totp(admin_id: int, minutes: int = 5) -> str:
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
expires = datetime.datetime.utcnow() + datetime.timedelta(minutes=minutes)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pending_totp (token, admin_id, created_at, expires_at) VALUES (?,?,?,?)",
|
||||||
|
(token, admin_id, now_iso(), expires.isoformat()),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pending_totp(token: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM pending_totp WHERE token=? AND expires_at>?", (token, now_iso())
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_pending_totp(token: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pending_totp WHERE token=?", (token,))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_expired_pending_totp():
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pending_totp WHERE expires_at<=?", (now_iso(),))
|
||||||
|
|
||||||
|
|
||||||
def list_all_subscriptions(limit: int = 200):
|
def list_all_subscriptions(limit: int = 200):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|
|
||||||
45
totp.py
Normal file
45
totp.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time as timemod
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret() -> str:
|
||||||
|
return base64.b32encode(os.urandom(20)).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _hotp(secret_b32: str, counter: int) -> str:
|
||||||
|
padded = secret_b32 + "=" * ((8 - len(secret_b32) % 8) % 8)
|
||||||
|
key = base64.b32decode(padded.upper())
|
||||||
|
msg = struct.pack(">Q", counter)
|
||||||
|
h = hmac.new(key, msg, hashlib.sha1).digest()
|
||||||
|
offset = h[-1] & 0x0F
|
||||||
|
code = (struct.unpack(">I", h[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000
|
||||||
|
return f"{code:06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def now_code(secret_b32: str, for_time: float | None = None) -> str:
|
||||||
|
t = for_time if for_time is not None else timemod.time()
|
||||||
|
counter = int(t // 30)
|
||||||
|
return _hotp(secret_b32, counter)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(secret_b32: str, code: str, window: int = 1) -> bool:
|
||||||
|
if not code or not code.isdigit() or len(code) != 6:
|
||||||
|
return False
|
||||||
|
counter = int(timemod.time() // 30)
|
||||||
|
for offset in range(-window, window + 1):
|
||||||
|
if hmac.compare_digest(_hotp(secret_b32, counter + offset), code):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def uri(secret_b32: str, username: str, issuer: str = "MBS Panel") -> str:
|
||||||
|
label = urllib.parse.quote(f"{issuer}:{username}")
|
||||||
|
return (
|
||||||
|
f"otpauth://totp/{label}?secret={secret_b32}"
|
||||||
|
f"&issuer={urllib.parse.quote(issuer)}&algorithm=SHA1&digits=6&period=30"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue