From 40564f06ec1013a502f0888ad61985f1d69d0168 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 7 Aug 2026 22:47:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20access=20extension=20=E2=80=94=20NFC=20?= =?UTF-8?q?door=20access=20via=20boltcards=20SUN=20+=20Home=20Assistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 70 ++++++++ __init__.py | 24 +++ __pycache__/__init__.cpython-314.pyc | Bin 0 -> 1001 bytes __pycache__/crud.cpython-314.pyc | Bin 0 -> 10299 bytes __pycache__/migrations.cpython-314.pyc | Bin 0 -> 1922 bytes __pycache__/models.cpython-314.pyc | Bin 0 -> 3968 bytes __pycache__/services.cpython-314.pyc | Bin 0 -> 4313 bytes __pycache__/views.cpython-314.pyc | Bin 0 -> 1241 bytes __pycache__/views_api.cpython-314.pyc | Bin 0 -> 8465 bytes __pycache__/views_reader.cpython-314.pyc | Bin 0 -> 3509 bytes config.json | 13 ++ crud.py | 160 ++++++++++++++++++ manifest.json | 9 + migrations.py | 45 +++++ models.py | 75 +++++++++ services.py | 56 +++++++ static/image/access.png | Bin 0 -> 199 bytes static/js/index.js | 141 ++++++++++++++++ templates/access/index.html | 203 +++++++++++++++++++++++ views.py | 18 ++ views_api.py | 118 +++++++++++++ views_reader.py | 54 ++++++ 22 files changed, 986 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-314.pyc create mode 100644 __pycache__/crud.cpython-314.pyc create mode 100644 __pycache__/migrations.cpython-314.pyc create mode 100644 __pycache__/models.cpython-314.pyc create mode 100644 __pycache__/services.cpython-314.pyc create mode 100644 __pycache__/views.cpython-314.pyc create mode 100644 __pycache__/views_api.cpython-314.pyc create mode 100644 __pycache__/views_reader.cpython-314.pyc create mode 100644 config.json create mode 100644 crud.py create mode 100644 manifest.json create mode 100644 migrations.py create mode 100644 models.py create mode 100644 services.py create mode 100644 static/image/access.png create mode 100644 static/js/index.js create mode 100644 templates/access/index.html create mode 100644 views.py create mode 100644 views_api.py create mode 100644 views_reader.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..77589e3 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Access — LNbits extension + +NFC **door access** for LNbits. A tapped Bolt Card (NTAG424) is authenticated +via the `boltcards` SUN check, authorized against per-door **grants**, and — if +allowed — the door's local **Home Assistant** webhook is fired to unlock a +Z-Wave lock. Every tap is logged. + +Runs on the **on-prem LNbits** on the door's LAN, so the unlock path stays local +and the door does not depend on the internet. + +## Flow + +```text +Pi + PN532 reader + → POST /access/api/v1/check (X-Controller-Token: ) + { "doorId": "...", "external_id": "...", "p": "...", "c": "..." } + 1. authenticate → boltcards /verify (valid, non-replayed SUN?) + 2. authorize → active grant for this external_id on this door? + 3. actuate → POST door.ha_webhook_url (→ lock.unlock → Z-Wave) + 4. log → access.logs (allow/deny + reason) + → { "allow": true|false, "reason": "..." } +``` + +The reader never unlocks directly; this extension owns the allow/deny decision +and the Home Assistant call. Fails **closed** at every step. + +## Data model + +- **doors** — a resource: `name`, `controller_token` (reader secret), + `ha_webhook_url` (local HA webhook), `boltcards_base_url` (blank = this + instance), `unlock_timeout_ms`, `enabled`. +- **grants** — `external_id → door` permission, `enabled`/`expires_at`. +- **logs** — every allow/deny with a reason. + +## Admin API (wallet admin key) + +- `GET/POST /access/api/v1/doors`, `PUT/DELETE /access/api/v1/doors/{id}` +- `GET/POST /access/api/v1/grants`, `DELETE /access/api/v1/grants/{id}` +- `GET /access/api/v1/logs` + +## Reader API (controller token) + +```http +POST /access/api/v1/check +X-Controller-Token: +Content-Type: application/json + +{ "doorId": "front-door", "external_id": "abc123", "p": "<32-hex>", "c": "<16-hex>" } +``` + +## Setup + +1. Install on the on-prem LNbits (same instance as `boltcards`). +2. Create a **door**, set its Home Assistant webhook URL, copy its + **controller token** to the reader config. +3. Issue Bolt Cards in `boltcards` as usual; add a **grant** (the card's + `external_id` → the door). +4. Point the door reader (Pi) at `/access/api/v1/check` with the token. + +## Notes + +- Card authentication delegates to `boltcards` over loopback HTTP via its + **`/api/v1/verify/{external_id}`** endpoint (aiolabs fork ≥ **1.1.1-aio.2**) — + a side-effect-light SUN check that returns `{"authenticated": true, …}` + without `/scan`'s spend semantics (no withdrawRequest, no daily-limit, no + "hit"), while still advancing the SUN counter for replay protection. +- Requires boltcards **1.1.1-aio.2+** installed on the same LNbits. +- Empty `ha_webhook_url` = authorize + log only (bring-up mode; no unlock). +- Keep this on the LAN; use a strong per-door controller token; never expose + Home Assistant publicly. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dd44c4b --- /dev/null +++ b/__init__.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter + +from .crud import db +from .views import access_generic_router +from .views_api import access_api_router +from .views_reader import access_reader_router + +access_static_files = [ + { + "path": "/access/static", + "name": "access_static", + } +] + +access_ext: APIRouter = APIRouter(prefix="/access", tags=["access"]) +access_ext.include_router(access_generic_router) +access_ext.include_router(access_api_router) +access_ext.include_router(access_reader_router) + +__all__ = [ + "access_ext", + "access_static_files", + "db", +] diff --git a/__pycache__/__init__.cpython-314.pyc b/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..684b8e2e93d1bc2d1081502a19da682781c2f9e3 GIT binary patch literal 1001 zcmdPqaxa1jv*n-%DID$C! zne-S-xMP_V8G^Wixb+x=c+8m~HkR@&rgj#E{8YHWNk&AEycqf-1NW zD)bnF`2876_=5P988ii6f{fN=yv6Al;2D%(T9R6%$#{z?B}tRXFHQ!90tka#%)-FHz|6qF@Y#WZfuVsR zj3JK6qJ@DWg_(gth9QtKgHfL$jv)r55{xYw7#LExKyoqAP-kGUW(s0dV_;xVX3%7M z2@<`<1+h3jwW36m>6UbSd~$wXN@huBeqLfud}3Z+KFIa?dBySZMIgI18E>(r|KW{AAjy9^p&r1yb?fs_A&fWi$9z6tyvSeaSbJ}|S2GhJX1{>aB5skB0JhsF-6 zD-tdZT$dSKZZL3NXOO(eAbCMb{|bWv5?A{QgANi`?+SzdCl(fNrY80xQ3eJE0CU&t Ao&W#< literal 0 HcmV?d00001 diff --git a/__pycache__/crud.cpython-314.pyc b/__pycache__/crud.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d67beb884d5b77f0b3866f3b50838afc565ada19 GIT binary patch literal 10299 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 literal 0 HcmV?d00001 diff --git a/__pycache__/migrations.cpython-314.pyc b/__pycache__/migrations.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23a65775782ba7501c74aadf75926dddfbae7ad2 GIT binary patch literal 1922 zcmdPqFLkU9=qcVdgQ!-2rq=c1$ zfuWg!f#ov`BLjmHgFHh6Ll{EwY1_p*8#xRBeCMXAq;$aA4GGaD?>TqWWV$Nb_ zV5nehVD@3K0c@_@)C1X@yRFW=am%Y z=j5ao#h2t~r{>{Pn~@k_o|=@ApPwCHT9kvTQo+U5%`wy`L_u90)d5NQIVGR~D~?Y} zEKVgsV`*MaesXqvNoHA2_rX+O zQdF9Xsvcy6LWrlYYjB98Zvdu_Dgk(yYg&Vueu++mVilHF!0JI!3c|!jD=10C!xY1O zjOysrijvf#yu=&=%5oBuQi+WzQo}8^q9C&mcGSd=>g%G^#NvFkjD*v`%mP%Sa4IAemYPhr*i$P~lS@ldZ}EZ@ z7ndaF7R2Y}muoWKVoFIW1{Kh1w|H|i(~A;IK;@NQLFFx;Tmu8c_{_Y_lFY=MB6bD_ z29UDijSLJ7FBuvb9&qvWYjtXUVB+G{{UE}iXK-ED_M)!s1s%H&3{3W{A4GXLIX*M< zakG7A;A3U`045X|xOpL(ICynG2w-Xw4q-pxZz{AS+(Uy;u?V|#S zCBwtY_OsZSfq|h(*@=O1DWjtm%Q7`52F8OtjE*)e2YJD4V@5|imV+kjAaNT;5NQV@ z{WKZDK3~c38C0zmu`w_(+~Tmw%}*)KNwq5iRk7ee0Wm%>Gcq!MVbWn_E8=8eU;qGv CLaR{# literal 0 HcmV?d00001 diff --git a/__pycache__/models.cpython-314.pyc b/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69fc2712e70045116d28cc09167ec8f51ab5da7f GIT binary patch literal 3968 zcmdPqivEz7E4XJB{?;=nK~l<|2H0|P??LmWd8qbGw0LkWXCh|T25=)qLN zB+nqqpvVx!EY1+b63b-54AE1<3>IY-X9!|L5@i94vWqhWaUhAZf<-yS8G^WwMA^Wi z+~N#DJV>JKU{PLih9Ev9QH~&fWd==wmmteE8EWag%7GT!2JN-R$G%}+_q zNe0QnFcXyV*?@t8A(5ehA(o+pF^HjrDUPj#S&Si{A&9YrC5*wCF^EZ=A%~-cHHf){ zEsUXrJ%~k_L6h|si%WifQI)QvLP17lab|L2jzUU)evy7rYH@yPQF5w6Nk(FcLZU)R zVu3<(VxB^NL28~}G9v>60|V)4$Ae-bzqBMixA+!&YF=VePHM_6 z7LcGO%Pr>OlA>G8nRz9*Sd#MdbBaJgqRDiNEiJz&H?iavZ+v`mPGWI!N@j9Ne0(v; zc}fkpICDYqS*%x3d5b4LJ~1yZza+6FH9r29AShP!kcHS77#NC685kITv@qHwSD?}RBPWQ>#lX$i==qTw#O7lVm%7Lz`H>&Q5n~XQxX2>@Q5?jP zV~~`&$Rhnw9>h^#kdVH}BK1)b#8G146KeGRs0?DOFmMYu`h8MmU}XFZis_XMek(a{ zam2@if-*k-7FT?HZhlH>4#>eg@$rSFi8)Xi{`mN`%%b9woXot`y!`n1TkP@iDf!7D zk0JRftuzlLBpM%IT#{IlnH-;3Qc{$eR9cc+3{eRWvEaj74IgPynSohazzX z28JR~sDKF&qnIBOJPiytS`4(Pc?8j)*)7)w}#7)#iKm`d1# zm`gaoxrhavi#UT=OSr-qO1OjAlo>SHZ*e&nr6!i7f@19!L*@}q28IyqnIwt2nFN#> zV3`Dz8NitY1TTo-0}(tR0u)F^0t^fcn#@H)AOT?zAp#O$DiQ^;!GR1( zp^#9PU|?XlC64eSO2U)|MTs9I={PEY${qD$g@V+g+|1(Q%=|osl6-KH ztB|NrT#%ZanUvfbi_WLIzq6@e1DAC3|!h4M7R7{r2>Ht>{5dZ2*O z2N9s`;#Xt{Vj`DEpqjG?lmayImPZmu9z;n3#-RM}0!sonc?BAMAO(`32q?;-1riT` zqd%lP;($l98YFRWL5m?!C5nh=O)jjZ5Xch>B+}q| zLrJZ{3sT^K?1mLMAobYO2q^WSwE3(dX#`x@FoJ6;7EoFVV<=$@Vpe9*WVyxZn4Fwi zTC%v?=r|fWsv*ACd4S*VflqYh*9{97CWQl1j#Q9?2M9M zM7SA+J3PNIa5D;jkzr+&oS^xIft6A6ivT;L@B~hf8sRS@Y>eC!EWa_ZF>-?~1prT# B5!(O& literal 0 HcmV?d00001 diff --git a/__pycache__/services.cpython-314.pyc b/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97a6bd8de67d7c73805d6fc88e9665ccbda4e631 GIT binary patch literal 4313 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 literal 0 HcmV?d00001 diff --git a/__pycache__/views_api.cpython-314.pyc b/__pycache__/views_api.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a428ad6e65106175a77a80e00594237edaff75f GIT binary patch literal 8465 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 Door: + door_id = urlsafe_short_hash() + controller_token = data.controller_token or secrets.token_urlsafe(24) + await db.execute( + """ + INSERT INTO access.doors ( + id, wallet, name, controller_token, ha_webhook_url, + boltcards_base_url, unlock_timeout_ms, enabled + ) + VALUES ( + :id, :wallet, :name, :controller_token, :ha_webhook_url, + :boltcards_base_url, :unlock_timeout_ms, :enabled + ) + """, + { + "id": door_id, + "wallet": wallet_id, + "name": data.name, + "controller_token": controller_token, + "ha_webhook_url": data.ha_webhook_url, + "boltcards_base_url": data.boltcards_base_url, + "unlock_timeout_ms": data.unlock_timeout_ms, + "enabled": data.enabled, + }, + ) + door = await get_door(door_id) + assert door, "Newly created door couldn't be retrieved" + return door + + +async def update_door(door: Door) -> Door: + await db.update("access.doors", door) + return door + + +async def get_door(door_id: str) -> Door | None: + return await db.fetchone( + "SELECT * FROM access.doors WHERE id = :id", {"id": door_id}, Door + ) + + +async def get_door_by_id_or_name(ident: str) -> Door | None: + """The reader's `doorId` may be the door id or its human name.""" + return await db.fetchone( + "SELECT * FROM access.doors WHERE id = :ident OR name = :ident", + {"ident": ident}, + Door, + ) + + +async def get_doors(wallet_ids: list[str]) -> list[Door]: + if not wallet_ids: + return [] + q = ",".join(f"'{w}'" for w in wallet_ids) + return await db.fetchall( + f"SELECT * FROM access.doors WHERE wallet IN ({q}) ORDER BY name", model=Door + ) + + +async def delete_door(door_id: str) -> None: + await db.execute("DELETE FROM access.doors WHERE id = :id", {"id": door_id}) + await db.execute( + "DELETE FROM access.grants WHERE door_id = :id", {"id": door_id} + ) + + +# ── Grants ───────────────────────────────────────────────────────────────── +async def create_grant(data: CreateGrant) -> Grant: + grant_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO access.grants (id, door_id, external_id, label, enabled, expires_at) + VALUES (:id, :door_id, :external_id, :label, :enabled, :expires_at) + """, + { + "id": grant_id, + "door_id": data.door_id, + "external_id": data.external_id.lower(), + "label": data.label, + "enabled": data.enabled, + "expires_at": data.expires_at, + }, + ) + grant = await get_grant(grant_id) + assert grant, "Newly created grant couldn't be retrieved" + return grant + + +async def get_grant(grant_id: str) -> Grant | None: + return await db.fetchone( + "SELECT * FROM access.grants WHERE id = :id", {"id": grant_id}, Grant + ) + + +async def get_grants(door_ids: list[str]) -> list[Grant]: + if not door_ids: + return [] + q = ",".join(f"'{d}'" for d in door_ids) + return await db.fetchall( + f"SELECT * FROM access.grants WHERE door_id IN ({q}) ORDER BY time DESC", + model=Grant, + ) + + +async def get_active_grant(door_id: str, external_id: str) -> Grant | None: + """The permission decision: an enabled, unexpired grant for this card+door.""" + return await db.fetchone( + """ + SELECT * FROM access.grants + WHERE door_id = :door_id AND external_id = :external_id AND enabled = true + """, + {"door_id": door_id, "external_id": external_id.lower()}, + Grant, + ) + + +async def delete_grant(grant_id: str) -> None: + await db.execute("DELETE FROM access.grants WHERE id = :id", {"id": grant_id}) + + +# ── Access log ───────────────────────────────────────────────────────────── +async def record_log( + door_id: str, external_id: str, decision: str, reason: str, ip: str = "" +) -> None: + await db.execute( + """ + INSERT INTO access.logs (id, door_id, external_id, decision, reason, ip) + VALUES (:id, :door_id, :external_id, :decision, :reason, :ip) + """, + { + "id": urlsafe_short_hash(), + "door_id": door_id, + "external_id": external_id, + "decision": decision, + "reason": reason, + "ip": ip, + }, + ) + + +async def get_logs(door_ids: list[str], limit: int = 200) -> list[AccessLog]: + if not door_ids: + return [] + q = ",".join(f"'{d}'" for d in door_ids) + return await db.fetchall( + f"SELECT * FROM access.logs WHERE door_id IN ({q}) " + f"ORDER BY time DESC LIMIT {int(limit)}", + model=AccessLog, + ) diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9395d7a --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "access", + "organisation": "aiolabs", + "repository": "access" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..2e6d9d5 --- /dev/null +++ b/migrations.py @@ -0,0 +1,45 @@ +async def m001_initial(db): + """Doors (resources), grants (card→door permissions), and an access log.""" + await db.execute( + f""" + CREATE TABLE access.doors ( + id TEXT PRIMARY KEY UNIQUE, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + controller_token TEXT NOT NULL, + ha_webhook_url TEXT NOT NULL DEFAULT '', + boltcards_base_url TEXT NOT NULL DEFAULT '', + unlock_timeout_ms INT NOT NULL DEFAULT 2500, + enabled BOOL NOT NULL DEFAULT true, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE access.grants ( + id TEXT PRIMARY KEY UNIQUE, + door_id TEXT NOT NULL, + external_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + enabled BOOL NOT NULL DEFAULT true, + expires_at TIMESTAMP, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE access.logs ( + id TEXT PRIMARY KEY UNIQUE, + door_id TEXT NOT NULL, + external_id TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..806f467 --- /dev/null +++ b/models.py @@ -0,0 +1,75 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class Door(BaseModel): + """A physical door/resource that a tap can open.""" + + id: str + wallet: str + name: str + # Secret the reader (Pi) must present in X-Controller-Token to hit /check. + controller_token: str + # Local Home Assistant webhook that fires lock.unlock (LAN-only). When empty, + # /check still authenticates + authorizes and logs, but performs no unlock + # (useful for bring-up / bench testing before the lock is wired). + ha_webhook_url: str + # boltcards API base used to SUN-verify the tapped card, e.g. + # http://localhost:5000/boltcards/api/v1 (same on-prem LNbits). Falls back to + # the instance base URL when empty (see services.verify_card). + boltcards_base_url: str + unlock_timeout_ms: int + enabled: bool + time: datetime + + +class CreateDoor(BaseModel): + name: str + ha_webhook_url: str = "" + boltcards_base_url: str = "" + # Generated server-side when left blank. + controller_token: str = "" + unlock_timeout_ms: int = 2500 + enabled: bool = True + + +class Grant(BaseModel): + """A card's permission to open a specific door.""" + + id: str + door_id: str + # The Bolt Card's boltcards `external_id` (the authenticated identity). + external_id: str + label: str + enabled: bool + # Optional hard expiry; null = no expiry. + expires_at: datetime | None + time: datetime + + +class CreateGrant(BaseModel): + door_id: str + external_id: str + label: str = "" + expires_at: datetime | None = None + enabled: bool = True + + +class AccessLog(BaseModel): + id: str + door_id: str + external_id: str + decision: str # "allow" | "deny" + reason: str + ip: str + time: datetime + + +class CheckRequest(BaseModel): + """What the door reader (Pi) POSTs to /access/api/v1/check.""" + + doorId: str + external_id: str + p: str + c: str diff --git a/services.py b/services.py new file mode 100644 index 0000000..3fbaed7 --- /dev/null +++ b/services.py @@ -0,0 +1,56 @@ +import httpx +from lnbits.settings import settings + +from .models import Door + + +def _boltcards_base(door: Door) -> str: + """Where to SUN-verify the tapped card. + + Defaults to this same on-prem LNbits instance's boltcards extension. The + `access` extension runs in the same process/host, so the loopback base URL + is reachable and keeps verification on the LAN. + """ + if door.boltcards_base_url: + return door.boltcards_base_url.rstrip("/") + return f"{settings.lnbits_baseurl.rstrip('/')}/boltcards/api/v1" + + +async def verify_card(door: Door, external_id: str, p: str, c: str) -> bool: + """Authenticate the tap by delegating the NTAG424 SUN check to boltcards. + + Calls the boltcards `/verify` endpoint (aiolabs fork ≥ 1.1.1-aio.2): a + side-effect-light SUN check that confirms a genuine, non-replayed tap and + returns `{"authenticated": true, ...}` — WITHOUT `/scan`'s spend semantics + (no withdrawRequest, no daily-limit, no "hit"). It still advances the SUN + counter server-side, so a captured p/c can't be replayed. + """ + url = f"{_boltcards_base(door)}/verify/{external_id}" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, params={"p": p, "c": c}, timeout=5.0) + data = resp.json() + except Exception: + return False + return isinstance(data, dict) and data.get("authenticated") is True + + +async def trigger_unlock(door: Door, external_id: str) -> bool: + """Fire the door's local Home Assistant webhook (→ lock.unlock → Z-Wave). + + Returns True only on a 2xx. Empty webhook URL means "authorize + log only" + (bring-up mode) and is treated as a successful no-op unlock. + """ + if not door.ha_webhook_url: + return True + timeout = max(door.unlock_timeout_ms, 250) / 1000.0 + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + door.ha_webhook_url, + json={"doorId": door.id, "doorName": door.name, "externalId": external_id}, + timeout=timeout, + ) + return resp.is_success + except Exception: + return False diff --git a/static/image/access.png b/static/image/access.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0e193339759f1f8c4cc6a6063e5d1cbd849477 GIT binary patch literal 199 zcmeAS@N?(olHy`uVBq!ia0y~yU`POA4kiW$hTLBd@);Ny`aE46Ln>~)yb(Yu37l0<29eoI)xd6BHadAXF2JB8UePZc%UqQ6O2Eq9y?l4 ({ label: d.name, value: d.id })) + } + }, + methods: { + doorName(id) { + const d = this.doors.find(x => x.id === id) + return d ? d.name : id + }, + async loadAll() { + await Promise.all([this.loadDoors(), this.loadGrants(), this.loadLogs()]) + }, + async loadDoors() { + const { data } = await LNbits.api.request( + 'GET', + '/access/api/v1/doors', + this.inkey + ) + this.doors = data + }, + async loadGrants() { + const { data } = await LNbits.api.request( + 'GET', + '/access/api/v1/grants', + this.inkey + ) + this.grants = data + }, + async loadLogs() { + const { data } = await LNbits.api.request( + 'GET', + '/access/api/v1/logs', + this.inkey + ) + this.logs = data + }, + async createDoor() { + try { + await LNbits.api.request( + 'POST', + '/access/api/v1/doors', + this.adminkey, + this.doorDialog.data + ) + this.doorDialog = { show: false, data: { unlock_timeout_ms: 2500 } } + await this.loadDoors() + } catch (e) { + LNbits.utils.notifyApiError(e) + } + }, + async deleteDoor(id) { + try { + await LNbits.api.request( + 'DELETE', + `/access/api/v1/doors/${id}`, + this.adminkey + ) + await this.loadAll() + } catch (e) { + LNbits.utils.notifyApiError(e) + } + }, + showToken(door) { + this.$q.dialog({ + title: `${door.name} — controller token`, + message: `Set this as X-Controller-Token on the reader for doorId "${door.id}":

${door.controller_token}`, + html: true + }) + }, + async createGrant() { + try { + await LNbits.api.request( + 'POST', + '/access/api/v1/grants', + this.adminkey, + this.grantDialog.data + ) + this.grantDialog = { show: false, data: {} } + await this.loadGrants() + } catch (e) { + LNbits.utils.notifyApiError(e) + } + }, + async deleteGrant(id) { + try { + await LNbits.api.request( + 'DELETE', + `/access/api/v1/grants/${id}`, + this.adminkey + ) + await this.loadGrants() + } catch (e) { + LNbits.utils.notifyApiError(e) + } + } + }, + created() { + this.loadAll() + } +}) diff --git a/templates/access/index.html b/templates/access/index.html new file mode 100644 index 0000000..e6da096 --- /dev/null +++ b/templates/access/index.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} {% block page %} +
+
+ + +
Doors
+ New door +
+ + + + + +
+ + + +
Grants (card → door)
+ New grant +
+ + + + + +
+
+ +
+ + +
Access log
+
+ + + + + +
+
+ + + +
New door
+ + + + +
+ Cancel + Create +
+
+
+ + + +
New grant
+ + + +
+ Cancel + Create +
+
+
+
+{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..ebef7ba --- /dev/null +++ b/views.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from lnbits.core.models import User +from lnbits.decorators import check_user_exists +from lnbits.helpers import template_renderer + +access_generic_router = APIRouter() + + +def access_renderer(): + return template_renderer(["access/templates"]) + + +@access_generic_router.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return access_renderer().TemplateResponse( + "access/index.html", {"request": request, "user": user.json()} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..8927492 --- /dev/null +++ b/views_api.py @@ -0,0 +1,118 @@ +from http import HTTPStatus + +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.crud import get_user +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key, require_invoice_key + +from .crud import ( + create_door, + create_grant, + delete_door, + delete_grant, + get_door, + get_doors, + get_grant, + get_grants, + get_logs, + update_door, +) +from .models import AccessLog, CreateDoor, CreateGrant, Door, Grant + +access_api_router = APIRouter() + + +async def _wallet_ids(key_info: WalletTypeInfo, all_wallets: bool) -> list[str]: + if not all_wallets: + return [key_info.wallet.id] + user = await get_user(key_info.wallet.user) + return user.wallet_ids if user else [] + + +async def _owned_door(door_id: str, wallet_id: str) -> Door: + door = await get_door(door_id) + if not door: + raise HTTPException(HTTPStatus.NOT_FOUND, "Door does not exist.") + if door.wallet != wallet_id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your door.") + return door + + +# ── Doors ────────────────────────────────────────────────────────────────── +@access_api_router.get("/api/v1/doors") +async def api_doors( + key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = False +) -> list[Door]: + return await get_doors(await _wallet_ids(key_info, all_wallets)) + + +@access_api_router.post("/api/v1/doors", status_code=HTTPStatus.CREATED) +async def api_door_create( + data: CreateDoor, key_info: WalletTypeInfo = Depends(require_admin_key) +) -> Door: + return await create_door(key_info.wallet.id, data) + + +@access_api_router.put("/api/v1/doors/{door_id}") +async def api_door_update( + door_id: str, + data: CreateDoor, + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> Door: + door = await _owned_door(door_id, key_info.wallet.id) + door.name = data.name + door.ha_webhook_url = data.ha_webhook_url + door.boltcards_base_url = data.boltcards_base_url + door.unlock_timeout_ms = data.unlock_timeout_ms + door.enabled = data.enabled + if data.controller_token: + door.controller_token = data.controller_token + return await update_door(door) + + +@access_api_router.delete("/api/v1/doors/{door_id}") +async def api_door_delete( + door_id: str, key_info: WalletTypeInfo = Depends(require_admin_key) +): + await _owned_door(door_id, key_info.wallet.id) + await delete_door(door_id) + return {"deleted": True} + + +# ── Grants ───────────────────────────────────────────────────────────────── +@access_api_router.get("/api/v1/grants") +async def api_grants( + key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = False +) -> list[Grant]: + doors = await get_doors(await _wallet_ids(key_info, all_wallets)) + return await get_grants([d.id for d in doors]) + + +@access_api_router.post("/api/v1/grants", status_code=HTTPStatus.CREATED) +async def api_grant_create( + data: CreateGrant, key_info: WalletTypeInfo = Depends(require_admin_key) +) -> Grant: + # Only allow granting on a door the caller owns. + await _owned_door(data.door_id, key_info.wallet.id) + return await create_grant(data) + + +@access_api_router.delete("/api/v1/grants/{grant_id}") +async def api_grant_delete( + grant_id: str, key_info: WalletTypeInfo = Depends(require_admin_key) +): + grant = await get_grant(grant_id) + if not grant: + raise HTTPException(HTTPStatus.NOT_FOUND, "Grant does not exist.") + await _owned_door(grant.door_id, key_info.wallet.id) + await delete_grant(grant_id) + return {"deleted": True} + + +# ── Access log ───────────────────────────────────────────────────────────── +@access_api_router.get("/api/v1/logs") +async def api_logs( + key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = False +) -> list[AccessLog]: + doors = await get_doors(await _wallet_ids(key_info, all_wallets)) + return await get_logs([d.id for d in doors]) diff --git a/views_reader.py b/views_reader.py new file mode 100644 index 0000000..8cfa677 --- /dev/null +++ b/views_reader.py @@ -0,0 +1,54 @@ +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: +# { "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}