Implement signup with lightning
This commit is contained in:
parent
c3bd4ae44f
commit
e655cddca3
@ -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
|
||||
);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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"})
|
||||
}
|
||||
}
|
||||
|
32
server/router/pages/lnAuthSignup.templ
Normal file
32
server/router/pages/lnAuthSignup.templ
Normal 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>
|
||||
}
|
@ -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"
|
||||
|
@ -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))
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user