diff --git a/README.md b/README.md index 52160c8..eaf8b78 100644 --- a/README.md +++ b/README.md @@ -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 - **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 +- **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 - **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 @@ -23,6 +24,7 @@ A web-based gate access management and control system. Authorized users can remo | ORM | SQLAlchemy | | Database | SQLite | | Auth | JWT (HS256) + bcrypt | +| 2FA | TOTP (RFC 6238) via pyotp | | Credential storage | Fernet symmetric encryption | | Gate integration | AVConnect HTTP API | | Notifications | Telegram Bot API | @@ -101,6 +103,9 @@ data/ | POST | `/api/admin/admins` | Create an admin user | | DELETE | `/api/admin/admins/{username}` | Delete an admin user | | 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) @@ -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. +## 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 | Role | Permissions | diff --git a/requirements.txt b/requirements.txt index a82223b..d8062b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ requests>=2.31.0 fake-useragent>=1.5.0 python-multipart>=0.0.9 qrcode[pil]>=7.4 +pyotp>=2.9 diff --git a/src/core/database.py b/src/core/database.py index 404b49a..d4bb0a1 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -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): diff --git a/src/core/schemas.py b/src/core/schemas.py index 754448d..e0d9529 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -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): diff --git a/src/routers/admins.py b/src/routers/admins.py index 0405393..9aeb52a 100644 --- a/src/routers/admins.py +++ b/src/routers/admins.py @@ -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() diff --git a/src/routers/auth.py b/src/routers/auth.py index f5624b3..2fe2668 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -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) diff --git a/src/static/admin.html b/src/static/admin.html index ff1d7b7..6804ca2 100644 --- a/src/static/admin.html +++ b/src/static/admin.html @@ -97,6 +97,12 @@ + @@ -531,6 +537,33 @@ + + + diff --git a/src/static/admin.js b/src/static/admin.js index 27f2179..a5c73a8 100644 --- a/src/static/admin.js +++ b/src/static/admin.js @@ -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" ? 'admin' : 'manager'; + const totpBadge = u.totp_enabled + ? '2FA ✓' + : ''; + const is_me = u.username === me; + const totpBtn = is_me + ? `` + : ""; const tr = document.createElement("tr"); tr.innerHTML = ` -
${esc(u.username)}${u.username === me ? 'you' : ""} ${roleBadge}
+
${esc(u.username)}${is_me ? 'you' : ""} ${roleBadge} ${totpBadge}
+ ${totpBtn} - ${u.username !== me ? `` : ""} + ${!is_me ? `` : ""}
`; 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 = "";