Add TOTP for Admin login
This commit is contained in:
18
README.md
18
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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user