padding: 8px;
text-align: left;
}
+
+ .atm-mode-border {
+ position: fixed;
+ inset: 0;
+ border: 5px solid #d32f2f;
+ box-sizing: border-box;
+ pointer-events: none;
+ z-index: 10000;
+ }
+
@media screen {
.receipt,
.receipt-text {
diff --git a/tests/e2e/tpos.spec.ts b/tests/e2e/tpos.spec.ts
index 8f0ad57..d460756 100644
--- a/tests/e2e/tpos.spec.ts
+++ b/tests/e2e/tpos.spec.ts
@@ -41,6 +41,80 @@ test('admin can create a terminal and add an item through the extracted dialogs'
await expect(page.getByText(itemName, {exact: true})).toBeVisible()
})
+test('ATM uses an owner session or password fallback and exits locally', async ({
+ page,
+ lnbitsServer
+}) => {
+ await login(page, lnbitsServer)
+ const merchantWallet = await superuserWallet(page)
+ const terminal = await createTpos(page, merchantWallet, {
+ name: `ATM terminal ${randomHex()}`,
+ currency: 'sats',
+ wallet: merchantWallet.id,
+ withdraw_limit: 100,
+ enable_remote: false
+ })
+
+ await page.goto(`/tpos/${terminal.id}`)
+ const directCreateRequest = page.waitForRequest(
+ request =>
+ request.method() === 'POST' &&
+ request.url().includes(`/tpos/api/v1/atm/${terminal.id}/create`)
+ )
+ await page
+ .locator('.q-fab:visible')
+ .first()
+ .getByRole('button')
+ .first()
+ .click()
+ await page.getByRole('button', {name: /^ATM$/}).click()
+ await directCreateRequest
+ await expect(
+ page.locator('.q-dialog').filter({hasText: 'Unlock ATM'})
+ ).toHaveCount(0)
+ await expect(page.locator('.atm-mode-border')).toBeVisible()
+ await page.getByRole('button', {name: /EXIT ATM/i}).click()
+ await expect(page.locator('.atm-mode-border')).toHaveCount(0)
+
+ await page.context().clearCookies()
+ await page.reload()
+ const unauthenticatedResponse = page.waitForResponse(
+ response =>
+ response.request().method() === 'POST' &&
+ response.url().endsWith(`/tpos/api/v1/atm/${terminal.id}/create`) &&
+ response.status() === 401
+ )
+ await page
+ .locator('.q-fab:visible')
+ .first()
+ .getByRole('button')
+ .first()
+ .click()
+ await page.getByRole('button', {name: /^ATM$/}).click()
+ await unauthenticatedResponse
+ const dialog = page
+ .locator('.q-dialog')
+ .filter({hasText: 'Unlock ATM'})
+ .last()
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByLabel('Password')).toBeVisible()
+ await expect(dialog.getByLabel('Username')).toHaveCount(0)
+
+ const authorizeRequest = page.waitForRequest(
+ request =>
+ request.method() === 'POST' &&
+ request.url().includes(`/tpos/api/v1/atm/${terminal.id}/authorize`)
+ )
+ await dialog.getByLabel('Password').fill(lnbitsServer.password)
+ await dialog.getByRole('button', {name: 'Enter ATM'}).click()
+ const authorize = await authorizeRequest
+ expect(authorize.postDataJSON()).toEqual({password: lnbitsServer.password})
+ await expect(page.locator('.atm-mode-border')).toBeVisible()
+
+ await page.getByRole('button', {name: /EXIT ATM/i}).click()
+ await expect(page.locator('.atm-mode-border')).toHaveCount(0)
+})
+
test('public item checkout completes through Lightning with FakeWallet', async ({
page,
lnbitsServer
@@ -96,7 +170,9 @@ test('public item checkout completes through Lightning with FakeWallet', async (
fallbackTile.evaluate(element => getComputedStyle(element).width)
)
.toBe('150px')
- const tileSizeSlider = pos.locator('[aria-label="Tile size"]')
+ const tileSizeSlider = pos
+ .getByRole('slider', {name: 'Tile size'})
+ .locator('.q-slider__track-container')
await expect(tileSizeSlider).toBeVisible()
await tileSizeSlider.focus()
await tileSizeSlider.press('ArrowRight', {delay: 50})
@@ -112,14 +188,6 @@ test('public item checkout completes through Lightning with FakeWallet', async (
})
)
.toEqual({height: '200px', width: '200px'})
- await expect
- .poll(() =>
- page.evaluate(key => {
- const value = window.localStorage.getItem(key)
- return value === null ? null : JSON.parse(value)
- }, `lnbits.tpos.${terminal.id}.tileSize`)
- )
- .toBe(200)
await page.reload()
const restoredTile = pos
.locator('div.flex.justify-center.gt-xs > div')
@@ -215,7 +283,7 @@ test('public item checkout completes through Lightning with FakeWallet', async (
await page.setViewportSize({width: 500, height: 900})
await page.reload()
- await expect(pos.locator('[aria-label="Tile size"]')).toHaveCount(0)
+ await expect(pos.getByRole('slider', {name: 'Tile size'})).toHaveCount(0)
await expect(pos.getByText(itemName, {exact: true}).last()).toBeVisible()
})
diff --git a/tests/test_api.py b/tests/test_api.py
index 6dce278..4f3f3e2 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1118,6 +1118,60 @@ async def fake_pay_tribute(withdraw_amount, wallet_id, percent=0.5):
assert "already been claimed" in claimed_again.json()["reason"]
+@pytest.mark.asyncio
+async def test_atm_create_requires_authenticated_tpos_owner(client: AsyncClient):
+ owner_account = Account(id=uuid4().hex, username=f"atmowner_{uuid4().hex[:8]}")
+ owner_account.hash_password("secret1234")
+ owner = await create_user_account_no_ckeck(account=owner_account)
+ other_account = Account(id=uuid4().hex, username=f"atmother_{uuid4().hex[:8]}")
+ other = await create_user_account_no_ckeck(account=other_account)
+
+ create = await client.post(
+ "/tpos/api/v1/tposs",
+ json=_tpos_payload(withdraw_limit=100),
+ headers={"X-API-KEY": owner.wallets[0].adminkey},
+ )
+ assert create.status_code == 201
+ tpos = create.json()
+
+ owner_create = await client.post(
+ f"/tpos/api/v1/atm/{tpos['id']}/create?usr={owner.id}"
+ )
+ assert owner_create.status_code == 200
+
+ authorize = await client.post(
+ f"/tpos/api/v1/atm/{tpos['id']}/authorize",
+ json={"password": "secret1234"},
+ )
+ assert authorize.status_code == 200
+
+ invalid = await client.post(
+ f"/tpos/api/v1/atm/{tpos['id']}/authorize",
+ json={"password": "wrong"},
+ )
+ assert invalid.status_code == 401
+ assert invalid.json()["detail"] == "Invalid credentials."
+ for _ in range(4):
+ invalid = await client.post(
+ f"/tpos/api/v1/atm/{tpos['id']}/authorize",
+ json={"password": "wrong"},
+ )
+ assert invalid.status_code == 401
+ locked = await client.post(
+ f"/tpos/api/v1/atm/{tpos['id']}/authorize",
+ json={"password": "secret1234"},
+ )
+ assert locked.status_code == 429
+
+ other_create = await client.post(
+ f"/tpos/api/v1/atm/{tpos['id']}/create?usr={other.id}"
+ )
+ assert other_create.status_code == 403
+ assert other_create.json()["detail"] == (
+ "You do not have access to this TPoS wallet."
+ )
+
+
@pytest.mark.asyncio
async def test_atm_pay_endpoint(client: AsyncClient, monkeypatch):
user, wallet = await _user_with_tabs("atmpayuser")
diff --git a/views_atm.py b/views_atm.py
index d1a2f50..2c3cfa6 100644
--- a/views_atm.py
+++ b/views_atm.py
@@ -2,11 +2,13 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from lnbits.core.crud import (
+ get_account,
get_wallet,
)
from lnbits.core.models import User
from lnbits.core.models.misc import SimpleStatus
from lnbits.decorators import check_user_exists
+from lnbits.utils.cache import cache
from lnurl import (
CallbackUrl,
LnurlErrorResponse,
@@ -27,12 +29,18 @@
update_lnurlcharge,
)
from .models import (
+ AuthorizeAtm,
CreateWithdrawPay,
LnurlCharge,
)
tpos_atm_router = APIRouter(prefix="/api/v1/atm", tags=["TPoS ATM"])
+ATM_AUTH_CACHE_PREFIX = "tpos:atm:auth:"
+ATM_AUTH_WINDOW = 15 * 60
+ATM_AUTH_COOLDOWN = 60
+ATM_AUTH_MAX_FAILURES = 5
+
@tpos_atm_router.post("/{tpos_id}/create")
async def api_tpos_atm_pin_check(
@@ -51,12 +59,47 @@ async def api_tpos_atm_pin_check(
if not tpos.can_withdraw:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Withdrawals are not allowed at this time. Try again later.",
+ HTTPStatus.BAD_REQUEST,
+ "Withdrawals are not allowed at this time. Try again later.",
)
+ return await create_lnurlcharge(tpos.id)
+
+
+@tpos_atm_router.post("/{tpos_id}/authorize")
+async def api_tpos_atm_authorize(tpos_id: str, data: AuthorizeAtm) -> LnurlCharge:
+ tpos = await get_tpos(tpos_id)
+ if not tpos:
+ raise HTTPException(HTTPStatus.NOT_FOUND, "TPoS does not exist.")
- charge = await create_lnurlcharge(tpos.id)
- return charge
+ wallet = await get_wallet(tpos.wallet)
+ account = await get_account(wallet.user) if wallet else None
+ key = f"{ATM_AUTH_CACHE_PREFIX}{tpos_id}"
+ failures = cache.get(key, 0) or 0
+ if failures >= ATM_AUTH_MAX_FAILURES:
+ raise HTTPException(
+ HTTPStatus.TOO_MANY_REQUESTS,
+ "Too many failed attempts. Try again later.",
+ )
+ if not account or not account.verify_password(data.password):
+ failures += 1
+ cache.set(
+ key,
+ failures,
+ expiry=(
+ ATM_AUTH_COOLDOWN
+ if failures >= ATM_AUTH_MAX_FAILURES
+ else ATM_AUTH_WINDOW
+ ),
+ )
+ raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
+
+ cache.pop(key)
+ if not tpos.can_withdraw:
+ raise HTTPException(
+ HTTPStatus.BAD_REQUEST,
+ "Withdrawals are not allowed at this time. Try again later.",
+ )
+ return await create_lnurlcharge(tpos.id)
@tpos_atm_router.get("/withdraw/{charge_id}/{amount}")