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

@@ -9,6 +9,7 @@ A web-based gate access management and control system. Authorized users can remo
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date - **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) to trigger gate macros - **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) to trigger gate macros
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels - **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
- **Two-factor authentication (TOTP)** — admins can enable app-based 2FA (Google Authenticator, Authy, etc.) on their account
- **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated - **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated
- **Keypass QR codes** — generate a scannable QR code for each keypass; scanning opens the PWA and logs in automatically - **Keypass QR codes** — generate a scannable QR code for each keypass; scanning opens the PWA and logs in automatically
- **Keypass code options** — choose character set (alphanumeric, alpha, numeric, or a 4-word passphrase) and length when auto-generating codes - **Keypass code options** — choose character set (alphanumeric, alpha, numeric, or a 4-word passphrase) and length when auto-generating codes
@@ -23,6 +24,7 @@ A web-based gate access management and control system. Authorized users can remo
| ORM | SQLAlchemy | | ORM | SQLAlchemy |
| Database | SQLite | | Database | SQLite |
| Auth | JWT (HS256) + bcrypt | | Auth | JWT (HS256) + bcrypt |
| 2FA | TOTP (RFC 6238) via pyotp |
| Credential storage | Fernet symmetric encryption | | Credential storage | Fernet symmetric encryption |
| Gate integration | AVConnect HTTP API | | Gate integration | AVConnect HTTP API |
| Notifications | Telegram Bot API | | Notifications | Telegram Bot API |
@@ -101,6 +103,9 @@ data/
| POST | `/api/admin/admins` | Create an admin user | | POST | `/api/admin/admins` | Create an admin user |
| DELETE | `/api/admin/admins/{username}` | Delete an admin user | | DELETE | `/api/admin/admins/{username}` | Delete an admin user |
| PATCH | `/api/admin/admins/{username}/password` | Change password | | PATCH | `/api/admin/admins/{username}/password` | Change password |
| POST | `/api/admin/admins/{username}/totp/setup` | Generate a new TOTP secret and return provisioning URI + QR |
| POST | `/api/admin/admins/{username}/totp/enable` | Verify a TOTP code and activate 2FA |
| DELETE | `/api/admin/admins/{username}/totp` | Disable 2FA and discard the secret |
### Admin — AVConnect Credentials (admin only) ### Admin — AVConnect Credentials (admin only)
@@ -232,6 +237,19 @@ Configure a Telegram bot to receive a message in a group or chat every time a ga
Notifications are sent in a background thread and never block the gate open response. Failures are logged as warnings and do not affect gate operation. Notifications are sent in a background thread and never block the gate open response. Failures are logged as warnings and do not affect gate operation.
## Two-Factor Authentication (TOTP)
Each admin account can independently enable TOTP-based two-factor authentication:
1. Open **Admin → Admin Users** and click **Enable 2FA** on your own row
2. Scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.)
3. Enter the 6-digit code to confirm — 2FA is only activated after a successful verification
4. On subsequent logins, after entering your password you will be prompted for the current TOTP code
To disable, click **Disable 2FA** on your row and confirm.
> Only the account owner can enable or disable their own 2FA. TOTP secrets are stored Fernet-encrypted in the database.
## Roles ## Roles
| Role | Permissions | | Role | Permissions |

View File

@@ -8,3 +8,4 @@ requests>=2.31.0
fake-useragent>=1.5.0 fake-useragent>=1.5.0
python-multipart>=0.0.9 python-multipart>=0.0.9
qrcode[pil]>=7.4 qrcode[pil]>=7.4
pyotp>=2.9

View File

@@ -71,6 +71,8 @@ class AdminUser(Base):
username: Mapped[str] = mapped_column(String, unique=True, nullable=False) username: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String, nullable=False) password_hash: Mapped[str] = mapped_column(String, nullable=False)
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager' 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): class TelegramConfig(Base):

View File

@@ -12,6 +12,7 @@ from core.database import Keypass
class AdminLoginRequest(BaseModel): class AdminLoginRequest(BaseModel):
username: str username: str
password: str password: str
otp_code: Optional[str] = None # 6-digit TOTP code; required when account has 2FA enabled
class KeypassLoginRequest(BaseModel): class KeypassLoginRequest(BaseModel):
@@ -23,6 +24,12 @@ class TokenResponse(BaseModel):
token_type: str = "bearer" token_type: str = "bearer"
class AdminLoginResponse(BaseModel):
token: Optional[str] = None
token_type: str = "bearer"
otp_required: bool = False
# ── Keypasses ───────────────────────────────────────────────────────────────── # ── Keypasses ─────────────────────────────────────────────────────────────────
class KeypassCreate(BaseModel): class KeypassCreate(BaseModel):
@@ -114,6 +121,12 @@ class AdminUserResponse(BaseModel):
id: int id: int
username: str username: str
role: str # 'admin' | 'manager' 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): class AdminUserCreate(BaseModel):

View File

@@ -1,12 +1,17 @@
import base64
import io
from typing import Optional from typing import Optional
import pyotp
import qrcode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session 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.database import AdminUser, get_db
from core.dependencies import require_admin 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"]) 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( async def list_admins(
db: Session = Depends(get_db), _: dict = Depends(require_admin) 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) @router.post("", response_model=AdminUserResponse, status_code=201)
@@ -73,3 +78,70 @@ async def change_password(
raise HTTPException(404, "Admin not found") raise HTTPException(404, "Admin not found")
user.password_hash = hash_password(req.new_password) user.password_hash = hash_password(req.new_password)
db.commit() 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 datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import pyotp
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session 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.config import utcnow
from core.database import AdminUser, Keypass, get_db 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger(__name__) 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)): async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first() user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
if not user or not verify_password(req.password, user.password_hash): if not user or not verify_password(req.password, user.password_hash):
logger.warning("Failed admin login attempt for username=%r", req.username) logger.warning("Failed admin login attempt for username=%r", req.username)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials") 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) logger.info("Admin login: username=%r role=%r", user.username, user.role)
token = create_token({ token = create_token({
"sub": user.username, "sub": user.username,
"role": "admin", "role": "admin",
"scope": user.role, # 'admin' | 'manager' "scope": user.role,
"exp": datetime.now(timezone.utc) + timedelta(hours=24), "exp": datetime.now(timezone.utc) + timedelta(hours=24),
}) })
return TokenResponse(token=token) return AdminLoginResponse(token=token)
@router.post("/keypass", response_model=TokenResponse) @router.post("/keypass", response_model=TokenResponse)

View File

@@ -97,6 +97,12 @@
<label for="admin-password">Password</label> <label for="admin-password">Password</label>
<input id="admin-password" type="password" autocomplete="current-password" /> <input id="admin-password" type="password" autocomplete="current-password" />
</div> </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> <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> <button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
</form> </form>
@@ -531,6 +537,33 @@
</div> </div>
</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 ───────────────────────────────────────────────────────────── --> <!-- ── Toast ───────────────────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" aria-live="assertive"></div> <div id="toast" class="toast hidden" aria-live="assertive"></div>

View File

@@ -63,20 +63,42 @@ function showAdmin() {
} }
// ── Login ───────────────────────────────────────────────────────────────────── // ── Login ─────────────────────────────────────────────────────────────────────
let _pendingCredentials = null; // { username, password } while OTP step is shown
document.getElementById("login-form").addEventListener("submit", async e => { document.getElementById("login-form").addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const username = document.getElementById("admin-username").value.trim();
const password = document.getElementById("admin-password").value;
const errEl = document.getElementById("login-error"); const errEl = document.getElementById("login-error");
const btn = e.target.querySelector("button[type=submit]"); const btn = e.target.querySelector("button[type=submit]");
const otpField = document.getElementById("otp-field");
btn.disabled = true; btn.disabled = true;
errEl.classList.add("hidden"); errEl.classList.add("hidden");
try { try {
const data = await api("POST", "/api/auth/admin", { username, password }); const body = _pendingCredentials
saveToken(data.token); ? { ..._pendingCredentials, otp_code: document.getElementById("admin-otp").value.trim() }
showAdmin(); : { username: document.getElementById("admin-username").value.trim(),
} catch (e) { password: document.getElementById("admin-password").value };
errEl.textContent = e.message; 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"); errEl.classList.remove("hidden");
} finally { } finally {
btn.disabled = false; btn.disabled = false;
@@ -641,15 +663,39 @@ async function loadAdmins() {
const roleBadge = u.role === "admin" const roleBadge = u.role === "admin"
? '<span class="badge badge-green" style="font-size:.75em">admin</span>' ? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
: '<span class="badge badge-muted" style="font-size:.75em">manager</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"); const tr = document.createElement("tr");
tr.innerHTML = ` 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"> <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> <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>`; </div></td>`;
tbody.appendChild(tr); 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 => { tbody.querySelectorAll("[data-chpw]").forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
document.getElementById("chpw-username").value = btn.dataset.chpw; 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("btn-new-admin").addEventListener("click", () => {
document.getElementById("admin-new-username").value = ""; document.getElementById("admin-new-username").value = "";
document.getElementById("admin-new-password").value = ""; document.getElementById("admin-new-password").value = "";