Add TOTP for Admin login

This commit is contained in:
Ettore
2026-05-10 16:38:12 +02:00
parent c4355eb371
commit 9f703c1bfa
8 changed files with 264 additions and 17 deletions

View File

@@ -71,6 +71,8 @@ class AdminUser(Base):
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String, nullable=False)
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
totp_secret_enc: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Fernet-encrypted TOTP secret
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
class TelegramConfig(Base):

View File

@@ -12,6 +12,7 @@ from core.database import Keypass
class AdminLoginRequest(BaseModel):
username: str
password: str
otp_code: Optional[str] = None # 6-digit TOTP code; required when account has 2FA enabled
class KeypassLoginRequest(BaseModel):
@@ -23,6 +24,12 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
class AdminLoginResponse(BaseModel):
token: Optional[str] = None
token_type: str = "bearer"
otp_required: bool = False
# ── Keypasses ─────────────────────────────────────────────────────────────────
class KeypassCreate(BaseModel):
@@ -114,6 +121,12 @@ class AdminUserResponse(BaseModel):
id: int
username: str
role: str # 'admin' | 'manager'
totp_enabled: bool = False
class TotpSetupResponse(BaseModel):
provisioning_uri: str # otpauth:// URI for QR scanning
qr_image_b64: str # base64-encoded PNG of the QR code
class AdminUserCreate(BaseModel):

View File

@@ -1,12 +1,17 @@
import base64
import io
from typing import Optional
import pyotp
import qrcode
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.auth import hash_password
from core.auth import decrypt_secret, encrypt_secret, hash_password
from core.database import AdminUser, get_db
from core.dependencies import require_admin
from core.schemas import AdminUserCreate, AdminUserResponse, AdminPasswordChange
from core.schemas import AdminPasswordChange, AdminUserCreate, AdminUserResponse, TotpSetupResponse
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
@@ -15,7 +20,7 @@ router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
async def list_admins(
db: Session = Depends(get_db), _: dict = Depends(require_admin)
):
return [AdminUserResponse(id=u.id, username=u.username, role=u.role) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
return [AdminUserResponse(id=u.id, username=u.username, role=u.role, totp_enabled=u.totp_enabled) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
@router.post("", response_model=AdminUserResponse, status_code=201)
@@ -73,3 +78,70 @@ async def change_password(
raise HTTPException(404, "Admin not found")
user.password_hash = hash_password(req.new_password)
db.commit()
# ── TOTP (2FA) ────────────────────────────────────────────────────────────────
@router.post("/{username}/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(
username: str,
db: Session = Depends(get_db),
caller: dict = Depends(require_admin),
):
"""Generate a new TOTP secret and return the provisioning URI for QR scanning.
Does NOT enable 2FA yet — call /enable with a valid code to activate."""
if caller["sub"] != username:
raise HTTPException(403, "You can only configure 2FA for your own account")
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
if not user:
raise HTTPException(404, "Admin not found")
secret = pyotp.random_base32()
user.totp_secret_enc = encrypt_secret(secret)
user.totp_enabled = False # pending confirmation
db.commit()
uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="Lagomare Gates")
buf = io.BytesIO()
qrcode.make(uri).save(buf)
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return TotpSetupResponse(provisioning_uri=uri, qr_image_b64=qr_b64)
class TotpConfirm(BaseModel):
otp_code: str
@router.post("/{username}/totp/enable", status_code=204)
async def totp_enable(
username: str,
req: TotpConfirm,
db: Session = Depends(get_db),
caller: dict = Depends(require_admin),
):
"""Verify the OTP code and activate 2FA for the account."""
if caller["sub"] != username:
raise HTTPException(403, "You can only configure 2FA for your own account")
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
if not user or not user.totp_secret_enc:
raise HTTPException(422, "Run /setup first to generate a secret")
secret = decrypt_secret(user.totp_secret_enc)
if not pyotp.TOTP(secret).verify(req.otp_code.strip(), valid_window=1):
raise HTTPException(422, "Invalid OTP code — make sure your authenticator is in sync")
user.totp_enabled = True
db.commit()
@router.delete("/{username}/totp", status_code=204)
async def totp_disable(
username: str,
db: Session = Depends(get_db),
caller: dict = Depends(require_admin),
):
"""Disable 2FA and discard the stored secret."""
if caller["sub"] != username:
raise HTTPException(403, "You can only configure 2FA for your own account")
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
if not user:
raise HTTPException(404, "Admin not found")
user.totp_enabled = False
user.totp_secret_enc = None
db.commit()

View File

@@ -2,31 +2,42 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import pyotp
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from core.auth import create_token, verify_password
from core.auth import create_token, decrypt_secret, verify_password
from core.config import utcnow
from core.database import AdminUser, Keypass, get_db
from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse
from core.schemas import AdminLoginRequest, AdminLoginResponse, KeypassLoginRequest, TokenResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger(__name__)
@router.post("/admin", response_model=TokenResponse)
@router.post("/admin", response_model=AdminLoginResponse)
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
if not user or not verify_password(req.password, user.password_hash):
logger.warning("Failed admin login attempt for username=%r", req.username)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
if user.totp_enabled and user.totp_secret_enc:
if not req.otp_code:
return AdminLoginResponse(otp_required=True)
secret = decrypt_secret(user.totp_secret_enc)
totp = pyotp.TOTP(secret)
if not totp.verify(req.otp_code.strip(), valid_window=1):
logger.warning("Invalid OTP for username=%r", user.username)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid OTP code")
logger.info("Admin login: username=%r role=%r", user.username, user.role)
token = create_token({
"sub": user.username,
"role": "admin",
"scope": user.role, # 'admin' | 'manager'
"scope": user.role,
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
})
return TokenResponse(token=token)
return AdminLoginResponse(token=token)
@router.post("/keypass", response_model=TokenResponse)

View File

@@ -97,6 +97,12 @@
<label for="admin-password">Password</label>
<input id="admin-password" type="password" autocomplete="current-password" />
</div>
<div class="field hidden" id="otp-field">
<label for="admin-otp">Authenticator code</label>
<input id="admin-otp" type="text" inputmode="numeric" pattern="[0-9]{6}"
autocomplete="one-time-code" maxlength="6" placeholder="6-digit code"
style="font-family:monospace;font-size:1.3rem;letter-spacing:.2em;text-align:center" />
</div>
<p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
</form>
@@ -531,6 +537,33 @@
</div>
</div>
<!-- ── TOTP setup modal ──────────────────────────────────────────────────── -->
<div id="totp-modal" class="modal-backdrop hidden">
<div class="modal" style="max-width:360px;text-align:center">
<h3>Set up Two-Factor Authentication</h3>
<div id="totp-step-scan">
<p style="color:var(--text-muted);font-size:.88rem;margin:.75rem 0 1rem">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to confirm.
</p>
<div style="display:flex;justify-content:center;background:#fff;padding:.75rem;border-radius:8px;margin-bottom:1rem">
<canvas id="totp-qr-canvas" style="max-width:100%;height:auto;display:block"></canvas>
</div>
<p id="totp-uri-fallback" style="font-size:.72rem;color:var(--text-muted);word-break:break-all;margin-bottom:1rem"></p>
<div class="field">
<label for="totp-confirm-code">Verification code</label>
<input id="totp-confirm-code" type="text" inputmode="numeric" pattern="[0-9]{6}"
maxlength="6" placeholder="000000"
style="font-family:monospace;font-size:1.4rem;letter-spacing:.25em;text-align:center" />
</div>
<p id="totp-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="totp-cancel" class="btn btn-ghost">Cancel</button>
<button type="button" id="totp-confirm-btn" class="btn btn-primary">Enable 2FA</button>
</div>
</div>
</div>
</div>
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div>

View File

@@ -63,20 +63,42 @@ function showAdmin() {
}
// ── Login ─────────────────────────────────────────────────────────────────────
let _pendingCredentials = null; // { username, password } while OTP step is shown
document.getElementById("login-form").addEventListener("submit", async e => {
e.preventDefault();
const username = document.getElementById("admin-username").value.trim();
const password = document.getElementById("admin-password").value;
const errEl = document.getElementById("login-error");
const btn = e.target.querySelector("button[type=submit]");
const otpField = document.getElementById("otp-field");
btn.disabled = true;
errEl.classList.add("hidden");
try {
const data = await api("POST", "/api/auth/admin", { username, password });
saveToken(data.token);
showAdmin();
} catch (e) {
errEl.textContent = e.message;
const body = _pendingCredentials
? { ..._pendingCredentials, otp_code: document.getElementById("admin-otp").value.trim() }
: { username: document.getElementById("admin-username").value.trim(),
password: document.getElementById("admin-password").value };
const data = await api("POST", "/api/auth/admin", body);
if (data.otp_required) {
_pendingCredentials = {
username: document.getElementById("admin-username").value.trim(),
password: document.getElementById("admin-password").value,
};
otpField.classList.remove("hidden");
document.getElementById("admin-otp").focus();
errEl.textContent = "Enter the 6-digit code from your authenticator app.";
errEl.classList.remove("hidden");
} else {
_pendingCredentials = null;
otpField.classList.add("hidden");
document.getElementById("admin-otp").value = "";
saveToken(data.token);
showAdmin();
}
} catch (err) {
_pendingCredentials = null;
otpField.classList.add("hidden");
document.getElementById("admin-otp").value = "";
errEl.textContent = err.message;
errEl.classList.remove("hidden");
} finally {
btn.disabled = false;
@@ -641,15 +663,39 @@ async function loadAdmins() {
const roleBadge = u.role === "admin"
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
const totpBadge = u.totp_enabled
? '<span class="badge badge-green" style="font-size:.75em;background:var(--accent-warn,#b45309);color:#fff" title="2FA enabled">2FA ✓</span>'
: '';
const is_me = u.username === me;
const totpBtn = is_me
? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-totp="${esc(u.username)}" data-totp-enabled="${u.totp_enabled}">${u.totp_enabled ? "Disable 2FA" : "Enable 2FA"}</button>`
: "";
const tr = document.createElement("tr");
tr.innerHTML = `
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${u.username === me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</div></td>
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${is_me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge} ${totpBadge}</div></td>
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
${totpBtn}
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
${!is_me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
</div></td>`;
tbody.appendChild(tr);
}
tbody.querySelectorAll("[data-totp]").forEach(btn => {
btn.addEventListener("click", async () => {
const username = btn.dataset.totp;
const enabled = btn.dataset.totpEnabled === "true";
if (enabled) {
if (!confirm("Disable two-factor authentication for your account?")) return;
try {
await api("DELETE", `/api/admin/admins/${encodeURIComponent(username)}/totp`);
showToast("2FA disabled");
loadAdmins();
} catch (e) { showToast(e.message, true); }
} else {
openTotpSetup(username);
}
});
});
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
btn.addEventListener("click", () => {
document.getElementById("chpw-username").value = btn.dataset.chpw;
@@ -671,6 +717,57 @@ async function loadAdmins() {
});
}
// ── TOTP setup modal ──────────────────────────────────────────────────────────
let _totpUsername = null;
async function openTotpSetup(username) {
_totpUsername = username;
document.getElementById("totp-confirm-code").value = "";
document.getElementById("totp-error").classList.add("hidden");
document.getElementById("totp-modal").classList.remove("hidden");
try {
const data = await api("POST", `/api/admin/admins/${encodeURIComponent(username)}/totp/setup`);
// Render QR from base64 PNG returned by the server
const canvas = document.getElementById("totp-qr-canvas");
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
canvas.style.width = Math.min(img.width, 220) + "px";
canvas.style.height = "auto";
canvas.getContext("2d").drawImage(img, 0, 0);
};
img.src = "data:image/png;base64," + data.qr_image_b64;
document.getElementById("totp-uri-fallback").textContent = data.provisioning_uri;
} catch (e) {
document.getElementById("totp-error").textContent = e.message;
document.getElementById("totp-error").classList.remove("hidden");
}
}
document.getElementById("totp-cancel").addEventListener("click", () => {
document.getElementById("totp-modal").classList.add("hidden");
_totpUsername = null;
});
document.getElementById("totp-confirm-btn").addEventListener("click", async () => {
if (!_totpUsername) return;
const code = document.getElementById("totp-confirm-code").value.trim();
const errEl = document.getElementById("totp-error");
errEl.classList.add("hidden");
if (!code) { errEl.textContent = "Enter the 6-digit code from your app."; errEl.classList.remove("hidden"); return; }
try {
await api("POST", `/api/admin/admins/${encodeURIComponent(_totpUsername)}/totp/enable`, { otp_code: code });
document.getElementById("totp-modal").classList.add("hidden");
_totpUsername = null;
showToast("Two-factor authentication enabled");
loadAdmins();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove("hidden");
}
});
document.getElementById("btn-new-admin").addEventListener("click", () => {
document.getElementById("admin-new-username").value = "";
document.getElementById("admin-new-password").value = "";