mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-20 10:39:59 +01:00
support totp confirmation method.
This commit is contained in:
parent
d9b9d1e9b2
commit
a653a5327b
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
);
|
||||
"""
|
||||
|
@ -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):
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user