169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
|
|
package handlers
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/hex"
|
||
|
|
"net/http"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/arkie/ark-database/internal/auth"
|
||
|
|
"github.com/ethereum/go-ethereum/accounts"
|
||
|
|
"github.com/ethereum/go-ethereum/common"
|
||
|
|
"github.com/ethereum/go-ethereum/crypto"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
var ethAddrRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
|
||
|
|
|
||
|
|
func EnsureWalletAuthSchema(ctx context.Context, pool *pgxpool.Pool) error {
|
||
|
|
_, err := pool.Exec(ctx, `
|
||
|
|
CREATE TABLE IF NOT EXISTS wallet_auth_nonces (
|
||
|
|
address TEXT PRIMARY KEY,
|
||
|
|
nonce TEXT NOT NULL,
|
||
|
|
expires_at TIMESTAMPTZ NOT NULL
|
||
|
|
);
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_wallet_auth_nonces_expires ON wallet_auth_nonces(expires_at);
|
||
|
|
`)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeAddr(a string) string {
|
||
|
|
return strings.ToLower(strings.TrimSpace(a))
|
||
|
|
}
|
||
|
|
|
||
|
|
type walletNonceReq struct {
|
||
|
|
Address string `json:"address"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type walletVerifyReq struct {
|
||
|
|
Address string `json:"address"`
|
||
|
|
Message string `json:"message"`
|
||
|
|
Signature string `json:"signature"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func WalletNonce() http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var req walletNonceReq
|
||
|
|
if err := jsonDecode(r, &req); err != nil {
|
||
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
addr := normalizeAddr(req.Address)
|
||
|
|
if !ethAddrRe.MatchString(addr) {
|
||
|
|
http.Error(w, "invalid address", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var b [16]byte
|
||
|
|
if _, err := rand.Read(b[:]); err != nil {
|
||
|
|
http.Error(w, "rng", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
nonce := hex.EncodeToString(b[:])
|
||
|
|
pool := poolFrom(r)
|
||
|
|
ctx := r.Context()
|
||
|
|
_, _ = pool.Exec(ctx, `DELETE FROM wallet_auth_nonces WHERE expires_at < NOW()`)
|
||
|
|
_, err := pool.Exec(ctx, `
|
||
|
|
INSERT INTO wallet_auth_nonces (address, nonce, expires_at) VALUES ($1, $2, NOW() + INTERVAL '15 minutes')
|
||
|
|
ON CONFLICT (address) DO UPDATE SET nonce = EXCLUDED.nonce, expires_at = EXCLUDED.expires_at`,
|
||
|
|
addr, nonce)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
checksum := common.HexToAddress(addr).Hex()
|
||
|
|
message := "ARK Database — wallet sign-in\n\n" +
|
||
|
|
"Wallet: " + checksum + "\n" +
|
||
|
|
"One-time code: " + nonce + "\n\n" +
|
||
|
|
"Sign this message to log in. No transaction or gas fee."
|
||
|
|
writeJSON(w, map[string]any{"nonce": nonce, "message": message})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func WalletVerify(jwtSecret string) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var req walletVerifyReq
|
||
|
|
if err := jsonDecode(r, &req); err != nil {
|
||
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
addr := normalizeAddr(req.Address)
|
||
|
|
if !ethAddrRe.MatchString(addr) {
|
||
|
|
http.Error(w, "invalid address", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sig := strings.TrimSpace(req.Signature)
|
||
|
|
if !strings.HasPrefix(sig, "0x") {
|
||
|
|
sig = "0x" + sig
|
||
|
|
}
|
||
|
|
msg := req.Message
|
||
|
|
if msg == "" {
|
||
|
|
http.Error(w, "message required", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
pool := poolFrom(r)
|
||
|
|
ctx := r.Context()
|
||
|
|
var storedNonce string
|
||
|
|
err := pool.QueryRow(ctx, `SELECT nonce FROM wallet_auth_nonces WHERE address = $1 AND expires_at > NOW()`, addr).Scan(&storedNonce)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "nonce expired or missing — request a new code", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !strings.Contains(msg, storedNonce) {
|
||
|
|
http.Error(w, "message does not match nonce", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
recovered, err := recoverPersonalSign(msg, sig)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "bad signature", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if normalizeAddr(recovered.Hex()) != addr {
|
||
|
|
http.Error(w, "signer mismatch", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, _ = pool.Exec(ctx, `DELETE FROM wallet_auth_nonces WHERE address = $1`, addr)
|
||
|
|
tok, err := auth.SignUserWallet(jwtSecret, common.HexToAddress(addr).Hex(), 30*24*time.Hour)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, map[string]any{"token": tok, "wallet": common.HexToAddress(addr).Hex()})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func recoverPersonalSign(message, sigHex string) (common.Address, error) {
|
||
|
|
sig, err := hex.DecodeString(strings.TrimPrefix(sigHex, "0x"))
|
||
|
|
if err != nil || len(sig) != 65 {
|
||
|
|
return common.Address{}, err
|
||
|
|
}
|
||
|
|
if sig[64] >= 27 {
|
||
|
|
sig[64] -= 27
|
||
|
|
}
|
||
|
|
hash := accounts.TextHash([]byte(message))
|
||
|
|
pub, err := crypto.SigToPub(hash, sig)
|
||
|
|
if err != nil {
|
||
|
|
return common.Address{}, err
|
||
|
|
}
|
||
|
|
return crypto.PubkeyToAddress(*pub), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func WalletMe(jwtSecret string) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
h := r.Header.Get("Authorization")
|
||
|
|
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
tok := strings.TrimSpace(h[7:])
|
||
|
|
claims, err := auth.ParseUserWallet(jwtSecret, tok)
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, map[string]any{"wallet": claims.Wallet, "role": claims.Role})
|
||
|
|
}
|
||
|
|
}
|