support totp confirmation method.

This commit is contained in:
fiatjaf 2021-03-14 14:21:26 -03:00
parent d9b9d1e9b2
commit a653a5327b
9 changed files with 147 additions and 30 deletions

View File

@ -32,10 +32,10 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
return Shop(**dict(row)) if row else None
async def set_wordlist(shop: int, wordlist: str) -> Optional[Shop]:
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
await db.execute(
"UPDATE shops SET wordlist = ? WHERE id = ?",
(wordlist, shop),
"UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
(method, wordlist, shop),
)
return await get_shop(shop)

View File

@ -1,5 +1,9 @@
import trio # type: ignore
import httpx
import base64
import struct
import hmac
import time
async def get_fiat_rate(currency: str):
@ -46,3 +50,16 @@ async def get_usd_rate():
satoshi_prices = [x for x in satoshi_prices if x]
return sum(satoshi_prices) / len(satoshi_prices)
def hotp(key, counter, digits=6, digest="sha1"):
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
counter = struct.pack(">Q", counter)
mac = hmac.new(key, counter, digest).digest()
offset = mac[-1] & 0x0F
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
return str(binary)[-digits:].zfill(digits)
def totp(key, time_step=30, digits=6, digest="sha1"):
return hotp(key, int(time.time() / time_step), digits, digest)

View File

@ -61,6 +61,10 @@ async def lnurl_callback(item_id):
extra={"tag": "offlineshop", "item": item.id},
)
resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(shop, payment_hash), routes=[])
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=item.success_action(shop, payment_hash) if shop.method else None,
routes=[],
)
return jsonify(resp.dict())

View File

@ -7,6 +7,7 @@ async def m001_initial(db):
CREATE TABLE shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet TEXT NOT NULL,
method TEXT NOT NULL,
wordlist TEXT
);
"""

View File

@ -1,4 +1,6 @@
import json
import base64
import hashlib
from collections import OrderedDict
from quart import url_for
from typing import NamedTuple, Optional, List, Dict
@ -6,6 +8,8 @@ from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from .helpers import totp
shop_counters: Dict = {}
@ -53,11 +57,24 @@ class ShopCounter(object):
class Shop(NamedTuple):
id: int
wallet: str
method: str
wordlist: str
def get_word(self, payment_hash):
sc = ShopCounter.invoke(self)
return sc.get_word(payment_hash)
@property
def otp_key(self) -> str:
return base64.b32encode(
hashlib.sha256(
("otpkey" + str(self.id) + self.wallet).encode("ascii"),
).digest()
).decode("ascii")
def get_code(self, payment_hash: str) -> str:
if self.method == "wordlist":
sc = ShopCounter.invoke(self)
return sc.get_word(payment_hash)
elif self.method == "totp":
return totp(self.otp_key)
return ""
class Item(NamedTuple):

View File

@ -14,7 +14,10 @@ new Vue({
data() {
return {
selectedWallet: null,
confirmationMethod: 'wordlist',
wordlistTainted: false,
offlineshop: {
method: null,
wordlist: [],
items: []
},
@ -80,28 +83,32 @@ new Vue({
)
.then(response => {
this.offlineshop = response.data
this.confirmationMethod = response.data.method
this.wordlistTainted = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
async updateWordlist() {
async setMethod() {
try {
await LNbits.api.request(
'PUT',
'/offlineshop/api/v1/offlineshop/wordlist',
'/offlineshop/api/v1/offlineshop/method',
this.selectedWallet.inkey,
{wordlist: this.offlineshop.wordlist}
{method: this.confirmationMethod, wordlist: this.offlineshop.wordlist}
)
this.$q.notify({
message: `Wordlist updated. Counter reset.`,
timeout: 700
})
} catch (err) {
LNbits.utils.notifyApiError(err)
return
}
this.$q.notify({
message:
`Method set to ${this.confirmationMethod}.` +
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
timeout: 700
})
this.loadShop()
},
async sendItem() {

View File

@ -120,17 +120,41 @@
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="row">
<h5 class="text-subtitle1 q-my-none">Wordlist</h5>
</div>
<q-form class="q-gutter-md q-y-md" @submit="updateWordlist">
<q-tabs
v-model="confirmationMethod"
no-caps
class="bg-purple text-white shadow-2"
>
<q-tab name="wordlist" label="Wordlist"></q-tab>
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
<q-tab name="none" label="Nothing"></q-tab>
</q-tabs>
<q-card-section class="q-py-sm text-center">
<q-form
v-if="confirmationMethod === 'wordlist'"
class="q-gutter-md q-y-md"
@submit="setMethod"
>
<div class="row">
<div class="col q-mx-lg">
<q-input v-model="offlineshop.wordlist" dense filled autogrow />
<q-input
v-model="offlineshop.wordlist"
@input="wordlistTainted = true"
dense
filled
autogrow
/>
</div>
<div class="col q-mx-lg">
<q-btn unelevated color="deep-purple" type="submit">
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="deep-purple"
type="submit"
:disabled="!wordlistTainted"
>
Update Wordlist
</q-btn>
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
@ -139,6 +163,49 @@
</div>
</div>
</q-form>
<div v-else-if="confirmationMethod === 'totp'">
<div class="row">
<div class="col q-mx-lg">
<q-responsive :ratio="1">
<qrcode
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="deep-purple"
:disabled="offlineshop.method === 'totp'"
@click="setMethod"
>
Set TOTP
</q-btn>
</div>
</div>
</div>
<div v-else-if="confirmationMethod === 'none'">
<p>
Setting this option disables the confirmation code message that
appears in the consumer wallet after a purchase is paid for. It's ok
if the consumer is to be trusted when they claim to have paid.
</p>
<q-btn
unelevated
color="deep-purple"
:disabled="offlineshop.method === 'none'"
@click="setMethod"
>
Disable Confirmation Codes
</q-btn>
</div>
</q-card-section>
</q-card>
</div>

View File

@ -51,9 +51,9 @@ async def confirmation_code():
return (
f"""
[{shop.get_word(payment_hash)}]
{item.name}
{item.price} {item.unit}
[{shop.get_code(payment_hash)}]<br>
{item.name}<br>
{item.price} {item.unit}<br>
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
"""
+ style

View File

@ -7,7 +7,7 @@ from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import offlineshop_ext
from .crud import (
get_or_create_shop_by_wallet,
set_wordlist,
set_method,
add_item,
update_item,
get_items,
@ -28,6 +28,7 @@ async def api_shop_from_wallet():
{
**shop._asdict(),
**{
"otp_key": shop.otp_key,
"items": [item.values() for item in items],
},
}
@ -86,14 +87,17 @@ async def api_delete_item(item_id):
return "", HTTPStatus.NO_CONTENT
@offlineshop_ext.route("/api/v1/offlineshop/wordlist", methods=["PUT"])
@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"wordlist": {"type": "string", "empty": True, "nullable": True, "required": True},
"method": {"type": "string", "required": True, "nullable": False},
"wordlist": {"type": "string", "empty": True, "nullable": True, "required": False},
}
)
async def api_set_wordlist():
async def api_set_method():
method = g.data["method"]
wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None
wordlist = [word.strip() for word in wordlist if word.strip()]
@ -101,7 +105,7 @@ async def api_set_wordlist():
if not shop:
return "", HTTPStatus.NOT_FOUND
updated_shop = await set_wordlist(shop.id, "\n".join(wordlist))
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
if not updated_shop:
return "", HTTPStatus.NOT_FOUND