diff --git a/models.py b/models.py index 59f2fed..ef875e8 100644 --- a/models.py +++ b/models.py @@ -13,6 +13,11 @@ class PayLnurlWData(BaseModel): lnurl: str +class PayLnurlWPin(BaseModel): + pin_token: str = Field(..., min_length=1, max_length=64) + pin: str = Field(..., pattern=r"^\d{4}$") + + class CreateWithdrawPay(BaseModel): pay_link: str diff --git a/static/components/pin-dialog.js b/static/components/pin-dialog.js new file mode 100644 index 0000000..f7a9725 --- /dev/null +++ b/static/components/pin-dialog.js @@ -0,0 +1,111 @@ +// LUD-24: PIN entry for NFC cards whose withdrawRequest carries a `pinLimit`. +window.app.component('tpos-pin-dialog', { + name: 'tpos-pin-dialog', + props: ['show', 'amount', 'attemptsLeft', 'error', 'loading'], + emits: ['submit', 'cancel'], + data() { + return { + pin: '', + keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9'] + } + }, + watch: { + show(value) { + if (value) this.pin = '' + } + }, + computed: { + errorText() { + if (!this.error) return '' + if (!this.attemptsLeft) return this.error + const word = this.attemptsLeft === 1 ? 'attempt' : 'attempts' + return `${this.error} (${this.attemptsLeft} ${word} left)` + } + }, + methods: { + press(digit) { + if (this.loading || this.pin.length >= 4) return + this.pin += digit + }, + back() { + if (this.loading) return + this.pin = this.pin.slice(0, -1) + }, + submit() { + if (this.loading || this.pin.length !== 4) return + this.$emit('submit', this.pin) + this.pin = '' + } + }, + template: ` + + +
+
Enter card PIN
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ Cancel +
+
+
+ ` +}) diff --git a/static/js/tpos.js b/static/js/tpos.js index a41bd33..c4ce67a 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -42,6 +42,14 @@ window.app = Vue.createApp({ atmMode: false, atmToken: '', nfcTagReading: false, + pinDialog: { + show: false, + token: null, + failedAttempts: 0, + attemptsLeft: 0, + error: '', + loading: false + }, lnaddressDialog: { show: false, lnaddress: '' @@ -1642,26 +1650,82 @@ window.app = Vue.createApp({ } ) .then(response => { - if (!response.data.success) { - Quasar.Notify.create({ - type: 'negative', - message: response.data.detail, - timeout: 0, - actions: [ - { - icon: 'close', - color: 'white', - round: true, - handler: () => { - this.readNfcTag() - } - } - ] - }) + if (response.data.pin_required) { + this.openPinDialog(response.data.pin_token) + } else if (!response.data.success) { + this.notifyNfcError(response.data.detail) + } + }) + .catch(error => { + console.error(error) + LNbits.utils.notifyApiError(error) + }) + }, + notifyNfcError(message) { + Quasar.Notify.create({ + type: 'negative', + message, + timeout: 0, + actions: [ + { + icon: 'close', + color: 'white', + round: true, + handler: () => { + this.readNfcTag() + } + } + ] + }) + }, + // LUD-24: the card asks for a PIN, it allows 3 wrong attempts in total + openPinDialog(token) { + this.pinDialog = { + show: true, + token, + failedAttempts: 0, + attemptsLeft: 0, + error: '', + loading: false + } + }, + closePinDialog() { + this.pinDialog.show = false + this.pinDialog.token = null + this.pinDialog.loading = false + }, + cancelPin() { + this.closePinDialog() + this.readNfcTag() + }, + submitPin(pin) { + this.pinDialog.loading = true + this.pinDialog.error = '' + return axios + .post(`/tpos/api/v1/tposs/${this.tposId}/invoices/pay-pin`, { + pin_token: this.pinDialog.token, + pin + }) + .then(response => { + const data = response.data + if (data.success) { + this.closePinDialog() + } else if (data.pin_retry) { + this.pinDialog.failedAttempts += 1 + this.pinDialog.attemptsLeft = Math.max( + 0, + 3 - this.pinDialog.failedAttempts + ) + this.pinDialog.error = 'Wrong PIN' + this.pinDialog.loading = false + } else { + this.closePinDialog() + this.notifyNfcError(data.detail) } }) .catch(error => { console.error(error) + this.closePinDialog() LNbits.utils.notifyApiError(error) }) }, diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index 4434caa..05337c8 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -57,6 +57,16 @@
+ + + diff --git a/tests/check_static_load.js b/tests/check_static_load.js index aef1109..ab7512d 100644 --- a/tests/check_static_load.js +++ b/tests/check_static_load.js @@ -39,6 +39,7 @@ const pages = [ 'static/components/keypad.js', 'static/components/payment-method-selector.js', 'static/components/payment-dialog.js', + 'static/components/pin-dialog.js', 'static/components/held-carts-dialog.js', 'static/components/print-dialog.js', 'static/components/receipt.js', diff --git a/tests/test_api.py b/tests/test_api.py index 1a9795f..26e211e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1352,3 +1352,151 @@ async def get(self, url, **kwargs): ) assert paid.status_code == 200 assert paid.json()["success"] is True + + +class _FakeLnurlResponse: + is_error = False + + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +def _fake_withdraw_client(pin_limit, valid_pin="1234", callbacks=None): + """Fake httpx client emulating a LUD-24 service (e.g. boltcards).""" + callbacks = callbacks if callbacks is not None else [] + + class FakeClient: + wrong = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, **kwargs): + if "callback" not in str(url): + request = { + "tag": "withdrawRequest", + "callback": "https://example.com/callback", + "k1": "abc", + } + if pin_limit is not None: + request["pinLimit"] = pin_limit + return _FakeLnurlResponse(request) + params = kwargs["params"] + callbacks.append(params) + if params.get("pin") != valid_pin: + FakeClient.wrong += 1 + if FakeClient.wrong >= 3: + reason = "Card blocked: too many incorrect PIN attempts" + else: + reason = "Invalid PIN" + return _FakeLnurlResponse({"status": "ERROR", "reason": reason}) + return _FakeLnurlResponse({"status": "OK"}) + + return FakeClient + + +async def _tpos_for_pin_tests(client: AsyncClient, name: str): + _user, wallet = await _user_with_tabs(name) + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(), + headers={"X-API-KEY": wallet.adminkey}, + ) + assert create.status_code == 201 + return create.json() + + +def _patch_withdraw(monkeypatch, fake_client, amount_msat): + monkeypatch.setattr( + views_payments.httpx, "AsyncClient", lambda *args, **kwargs: fake_client() + ) + monkeypatch.setattr( + views_payments, + "bolt11_decode", + lambda _pr: SimpleNamespace(amount_msat=amount_msat), + ) + + +@pytest.mark.asyncio +async def test_pay_invoice_below_pin_limit_needs_no_pin( + client: AsyncClient, monkeypatch +): + tpos = await _tpos_for_pin_tests(client, "pinbelowuser") + callbacks: list = [] + _patch_withdraw( + monkeypatch, _fake_withdraw_client(10_000_000, callbacks=callbacks), 9_999_000 + ) + paid = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay", + json={"lnurl": "example.com/withdraw"}, + ) + assert paid.json()["success"] is False # fake service wants a PIN anyway + assert "pin_required" not in paid.json() + assert "pin" not in callbacks[0] + + +@pytest.mark.asyncio +async def test_pay_invoice_pin_flow(client: AsyncClient, monkeypatch): + tpos = await _tpos_for_pin_tests(client, "pinflowuser") + callbacks: list = [] + _patch_withdraw( + monkeypatch, _fake_withdraw_client(10_000_000, callbacks=callbacks), 10_000_000 + ) + first = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay", + json={"lnurl": "example.com/withdraw"}, + ) + data = first.json() + assert data["success"] is False + assert data["pin_required"] is True + assert callbacks == [] # callback must not be called without a PIN + + url = f"/tpos/api/v1/tposs/{tpos['id']}/invoices/pay-pin" + wrong = await client.post(url, json={"pin_token": data["pin_token"], "pin": "0000"}) + assert wrong.json()["success"] is False + assert wrong.json()["pin_retry"] is True + + ok = await client.post(url, json={"pin_token": data["pin_token"], "pin": "1234"}) + assert ok.json()["success"] is True + assert callbacks[-1] == {"k1": "abc", "pr": "lnbc1test", "pin": "1234"} + + # token is single use once the payment succeeded + again = await client.post(url, json={"pin_token": data["pin_token"], "pin": "1234"}) + assert again.json()["pin_expired"] is True + + +@pytest.mark.asyncio +async def test_pay_invoice_pin_card_blocked(client: AsyncClient, monkeypatch): + tpos = await _tpos_for_pin_tests(client, "pinblockuser") + _patch_withdraw(monkeypatch, _fake_withdraw_client(1000), 5_000) + first = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay", + json={"lnurl": "example.com/withdraw"}, + ) + token = first.json()["pin_token"] + url = f"/tpos/api/v1/tposs/{tpos['id']}/invoices/pay-pin" + for _ in range(2): + res = await client.post(url, json={"pin_token": token, "pin": "0000"}) + assert res.json()["pin_retry"] is True + blocked = await client.post(url, json={"pin_token": token, "pin": "0000"}) + assert blocked.json()["success"] is False + assert "pin_retry" not in blocked.json() + assert "blocked" in blocked.json()["detail"] + gone = await client.post(url, json={"pin_token": token, "pin": "1234"}) + assert gone.json()["pin_expired"] is True + + +@pytest.mark.asyncio +async def test_pay_invoice_pin_validation(client: AsyncClient): + tpos = await _tpos_for_pin_tests(client, "pinvaliduser") + url = f"/tpos/api/v1/tposs/{tpos['id']}/invoices/pay-pin" + bad = await client.post(url, json={"pin_token": "x", "pin": "12ab"}) + assert bad.status_code == 422 + unknown = await client.post(url, json={"pin_token": "nope", "pin": "1234"}) + assert unknown.json()["pin_expired"] is True diff --git a/views_payments.py b/views_payments.py index ef7afd9..5b98b09 100644 --- a/views_payments.py +++ b/views_payments.py @@ -1,9 +1,11 @@ import json from http import HTTPStatus from typing import Any, Literal +from time import time from uuid import uuid4 import httpx +from bolt11 import decode as bolt11_decode from fastapi import APIRouter, HTTPException, Query, Request from lnbits.core.crud import get_account, get_standalone_payment, get_wallet from lnbits.core.crud.payments import update_payment, update_payment_checking_id @@ -26,6 +28,7 @@ CreateTposInvoice, InventorySale, PayLnurlWData, + PayLnurlWPin, PrintReceiptRequest, ReceiptData, ReceiptDetailsData, @@ -306,6 +309,50 @@ async def api_tpos_get_latest_invoices(tpos_id: str): return result +LNURLW_HEADERS = {"user-agent": "lnbits/tpos"} +PIN_PENDING_TTL = 120 # seconds a withdrawRequest waits for the PIN +PIN_PENDING_MAX = 100 +# LUD-24: the card link is single use, so after the first GET the +# callback/k1 are kept server side until the customer entered the PIN. +_pin_pending: dict[str, dict[str, Any]] = {} + + +def _pin_pending_cleanup() -> None: + now = time() + for token in [t for t, v in _pin_pending.items() if v["expires"] < now]: + _pin_pending.pop(token, None) + while len(_pin_pending) > PIN_PENDING_MAX: + _pin_pending.pop(next(iter(_pin_pending)), None) + + +async def _call_withdraw_callback( + client: httpx.AsyncClient, + callback: str, + k1: str, + payment_request: str, + pin: str | None = None, +) -> tuple[dict, bool]: + """Call the LNURL-withdraw callback. Returns (response, is_error).""" + params = {"k1": k1, "pr": payment_request} + if pin: + params["pin"] = pin + r = await client.get( + callback, follow_redirects=True, headers=LNURLW_HEADERS, params=params + ) + try: + return r.json(), r.is_error + except ValueError: + return {}, True + + +def _callback_result(resp: dict, is_error: bool) -> dict: + if is_error: + return {"success": False, "detail": "Error loading callback"} + if resp.get("status") == "ERROR": + return {"success": False, "detail": resp.get("reason", "")} + return {"success": True, "detail": resp} + + @tpos_payments_router.post( "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK ) @@ -334,45 +381,97 @@ async def api_tpos_pay_invoice( async with httpx.AsyncClient() as client: try: - headers = {"user-agent": "lnbits/tpos"} - r = await client.get(lnurl, follow_redirects=True, headers=headers) + r = await client.get(lnurl, follow_redirects=True, headers=LNURLW_HEADERS) if r.is_error: - lnurl_response = {"success": False, "detail": "Error loading"} - else: - resp = r.json() - if resp.get("status") == "ERROR": - lnurl_response = { - "success": False, - "detail": resp.get("reason", ""), - } - return lnurl_response - - if resp.get("tag") != "withdrawRequest": - lnurl_response = {"success": False, "detail": "Wrong tag type"} - else: - r2 = await client.get( - resp.get("callback", ""), - follow_redirects=True, - headers=headers, - params={ - "k1": resp.get("k1", ""), - "pr": payment_request, - }, - ) - resp2 = r2.json() - if r2.is_error: - lnurl_response = { + return {"success": False, "detail": "Error loading"} + resp = r.json() + if resp.get("status") == "ERROR": + return {"success": False, "detail": resp.get("reason", "")} + if resp.get("tag") != "withdrawRequest": + return {"success": False, "detail": "Wrong tag type"} + + callback = resp.get("callback", "") + k1 = resp.get("k1", "") + + # LUD-24: PIN is mandatory if amount >= pinLimit + pin_limit = resp.get("pinLimit") + if pin_limit is not None: + try: + amount_msat = bolt11_decode(payment_request).amount_msat + except Exception: + return {"success": False, "detail": "Invalid payment request"} + if amount_msat is None or amount_msat >= int(pin_limit): + if not callback.lower().startswith("https://"): + return { "success": False, - "detail": "Error loading callback", + "detail": "PIN required but callback is not HTTPS", } - elif resp2.get("status") == "ERROR": - lnurl_response = {"success": False, "detail": resp2["reason"]} - else: - lnurl_response = {"success": True, "detail": resp2} + _pin_pending_cleanup() + pin_token = uuid4().hex + _pin_pending[pin_token] = { + "tpos_id": tpos_id, + "callback": callback, + "k1": k1, + "payment_request": payment_request, + "expires": time() + PIN_PENDING_TTL, + } + return { + "success": False, + "pin_required": True, + "pin_token": pin_token, + "detail": "PIN required", + } + + cb_resp, is_error = await _call_withdraw_callback( + client, callback, k1, payment_request + ) + return _callback_result(cb_resp, is_error) except (httpx.ConnectError, httpx.RequestError): - lnurl_response = {"success": False, "detail": "Unexpected error occurred"} + return {"success": False, "detail": "Unexpected error occurred"} - return lnurl_response + +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices/pay-pin", status_code=HTTPStatus.OK +) +async def api_tpos_pay_invoice_pin(data: PayLnurlWPin, tpos_id: str): + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + _pin_pending_cleanup() + pending = _pin_pending.get(data.pin_token) + if not pending or pending["tpos_id"] != tpos_id: + return { + "success": False, + "pin_expired": True, + "detail": "PIN request expired. Please tap the card again.", + } + + async with httpx.AsyncClient() as client: + try: + cb_resp, is_error = await _call_withdraw_callback( + client, + pending["callback"], + pending["k1"], + pending["payment_request"], + data.pin, + ) + except (httpx.ConnectError, httpx.RequestError): + _pin_pending.pop(data.pin_token, None) + return {"success": False, "detail": "Unexpected error occurred"} + + result = _callback_result(cb_resp, is_error) + if result["success"]: + _pin_pending.pop(data.pin_token, None) + elif str(result["detail"]).strip().lower() == "invalid pin": + # the card allows retries after a wrong PIN + result["pin_retry"] = True + pending["expires"] = time() + PIN_PENDING_TTL + else: + _pin_pending.pop(data.pin_token, None) + return result @tpos_payments_router.get(