Implement signup with lightning
This commit is contained in:
parent
c3bd4ae44f
commit
e655cddca3
@ -1,8 +1,8 @@
|
|||||||
CREATE TABLE users(
|
CREATE TABLE users(
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
ln_pubkey TEXT,
|
ln_pubkey TEXT UNIQUE,
|
||||||
nostr_pubkey TEXT,
|
nostr_pubkey TEXT UNIQUE,
|
||||||
msats BIGINT NOT NULL DEFAULT 0
|
msats BIGINT NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -29,14 +29,14 @@
|
|||||||
@apply pb-1;
|
@apply pb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
a:not(.no-link),
|
||||||
button[hx-get],
|
button[hx-get],
|
||||||
button[hx-post] {
|
button[hx-post] {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
transition: background-color 150ms ease-in, color 150ms ease-in;
|
transition: background-color 150ms ease-in, color 150ms ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover,
|
a:not(.no-link):hover,
|
||||||
button[hx-get]:hover,
|
button[hx-get]:hover,
|
||||||
button[hx-post]:hover {
|
button[hx-post]:hover {
|
||||||
background-color: var(--color);
|
background-color: var(--color);
|
||||||
@ -71,9 +71,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login, .signup {
|
.login, .signup {
|
||||||
|
text-decoration: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
padding: 0.25em 1em !important;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0.25em 1em;
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
@ -8,58 +8,74 @@ import (
|
|||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
"github.com/btcsuite/btcutil/bech32"
|
"github.com/btcsuite/btcutil/bech32"
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/env"
|
"git.ekzyis.com/ekzyis/delphi.market/env"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LNAuth struct {
|
type LnAuth struct {
|
||||||
K1 string
|
K1 string
|
||||||
LNURL string
|
LNURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LNAuthResponse struct {
|
type LnAuthCallback struct {
|
||||||
K1 string `query:"k1"`
|
K1 string `query:"k1"`
|
||||||
Sig string `query:"sig"`
|
Sig string `query:"sig"`
|
||||||
Key string `query:"key"`
|
Key string `query:"key"`
|
||||||
|
Tag string `query:"tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLNAuth() (*LNAuth, error) {
|
func NewLnAuth(tag string) (*LnAuth, error) {
|
||||||
k1 := make([]byte, 32)
|
var (
|
||||||
_, err := rand.Read(k1)
|
k1 = make([]byte, 32)
|
||||||
if err != nil {
|
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)
|
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))
|
k1hex = hex.EncodeToString(k1)
|
||||||
conv, err := bech32.ConvertBits(url, 8, 5, true)
|
url = []byte(fmt.Sprintf("https://%s/api/lnauth/callback?tag=%s&k1=%s&action=login", env.PublicURL, tag, k1hex))
|
||||||
if err != nil {
|
|
||||||
|
if bech32Url, err = bech32.ConvertBits(url, 8, 5, true); err != nil {
|
||||||
return nil, fmt.Errorf("bech32.ConvertBits error: %w", err)
|
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 nil, fmt.Errorf("bech32.Encode error: %w", err)
|
||||||
}
|
}
|
||||||
return &LNAuth{k1hex, lnurl}, nil
|
|
||||||
|
return &LnAuth{k1hex, lnurl}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func VerifyLNAuth(r *LNAuthResponse) (bool, error) {
|
func VerifyLNAuth(r *LnAuthCallback) (bool, error) {
|
||||||
var k1Bytes, sigBytes, keyBytes []byte
|
var (
|
||||||
k1Bytes, err := hex.DecodeString(r.K1)
|
k1Bytes, sigBytes, keyBytes []byte
|
||||||
if err != nil {
|
key *secp256k1.PublicKey
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if k1Bytes, err = hex.DecodeString(r.K1); err != nil {
|
||||||
return false, fmt.Errorf("k1 decode error: %w", err)
|
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)
|
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)
|
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)
|
return false, fmt.Errorf("key parse error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ecdsaKey := ecdsa.PublicKey{Curve: btcec.S256(), X: key.X(), Y: key.Y()}
|
ecdsaKey := ecdsa.PublicKey{Curve: btcec.S256(), X: key.X(), Y: key.Y()}
|
||||||
return ecdsa.VerifyASN1(&ecdsaKey, k1Bytes, sigBytes), nil
|
return ecdsa.VerifyASN1(&ecdsaKey, k1Bytes, sigBytes), nil
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ var (
|
|||||||
EnvContextKey RenderContextKey = "env"
|
EnvContextKey RenderContextKey = "env"
|
||||||
SessionContextKey RenderContextKey = "session"
|
SessionContextKey RenderContextKey = "session"
|
||||||
CommitContextKey RenderContextKey = "commit"
|
CommitContextKey RenderContextKey = "commit"
|
||||||
|
ReqPathContextKey RenderContextKey = "reqPath"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RenderContext(sc Context, c echo.Context) context.Context {
|
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, EnvContextKey, sc.Environment)
|
||||||
ctx = context.WithValue(ctx, SessionContextKey, c.Get("session"))
|
ctx = context.WithValue(ctx, SessionContextKey, c.Get("session"))
|
||||||
ctx = context.WithValue(ctx, CommitContextKey, sc.CommitShortSha)
|
ctx = context.WithValue(ctx, CommitContextKey, sc.CommitShortSha)
|
||||||
|
ctx = context.WithValue(ctx, ReqPathContextKey, c.Request().URL.Path)
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
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/context"
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@ -8,6 +14,112 @@ import (
|
|||||||
|
|
||||||
func HandleSignup(sc context.Context) echo.HandlerFunc {
|
func HandleSignup(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
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)
|
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()
|
@components.Nav()
|
||||||
<div id="content" class="flex flex-col text-center">
|
<div id="content" class="flex flex-col text-center">
|
||||||
@components.Figlet("random", "signup")
|
@components.Figlet("random", "signup")
|
||||||
<div class="flex flex-col mb-3 text-center">
|
<div
|
||||||
<button class="flex signup lightning my-3 items-center">
|
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
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
|
@ -16,4 +16,6 @@ func Init(e *echo.Echo, sc Context) {
|
|||||||
e.GET("/about", handler.HandleAbout(sc))
|
e.GET("/about", handler.HandleAbout(sc))
|
||||||
e.GET("/login", handler.HandleLogin(sc))
|
e.GET("/login", handler.HandleLogin(sc))
|
||||||
e.GET("/signup", handler.HandleSignup(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