Implement signup with lightning

This commit is contained in:
ekzyis 2024-07-12 10:58:16 +02:00
parent c3bd4ae44f
commit e655cddca3
8 changed files with 202 additions and 30 deletions

View File

@ -1,8 +1,8 @@
CREATE TABLE users(
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
ln_pubkey TEXT,
nostr_pubkey TEXT,
ln_pubkey TEXT UNIQUE,
nostr_pubkey TEXT UNIQUE,
msats BIGINT NOT NULL DEFAULT 0
);

View File

@ -29,14 +29,14 @@
@apply pb-1;
}
a,
a:not(.no-link),
button[hx-get],
button[hx-post] {
text-decoration: underline;
transition: background-color 150ms ease-in, color 150ms ease-in;
}
a:hover,
a:not(.no-link):hover,
button[hx-get]:hover,
button[hx-post]:hover {
background-color: var(--color);
@ -71,9 +71,11 @@
}
.login, .signup {
text-decoration: none !important;
transition: none !important;
padding: 0.25em 1em !important;
width: fit-content;
margin: 0 auto;
padding: 0.25em 1em;
border-radius: 5px;
font-weight: bold;
}

View File

@ -8,58 +8,74 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcutil/bech32"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"git.ekzyis.com/ekzyis/delphi.market/env"
)
type LNAuth struct {
type LnAuth struct {
K1 string
LNURL string
}
type LNAuthResponse struct {
type LnAuthCallback struct {
K1 string `query:"k1"`
Sig string `query:"sig"`
Key string `query:"key"`
Tag string `query:"tag"`
}
func NewLNAuth() (*LNAuth, error) {
k1 := make([]byte, 32)
_, err := rand.Read(k1)
if err != nil {
func NewLnAuth(tag string) (*LnAuth, error) {
var (
k1 = make([]byte, 32)
k1hex string
url []byte
bech32Url []byte
lnurl string
err error
)
if _, err := rand.Read(k1); err != nil {
return nil, fmt.Errorf("rand.Read error: %w", err)
}
k1hex := hex.EncodeToString(k1)
url := []byte(fmt.Sprintf("https://%s/api/login/callback?tag=login&k1=%s&action=login", env.PublicURL, k1hex))
conv, err := bech32.ConvertBits(url, 8, 5, true)
if err != nil {
k1hex = hex.EncodeToString(k1)
url = []byte(fmt.Sprintf("https://%s/api/lnauth/callback?tag=%s&k1=%s&action=login", env.PublicURL, tag, k1hex))
if bech32Url, err = bech32.ConvertBits(url, 8, 5, true); err != nil {
return nil, fmt.Errorf("bech32.ConvertBits error: %w", err)
}
lnurl, err := bech32.Encode("lnurl", conv)
if err != nil {
if lnurl, err = bech32.Encode("lnurl", bech32Url); err != nil {
return nil, fmt.Errorf("bech32.Encode error: %w", err)
}
return &LNAuth{k1hex, lnurl}, nil
return &LnAuth{k1hex, lnurl}, nil
}
func VerifyLNAuth(r *LNAuthResponse) (bool, error) {
var k1Bytes, sigBytes, keyBytes []byte
k1Bytes, err := hex.DecodeString(r.K1)
if err != nil {
func VerifyLNAuth(r *LnAuthCallback) (bool, error) {
var (
k1Bytes, sigBytes, keyBytes []byte
key *secp256k1.PublicKey
err error
)
if k1Bytes, err = hex.DecodeString(r.K1); err != nil {
return false, fmt.Errorf("k1 decode error: %w", err)
}
sigBytes, err = hex.DecodeString(r.Sig)
if err != nil {
if sigBytes, err = hex.DecodeString(r.Sig); err != nil {
return false, fmt.Errorf("sig decode error: %w", err)
}
keyBytes, err = hex.DecodeString(r.Key)
if err != nil {
if keyBytes, err = hex.DecodeString(r.Key); err != nil {
return false, fmt.Errorf("key decode error: %w", err)
}
key, err := btcec.ParsePubKey(keyBytes)
if err != nil {
if key, err = btcec.ParsePubKey(keyBytes); err != nil {
return false, fmt.Errorf("key parse error: %w", err)
}
ecdsaKey := ecdsa.PublicKey{Curve: btcec.S256(), X: key.X(), Y: key.Y()}
return ecdsa.VerifyASN1(&ecdsaKey, k1Bytes, sigBytes), nil
}

View File

@ -25,6 +25,7 @@ var (
EnvContextKey RenderContextKey = "env"
SessionContextKey RenderContextKey = "session"
CommitContextKey RenderContextKey = "commit"
ReqPathContextKey RenderContextKey = "reqPath"
)
func RenderContext(sc Context, c echo.Context) context.Context {
@ -32,5 +33,6 @@ func RenderContext(sc Context, c echo.Context) context.Context {
ctx = context.WithValue(ctx, EnvContextKey, sc.Environment)
ctx = context.WithValue(ctx, SessionContextKey, c.Get("session"))
ctx = context.WithValue(ctx, CommitContextKey, sc.CommitShortSha)
ctx = context.WithValue(ctx, ReqPathContextKey, c.Request().URL.Path)
return ctx
}

View File

@ -1,6 +1,12 @@
package handler
import (
"database/sql"
"net/http"
"time"
"git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/server/auth"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
"github.com/labstack/echo/v4"
@ -8,6 +14,112 @@ import (
func HandleSignup(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Param("method") == "lightning" {
return LnAuthSignup(sc, c)
}
return pages.Signup().Render(context.RenderContext(sc, c), c.Response().Writer)
}
}
func LnAuthSignup(sc context.Context, c echo.Context) error {
var (
db = sc.Db
ctx = c.Request().Context()
lnAuth *auth.LnAuth
sessionId string
// sessions expire in 30 days. TODO: refresh sessions
expires = time.Now().Add(60 * 60 * 24 * 30 * time.Second)
qr string
err error
)
if lnAuth, err = auth.NewLnAuth("signup"); err != nil {
return err
}
if err = db.QueryRowContext(
ctx,
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
lnAuth.K1, lnAuth.LNURL).Scan(&sessionId); err != nil {
return err
}
if qr, err = lib.ToQR(lnAuth.LNURL); err != nil {
return err
}
c.SetCookie(&http.Cookie{
Name: "session",
HttpOnly: true,
Path: "/",
Value: sessionId,
Secure: true,
Expires: expires,
})
return pages.LnAuthSignup(qr, lnAuth.LNURL).Render(context.RenderContext(sc, c), c.Response().Writer)
}
func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
db = sc.Db
tx *sql.Tx
ctx = c.Request().Context()
query auth.LnAuthCallback
sessionId string
userId int
ok bool
err error
)
if err = c.Bind(&query); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
return err
}
err = tx.QueryRow("SELECT session_id FROM lnauth WHERE k1 = $1 LIMIT 1", query.K1).Scan(&sessionId)
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"})
} else if err != nil {
return err
}
ok, err = auth.VerifyLNAuth(&query)
if err != nil {
return err
} else if !ok {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "bad signature"})
}
if query.Tag == "signup" {
if err = tx.QueryRow("INSERT INTO users(ln_pubkey) VALUES ($1) RETURNING id").Scan(&userId); err != nil {
return err
}
} else if query.Tag == "login" {
err = tx.QueryRow("SELECT id FROM users WHERE ln_pubkey = $1", query.Key).Scan(&userId)
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "user not found"})
} else if err != nil {
return err
}
} else {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "tag must be signup or login"})
}
if _, err = tx.Exec("INSERT INTO sessions(user_id, session_id) VALUES($1, $2)", userId, sessionId); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM lnauth WHERE k1 = $1", query.K1); err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"status": "OK"})
}
}

View File

@ -0,0 +1,32 @@
package pages
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
templ LnAuthSignup(qr string, lnurl string) {
<html>
@components.Head()
<body class="container">
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", "signup")
<small><code>with lightning</code></small>
<div
class="flex flex-col my-3 text-center"
hx-target="#content"
hx-swap="outerHTML"
hx-select="#content"
hx-push-url="true"
>
<a
class="mx-auto no-link"
href={ templ.SafeURL("lightning:" + lnurl) }
>
<img src={ "data:image/jpeg;base64," + qr }/>
</a>
<small class="mx-auto w-[256px] my-1 break-words">{ lnurl }</small>
</div>
</div>
@components.Footer()
</body>
</html>
}

View File

@ -9,8 +9,14 @@ templ Signup() {
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", "signup")
<div class="flex flex-col mb-3 text-center">
<button class="flex signup lightning my-3 items-center">
<div
class="flex flex-col mb-3 text-center"
hx-target="#content"
hx-swap="outerHTML"
hx-select="#content"
hx-push-url="true"
>
<button class="flex signup lightning my-3 items-center" hx-get="/signup/lightning">
<svg
width="24"
height="24"

View File

@ -16,4 +16,6 @@ func Init(e *echo.Echo, sc Context) {
e.GET("/about", handler.HandleAbout(sc))
e.GET("/login", handler.HandleLogin(sc))
e.GET("/signup", handler.HandleSignup(sc))
e.GET("/signup/:method", handler.HandleSignup(sc))
e.GET("/api/lnauth/callback", handler.HandleLnAuthCallback(sc))
}