access/views_reader.py
Patrick Mulligan 40564f06ec feat: access extension — NFC door access via boltcards SUN + Home Assistant
Promoted from the door-portal scratch repo to its own repo for install via the
aiolabs catalog. Authenticates a tapped Bolt Card via boltcards /verify,
authorizes against per-door grants, and fires the door's local Home Assistant
webhook to unlock a Z-Wave lock. Fails closed; logs every attempt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 22:47:41 +02:00

54 lines
2.4 KiB
Python

from fastapi import APIRouter, Request
from .crud import get_active_grant, get_door_by_id_or_name, record_log
from .models import CheckRequest
from .services import trigger_unlock, verify_card
access_reader_router = APIRouter()
def _client_ip(request: Request) -> str:
if "x-real-ip" in request.headers:
return request.headers["x-real-ip"]
if "x-forwarded-for" in request.headers:
return request.headers["x-forwarded-for"]
return request.client.host if request.client else ""
# The door reader (Pi + PN532) calls this. Fails closed at every step.
# POST /access/api/v1/check
# X-Controller-Token: <door.controller_token>
# { "doorId": "...", "external_id": "...", "p": "...", "c": "..." }
@access_reader_router.post("/api/v1/check")
async def check(data: CheckRequest, request: Request):
ip = _client_ip(request)
door = await get_door_by_id_or_name(data.doorId)
# Unknown/disabled door, or wrong controller token → deny (no card read logged
# against a real door we can't identify; log against the requested id).
if not door or not door.enabled:
await record_log(data.doorId, data.external_id, "deny", "door_unknown", ip)
return {"allow": False, "reason": "door_unknown"}
token = request.headers.get("x-controller-token", "")
if not token or token != door.controller_token:
await record_log(door.id, data.external_id, "deny", "bad_controller_token", ip)
return {"allow": False, "reason": "bad_controller_token"}
# 1) Authenticate the card (NTAG424 SUN via boltcards).
if not await verify_card(door, data.external_id, data.p, data.c):
await record_log(door.id, data.external_id, "deny", "card_invalid", ip)
return {"allow": False, "reason": "card_invalid"}
# 2) Authorize: does this card have an active grant on this door?
grant = await get_active_grant(door.id, data.external_id)
if not grant:
await record_log(door.id, data.external_id, "deny", "not_authorized", ip)
return {"allow": False, "reason": "not_authorized"}
# 3) Actuate: fire the Home Assistant unlock webhook.
unlocked = await trigger_unlock(door, data.external_id)
decision = "allow" if unlocked else "deny"
reason = "unlocked" if unlocked else "unlock_failed"
await record_log(door.id, data.external_id, decision, reason, ip)
return {"allow": unlocked, "reason": reason}