support totp confirmation method.
This commit is contained in:
parent
d9b9d1e9b2
commit
a653a5327b
9 changed files with 147 additions and 30 deletions
|
|
@ -32,10 +32,10 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
|
||||||
return Shop(**dict(row)) if row else None
|
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(
|
await db.execute(
|
||||||
"UPDATE shops SET wordlist = ? WHERE id = ?",
|
"UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
|
||||||
(wordlist, shop),
|
(method, wordlist, shop),
|
||||||
)
|
)
|
||||||
return await get_shop(shop)
|
return await get_shop(shop)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import trio # type: ignore
|
import trio # type: ignore
|
||||||
import httpx
|
import httpx
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
async def get_fiat_rate(currency: str):
|
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]
|
satoshi_prices = [x for x in satoshi_prices if x]
|
||||||
return sum(satoshi_prices) / len(satoshi_prices)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ async def lnurl_callback(item_id):
|
||||||
extra={"tag": "offlineshop", "item": 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())
|
return jsonify(resp.dict())
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ async def m001_initial(db):
|
||||||
CREATE TABLE shops (
|
CREATE TABLE shops (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
wallet TEXT NOT NULL,
|
wallet TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
wordlist TEXT
|
wordlist TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from quart import url_for
|
from quart import url_for
|
||||||
from typing import NamedTuple, Optional, List, Dict
|
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.types import LnurlPayMetadata # type: ignore
|
||||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||||
|
|
||||||
|
from .helpers import totp
|
||||||
|
|
||||||
shop_counters: Dict = {}
|
shop_counters: Dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,11 +57,24 @@ class ShopCounter(object):
|
||||||
class Shop(NamedTuple):
|
class Shop(NamedTuple):
|
||||||
id: int
|
id: int
|
||||||
wallet: str
|
wallet: str
|
||||||
|
method: str
|
||||||
wordlist: str
|
wordlist: str
|
||||||
|
|
||||||
def get_word(self, payment_hash):
|
@property
|
||||||
sc = ShopCounter.invoke(self)
|
def otp_key(self) -> str:
|
||||||
return sc.get_word(payment_hash)
|
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):
|
class Item(NamedTuple):
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ new Vue({
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedWallet: null,
|
selectedWallet: null,
|
||||||
|
confirmationMethod: 'wordlist',
|
||||||
|
wordlistTainted: false,
|
||||||
offlineshop: {
|
offlineshop: {
|
||||||
|
method: null,
|
||||||
wordlist: [],
|
wordlist: [],
|
||||||
items: []
|
items: []
|
||||||
},
|
},
|
||||||
|
|
@ -80,28 +83,32 @@ new Vue({
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.offlineshop = response.data
|
this.offlineshop = response.data
|
||||||
|
this.confirmationMethod = response.data.method
|
||||||
|
this.wordlistTainted = false
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async updateWordlist() {
|
async setMethod() {
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
'/offlineshop/api/v1/offlineshop/wordlist',
|
'/offlineshop/api/v1/offlineshop/method',
|
||||||
this.selectedWallet.inkey,
|
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) {
|
} catch (err) {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
message:
|
||||||
|
`Method set to ${this.confirmationMethod}.` +
|
||||||
|
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
|
||||||
|
timeout: 700
|
||||||
|
})
|
||||||
this.loadShop()
|
this.loadShop()
|
||||||
},
|
},
|
||||||
async sendItem() {
|
async sendItem() {
|
||||||
|
|
|
||||||
|
|
@ -120,17 +120,41 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card class="q-pa-sm col-5">
|
<q-card class="q-pa-sm col-5">
|
||||||
<q-card-section class="q-pa-none text-center">
|
<q-tabs
|
||||||
<div class="row">
|
v-model="confirmationMethod"
|
||||||
<h5 class="text-subtitle1 q-my-none">Wordlist</h5>
|
no-caps
|
||||||
</div>
|
class="bg-purple text-white shadow-2"
|
||||||
<q-form class="q-gutter-md q-y-md" @submit="updateWordlist">
|
>
|
||||||
|
<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="row">
|
||||||
<div class="col q-mx-lg">
|
<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>
|
||||||
<div class="col q-mx-lg">
|
<div
|
||||||
<q-btn unelevated color="deep-purple" type="submit">
|
class="col q-mx-lg items-align flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
type="submit"
|
||||||
|
:disabled="!wordlistTainted"
|
||||||
|
>
|
||||||
Update Wordlist
|
Update Wordlist
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
|
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
|
||||||
|
|
@ -139,6 +163,49 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</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-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ async def confirmation_code():
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"""
|
f"""
|
||||||
[{shop.get_word(payment_hash)}]
|
[{shop.get_code(payment_hash)}]<br>
|
||||||
{item.name}
|
{item.name}<br>
|
||||||
{item.price} {item.unit}
|
{item.price} {item.unit}<br>
|
||||||
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
|
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
|
||||||
"""
|
"""
|
||||||
+ style
|
+ style
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
from . import offlineshop_ext
|
from . import offlineshop_ext
|
||||||
from .crud import (
|
from .crud import (
|
||||||
get_or_create_shop_by_wallet,
|
get_or_create_shop_by_wallet,
|
||||||
set_wordlist,
|
set_method,
|
||||||
add_item,
|
add_item,
|
||||||
update_item,
|
update_item,
|
||||||
get_items,
|
get_items,
|
||||||
|
|
@ -28,6 +28,7 @@ async def api_shop_from_wallet():
|
||||||
{
|
{
|
||||||
**shop._asdict(),
|
**shop._asdict(),
|
||||||
**{
|
**{
|
||||||
|
"otp_key": shop.otp_key,
|
||||||
"items": [item.values() for item in items],
|
"items": [item.values() for item in items],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -86,14 +87,17 @@ async def api_delete_item(item_id):
|
||||||
return "", HTTPStatus.NO_CONTENT
|
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_check_wallet_key("invoice")
|
||||||
@api_validate_post_request(
|
@api_validate_post_request(
|
||||||
schema={
|
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 = g.data["wordlist"].split("\n") if g.data["wordlist"] else None
|
||||||
wordlist = [word.strip() for word in wordlist if word.strip()]
|
wordlist = [word.strip() for word in wordlist if word.strip()]
|
||||||
|
|
||||||
|
|
@ -101,7 +105,7 @@ async def api_set_wordlist():
|
||||||
if not shop:
|
if not shop:
|
||||||
return "", HTTPStatus.NOT_FOUND
|
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:
|
if not updated_shop:
|
||||||
return "", HTTPStatus.NOT_FOUND
|
return "", HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue