lnurl-auth from lnbits wallets to services.
This commit is contained in:
parent
8f1ae1646e
commit
eaec3480e6
6 changed files with 156 additions and 38 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
|
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||||
from typing import List, NamedTuple, Optional, Dict
|
from typing import List, NamedTuple, Optional, Dict
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
|
|
||||||
|
|
@ -33,6 +35,14 @@ class Wallet(NamedTuple):
|
||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
return self.balance_msat // 1000
|
return self.balance_msat // 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lnurlauth_key(self) -> SigningKey:
|
||||||
|
return SigningKey.from_string(
|
||||||
|
hashlib.sha256(self.id.encode("utf-8")).digest(),
|
||||||
|
curve=SECP256k1,
|
||||||
|
hashfunc=hashlib.sha256,
|
||||||
|
)
|
||||||
|
|
||||||
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||||
from .crud import get_wallet_payment
|
from .crud import get_wallet_payment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import trio # type: ignore
|
import trio # type: ignore
|
||||||
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
|
from binascii import unhexlify
|
||||||
from typing import Optional, Tuple, Dict
|
from typing import Optional, Tuple, Dict
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
from quart import g
|
from quart import g
|
||||||
from lnurl import LnurlWithdrawResponse # type: ignore
|
from lnurl import LnurlWithdrawResponse # type: ignore
|
||||||
|
from ecdsa.util import sigencode_der # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from typing import TypedDict # type: ignore
|
from typing import TypedDict # type: ignore
|
||||||
|
|
@ -155,6 +159,32 @@ async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_lnurlauth(callback: str):
|
||||||
|
k1 = unhexlify(parse_qs(urlparse(callback).query)["k1"][0])
|
||||||
|
key = g.wallet.lnurlauth_key
|
||||||
|
sig = key.sign_digest_deterministic(k1, sigencode=sigencode_der)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
callback,
|
||||||
|
params={
|
||||||
|
"k1": k1.hex(),
|
||||||
|
"key": key.verifying_key.to_string("compressed").hex(),
|
||||||
|
"sig": sig.hex(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = json.loads(r.text)
|
||||||
|
if resp["status"] == "OK":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return resp["reason"]
|
||||||
|
except (KeyError, json.decoder.JSONDecodeError):
|
||||||
|
return r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
|
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
|
||||||
payment = get_wallet_payment(wallet_id, payment_hash)
|
payment = get_wallet_payment(wallet_id, payment_hash)
|
||||||
if not payment:
|
if not payment:
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ new Vue({
|
||||||
show: false,
|
show: false,
|
||||||
invoice: null,
|
invoice: null,
|
||||||
lnurlpay: null,
|
lnurlpay: null,
|
||||||
|
lnurlauth: null,
|
||||||
data: {
|
data: {
|
||||||
request: '',
|
request: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
|
@ -237,6 +238,7 @@ new Vue({
|
||||||
this.parse.show = true
|
this.parse.show = true
|
||||||
this.parse.invoice = null
|
this.parse.invoice = null
|
||||||
this.parse.lnurlpay = null
|
this.parse.lnurlpay = null
|
||||||
|
this.parse.lnurlauth = null
|
||||||
this.parse.data.request = ''
|
this.parse.data.request = ''
|
||||||
this.parse.data.comment = ''
|
this.parse.data.comment = ''
|
||||||
this.parse.data.paymentChecker = null
|
this.parse.data.paymentChecker = null
|
||||||
|
|
@ -363,6 +365,8 @@ new Vue({
|
||||||
if (data.kind === 'pay') {
|
if (data.kind === 'pay') {
|
||||||
this.parse.lnurlpay = Object.freeze(data)
|
this.parse.lnurlpay = Object.freeze(data)
|
||||||
this.parse.data.amount = data.minSendable / 1000
|
this.parse.data.amount = data.minSendable / 1000
|
||||||
|
} else if (data.kind === 'auth') {
|
||||||
|
this.parse.lnurlauth = Object.freeze(data)
|
||||||
} else if (data.kind === 'withdraw') {
|
} else if (data.kind === 'withdraw') {
|
||||||
this.parse.show = false
|
this.parse.show = false
|
||||||
this.receive.show = true
|
this.receive.show = true
|
||||||
|
|
@ -542,6 +546,28 @@ new Vue({
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
authLnurl: function () {
|
||||||
|
let dismissAuthMsg = this.$q.notify({
|
||||||
|
timeout: 10,
|
||||||
|
message: 'Performing authentication...'
|
||||||
|
})
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
||||||
|
.then(response => {
|
||||||
|
dismissAuthMsg()
|
||||||
|
this.$q.notify({
|
||||||
|
message: `Authentication successful.`,
|
||||||
|
type: 'positive',
|
||||||
|
timeout: 3500
|
||||||
|
})
|
||||||
|
this.parse.show = false
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
dismissAuthMsg()
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
deleteWallet: function (walletId, user) {
|
deleteWallet: function (walletId, user) {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
<p style="word-break: break-all">
|
<p class="text-wrap">
|
||||||
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
||||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||||
|
|
@ -346,6 +346,32 @@
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="parse.lnurlauth">
|
||||||
|
{% raw %}
|
||||||
|
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||||
|
<p class="q-my-none text-h6">
|
||||||
|
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
||||||
|
</p>
|
||||||
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
|
<p>
|
||||||
|
For every website and for every LNbits wallet, a new keypair will be
|
||||||
|
deterministically generated so your identity can't be tied to your
|
||||||
|
LNbits wallet or linked across websites. No other data will be shared
|
||||||
|
with {{ parse.lnurlauth.domain }}.
|
||||||
|
</p>
|
||||||
|
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||||
|
<p class="q-mx-xl">
|
||||||
|
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||||
|
</p>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="deep-purple" type="submit">Login</q-btn>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
{% endraw %}
|
||||||
|
</div>
|
||||||
<div v-else-if="parse.lnurlpay">
|
<div v-else-if="parse.lnurlpay">
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from lnbits import bolt11
|
||||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
|
||||||
from .. import core_app
|
from .. import core_app
|
||||||
from ..services import create_invoice, pay_invoice
|
from ..services import create_invoice, pay_invoice, perform_lnurlauth
|
||||||
from ..crud import delete_expired_invoices
|
from ..crud import delete_expired_invoices
|
||||||
from ..tasks import sse_listeners
|
from ..tasks import sse_listeners
|
||||||
|
|
||||||
|
|
@ -303,48 +303,69 @@ async def api_lnurlscan(code: str):
|
||||||
return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
domain = urlparse(url.url).netloc
|
domain = urlparse(url.url).netloc
|
||||||
|
|
||||||
|
# params is what will be returned to the client
|
||||||
|
params: Dict = {"domain": domain}
|
||||||
|
|
||||||
if url.is_login:
|
if url.is_login:
|
||||||
return jsonify({"domain": domain, "kind": "auth", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
|
params.update(kind="auth")
|
||||||
|
params.update(callback=url.url) # with k1 already in it
|
||||||
|
params.update(pubkey=g.wallet.lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(url.url, timeout=40)
|
||||||
|
if r.is_error:
|
||||||
|
return jsonify({"domain": domain, "error": "failed to get parameters"}), HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
try:
|
||||||
r = await client.get(url.url, timeout=40)
|
jdata = json.loads(r.text)
|
||||||
if r.is_error:
|
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
|
||||||
return jsonify({"domain": domain, "error": "failed to get parameters"}), HTTPStatus.SERVICE_UNAVAILABLE
|
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
|
||||||
|
return (
|
||||||
|
jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}),
|
||||||
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
if type(data) is lnurl.LnurlChannelResponse:
|
||||||
jdata = json.loads(r.text)
|
return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
|
||||||
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
|
|
||||||
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
|
|
||||||
return (
|
|
||||||
jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}),
|
|
||||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if type(data) is lnurl.LnurlChannelResponse:
|
params.update(**data.dict())
|
||||||
return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
|
|
||||||
|
|
||||||
params: Dict = data.dict()
|
if type(data) is lnurl.LnurlWithdrawResponse:
|
||||||
if type(data) is lnurl.LnurlWithdrawResponse:
|
params.update(kind="withdraw")
|
||||||
params.update(kind="withdraw")
|
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
|
||||||
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
|
|
||||||
|
|
||||||
# callback with k1 already in it
|
# callback with k1 already in it
|
||||||
parsed_callback: ParseResult = urlparse(data.callback)
|
parsed_callback: ParseResult = urlparse(data.callback)
|
||||||
qs: Dict = parse_qs(parsed_callback.query)
|
qs: Dict = parse_qs(parsed_callback.query)
|
||||||
qs["k1"] = data.k1
|
qs["k1"] = data.k1
|
||||||
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
|
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
|
||||||
params.update(callback=urlunparse(parsed_callback))
|
params.update(callback=urlunparse(parsed_callback))
|
||||||
|
|
||||||
if type(data) is lnurl.LnurlPayResponse:
|
if type(data) is lnurl.LnurlPayResponse:
|
||||||
params.update(kind="pay")
|
params.update(kind="pay")
|
||||||
params.update(fixed=data.min_sendable == data.max_sendable)
|
params.update(fixed=data.min_sendable == data.max_sendable)
|
||||||
params.update(description_hash=data.metadata.h)
|
params.update(description_hash=data.metadata.h)
|
||||||
params.update(description=data.metadata.text)
|
params.update(description=data.metadata.text)
|
||||||
if data.metadata.images:
|
if data.metadata.images:
|
||||||
image = min(data.metadata.images, key=lambda image: len(image[1]))
|
image = min(data.metadata.images, key=lambda image: len(image[1]))
|
||||||
data_uri = "data:" + image[0] + "," + image[1]
|
data_uri = "data:" + image[0] + "," + image[1]
|
||||||
params.update(image=data_uri)
|
params.update(image=data_uri)
|
||||||
params.update(commentAllowed=jdata.get("commentAllowed", 0))
|
params.update(commentAllowed=jdata.get("commentAllowed", 0))
|
||||||
|
|
||||||
params.update(domain=domain)
|
|
||||||
return jsonify(params)
|
return jsonify(params)
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.route("/api/v1/lnurlauth", methods=["POST"])
|
||||||
|
@api_check_wallet_key("admin")
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={
|
||||||
|
"callback": {"type": "string", "required": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_perform_lnurlauth():
|
||||||
|
try:
|
||||||
|
await perform_lnurlauth(g.data["callback"])
|
||||||
|
return "", HTTPStatus.OK
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ window.LNbits = {
|
||||||
description
|
description
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
authLnurl: function (wallet, callback) {
|
||||||
|
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
|
||||||
|
callback
|
||||||
|
})
|
||||||
|
},
|
||||||
getWallet: function (wallet) {
|
getWallet: function (wallet) {
|
||||||
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue