feat: rebuild lnurl-auth api using sessions & passport, add login with QR

This commit is contained in:
MTG2000
2022-06-07 20:49:32 +03:00
parent 31e80e8820
commit ab20b8af2c
13 changed files with 1880 additions and 118 deletions

View File

@@ -2,7 +2,13 @@ const lnurl = require('lnurl')
const crypto = require('crypto')
const { prisma } = require('../../prisma')
const { CONSTS } = require('../../utils')
const express = require('express');
const session = require("express-session");
const passport = require("passport");
const lnurlAuth = require("passport-lnurl-auth");
const assert = require('assert');
const { HttpError } = require('lnurl/lib');
var SQLiteStore = require('connect-sqlite3')(session);
async function generateSecret() {
let secret = null
@@ -28,10 +34,11 @@ function isHashUsed(hash) {
return prisma.generatedK1.findFirst({ where: { value: hash } })
}
function addHash(hash) {
function addHash(hash, sid) {
return prisma.generatedK1.create({
data: {
value: hash,
sid,
}
})
}
@@ -57,11 +64,10 @@ function removeExpiredHashes() {
})
}
async function generateAuthUrl() {
async function generateAuthUrl(sid) {
const hostname = CONSTS.LNURL_AUTH_HOST;
console.log(hostname);
const secret = await generateSecret()
await addHash(createHash(secret))
await addHash(createHash(secret), sid)
const url = `${hostname}?tag=login&k1=${secret}`
return {
url,
@@ -70,6 +76,16 @@ async function generateAuthUrl() {
}
}
async function getSidByK1(k1) {
const hash = createHash(k1)
const data = await prisma.generatedK1.findFirst({
where: {
value: hash,
}
});
return data.sid;
}
async function verifySig(sig, k1, key) {
if (!lnurl.verifyAuthorizationSignature(sig, k1, key)) {
const message = 'Signature verification failed'
@@ -94,10 +110,143 @@ function createHash(data) {
return crypto.createHash('sha256').update(data).digest('hex')
}
function setupAuthMiddelwares(app) {
app.use(session({
secret: "12345",
resave: false,
saveUninitialized: true,
store: new SQLiteStore()
}));
passport.use(
new lnurlAuth.Strategy(function (linkingPublicKey, done) {
const user = { id: linkingPublicKey };
console.log("Strategy Function");
console.log(user);
// let user = map.user.get(linkingPublicKey);
// if (!user) {
// user = { id: linkingPublicKey };
// map.user.set(linkingPublicKey, user);
// }
done(null, user);
})
);
app.use(passport.initialize());
app.use(passport.session());
app.use(passport.authenticate("lnurl-auth"));
passport.serializeUser(function (user, done) {
done(null, user.id);
});
passport.deserializeUser(function (id, done) {
// done(null, map.user.get(id) || null);
done(null, id || null);
});
return app;
/*
app.get(
"/do-login",
function (req, res, next) {
next();
},
async function (req, res) {
if (req.query.k1 || req.query.key || req.query.sig) {
// Check signature against provided linking public key.
// This request could originate from a mobile app (ie. not their browser).
let session;
assert.ok(
req.query.k1,
new HttpError('Missing required parameter: "k1"', 400)
);
assert.ok(
req.query.sig,
new HttpError('Missing required parameter: "sig"', 400)
);
assert.ok(
req.query.key,
new HttpError('Missing required parameter: "key"', 400)
);
session = map.session.get(req.query.k1);
assert.ok(
session,
new HttpError("Secret does not match any known session", 400)
);
const { k1, sig, key } = req.query;
assert.ok(
verifyAuthorizationSignature(sig, k1, key),
new HttpError("Invalid signature", 400)
);
session.lnurlAuth = session.lnurlAuth || {};
session.lnurlAuth.linkingPublicKey = req.query.key;
const result = await session.save();
console.log(result);
res.status(200).json({ status: "OK" });
}
req.session = req.session || {};
req.session.lnurlAuth = req.session.lnurlAuth || {};
let k1 = req.session.lnurlAuth.k1 || null;
if (!k1) {
k1 = req.session.lnurlAuth.k1 = generateSecret(32, "hex");
map.session.set(k1, req.session);
}
const callbackUrl =
"https://" +
`${req.get("host")}/do-login?${querystring.stringify({
k1,
tag: "login",
})}`;
const encoded = lnurl.encode(callbackUrl).toUpperCase();
const qrCode = await qrcode.toDataURL(encoded);
return res.json({
lnurl: encoded,
qrCode: qrCode,
});
}
);
*/
// app.get("/logout", function (req, res, next) {
// if (req.user) {
// req.session.destroy();
// return res.redirect("/");
// }
// next();
// });
// app.get("/me", function (req, res, next) {
// res.json({ user: req.user ? req.user : null });
// next();
// });
// app.get("/profile", function (req, res, next) {
// if (!req.user) {
// return res.redirect("/login");
// }
// res.render("profile", { user: req.user });
// next();
// });
}
module.exports = {
generateAuthUrl: generateAuthUrl,
verifySig: verifySig,
removeHash: removeHash,
createHash: createHash,
removeExpiredHashes: removeExpiredHashes
removeExpiredHashes: removeExpiredHashes,
getSidByK1: getSidByK1
}

View File

@@ -1,31 +1,18 @@
const { ApolloServer } = require("apollo-server-lambda");
const schema = require('./schema')
const cookie = require('cookie')
const jose = require('jose');
const { CONSTS } = require('../utils');
const extractKey = async (cookieHeader) => {
const cookies = cookie.parse(cookieHeader ?? '');
const authToken = cookies.Authorization;
if (authToken) {
const token = authToken.split(' ')[1];
const { payload } = await jose.jwtVerify(token, Buffer.from(CONSTS.JWT_SECRET), {
algorithms: ['HS256'],
})
return payload.pubKey
}
return null;
}
const { createExpressApp } = require('../utils/express-app')
const server = new ApolloServer({
schema,
context: async ({ event }) => {
const userPubKey = await extractKey(event.headers.cookie ?? event.headers.Cookie)
context: async ({ event, context, express }) => {
const userPubKey = express.req.user?.id;
return { userPubKey }
},
});
const apolloHandler = server.createHandler({
expressGetMiddlewareOptions: {
cors: {
@@ -33,6 +20,11 @@ const apolloHandler = server.createHandler({
credentials: true,
}
},
expressAppFromMiddleware(middleware) {
const app = createExpressApp();
app.use(middleware)
return app;
}
});

View File

@@ -5,19 +5,23 @@ const cookie = require('cookie')
const jose = require('jose');
const { CONSTS } = require('../utils');
const { CORS_HEADERS } = require('../utils/consts');
const serverless = require('serverless-http');
// const { expressApp } = require('../utils/express-app');
// const { sessionsStore } = require('../utils/sessionsStore');
const { getSidByK1 } = require('../auth/services/lnurl.service');
const express = require('express')
const session = require("express-session");
const passport = require("passport");
const lnurlAuth = require("passport-lnurl-auth");
// const { sessionsStore } = require('./sessionsStore');
var cors = require('cors');
const { createExpressApp } = require('../utils/express-app');
const { sessionsStore } = require('../utils/sessionsStore');
async function generateAuthUrl() {
const data = await LnurlService.generateAuthUrl();
return {
statusCode: 200,
headers: CORS_HEADERS,
body: JSON.stringify(data)
};
}
async function login(req, res) {
async function login(tag, k1, sig, key) {
const { tag, k1, sig, key } = req.query;
if (tag !== 'login') {
return {
statusCode: 400,
@@ -99,19 +103,92 @@ async function login(tag, k1, sig, key) {
}
}
const app = createExpressApp();
app.get('/login', async (req, res) => {
const { tag, k1, sig, key } = req.query;
exports.handler = async (event, context) => {
const { tag, k1, sig, key } = event.queryStringParameters ?? {}
if (event.httpMethod !== "GET") {
return { statusCode: 405, body: "Method Not Allowed" };
}
// Generate an auth URL
if (!sig || !key) {
return generateAuthUrl();
const data = await LnurlService.generateAuthUrl(req.sessionID);
return res.status(200).json(data);
}
else {
return login(tag, k1, sig, key)
if (tag !== 'login')
return res.status(400).send("Invalid tag provided")
// Verify login params
try {
await LnurlService.verifySig(sig, k1, key)
} catch (error) {
return res.status(400).json({ status: 'ERROR', reason: 'Invalid Signature' })
}
try {
//Create user if not already existing
const user = await prisma.user.findFirst({ where: { pubKey: key } })
if (user === null) {
await prisma.user.create({
data: {
pubKey: key,
name: key,
avatar: `https://avatars.dicebear.com/api/bottts/${key}.svg`
}
})
}
// Update the session with the secret
const sid = await getSidByK1(k1);
const d = await new Promise((res, rej) => {
sessionsStore.get(sid, (err, d) => {
if (err) rej(err);
res(d)
})
});
// console.log(d);
await new Promise((res, rej) => {
sessionsStore.set(sid, { ...d, lnurlAuth: { linkingPublicKey: key } }, (err) => {
if (err) rej(err);
res()
})
});
LnurlService.removeHash(LnurlService.createHash(k1)).catch();
LnurlService.removeExpiredHashes().catch();
return res.status(200).json({ status: "OK" })
// const jwt = await new jose.SignJWT({ pubKey: key })
// .setProtectedHeader({ alg: 'HS256' })
// .setIssuedAt()
// .setExpirationTime(maxAge)
// //TODO: Set audience, issuer
// .sign(Buffer.from(jwtSecret, 'utf-8'))
// const authCookie = cookie.serialize('Authorization', `Bearer ${jwt}`, {
// secure: true,
// httpOnly: true,
// path: '/',
// maxAge: maxAge,
// sameSite: 'none',
// })
} catch (error) {
console.log(error);
return res.status(200).json({ status: 'ERROR', reason: 'Unexpected error happened, please try again' })
}
}
})
const handler = serverless(app);
exports.handler = async (event, context) => {
return await handler(event, context);
};

View File

@@ -1,24 +1,16 @@
const serverless = require('serverless-http');
const { createExpressApp } = require('../utils/express-app');
const app = createExpressApp();
const cookie = require('cookie');
const { CORS_HEADERS } = require('../utils/consts');
exports.handler = async (event, context) => {
const myCookie = cookie.serialize('Authorization', '', {
secure: true,
httpOnly: true,
path: '/',
maxAge: -1,
})
return {
statusCode: 200,
body: JSON.stringify({
status: 'OK',
}),
'headers': {
'Set-Cookie': myCookie,
'Cache-Control': 'no-cache',
...CORS_HEADERS
}
app.get('/logout', (req, res, next) => {
if (req.user) {
req.session.destroy();
return res.redirect("/");
}
next();
})
const handler = serverless(app);
exports.handler = async (event, context) => {
return await handler(event, context);
};

View File

@@ -1,6 +1,7 @@
const BOLT_FUN_LIGHTNING_ADDRESS = 'johns@getalby.com'; // #TODO, replace it by bolt-fun lightning address if there exist one
const JWT_SECRET = process.env.JWT_SECRET;
const LNURL_AUTH_HOST = process.env.LNURL_AUTH_HOST
const SESSION_SECRET = process.env.SESSION_SECRET
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'http://localhost:3000',
@@ -13,7 +14,8 @@ const CONSTS = {
JWT_SECRET,
BOLT_FUN_LIGHTNING_ADDRESS,
LNURL_AUTH_HOST,
CORS_HEADERS
CORS_HEADERS,
SESSION_SECRET
}
module.exports = CONSTS;

View File

@@ -0,0 +1,51 @@
const express = require('express');
const session = require("express-session");
const passport = require("passport");
const lnurlAuth = require("passport-lnurl-auth");
const { sessionsStore } = require('./sessionsStore');
var cors = require('cors');
const { SESSION_SECRET } = require('./consts');
const createExpressApp = () => {
const app = express();
app.use(cors({
origin: ['http://localhost:3000', 'https://studio.apollographql.com'],
credentials: true,
}))
app.use(session({
secret: SESSION_SECRET,
resave: false,
store: sessionsStore,
saveUninitialized: true,
cookie: {
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
}
}));
passport.use(new lnurlAuth.Strategy(function (linkingPublicKey, done) {
// The user has successfully authenticated using lnurl-auth.
// The linked public key is provided here.
// You can use this as a unique reference for the user similar to a username or email address.
const user = { id: linkingPublicKey };
done(null, user);
}));
app.use(passport.initialize());
app.use(passport.session());
app.use(passport.authenticate("lnurl-auth"));
passport.serializeUser(function (user, done) {
done(null, user?.id);
});
passport.deserializeUser(function (id, done) {
done(null, { id } || null);
});
return app;
}
module.exports = { createExpressApp };

View File

@@ -0,0 +1,16 @@
const session = require("express-session");
var Store = require('connect-pg-simple')(session);
let sessionsStore;
if (!global.sessionsStore) {
console.log("New Sessions Store");
global.sessionsStore = new Store({
createTableIfMissing: true,
tableName: "user_sessions",
});
}
sessionsStore = global.sessionsStore;
module.exports = { sessionsStore };

1541
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,11 +23,18 @@
"apollo-server": "^3.6.7",
"apollo-server-lambda": "^3.6.7",
"axios": "^0.26.1",
"better-sqlite3": "^7.5.3",
"better-sqlite3-session-store": "^0.0.3",
"chance": "^1.1.8",
"connect-pg-simple": "^7.0.0",
"connect-sqlite3": "^0.9.13",
"cookie": "^0.5.0",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"dayjs": "^1.11.1",
"env-cmd": "^10.1.0",
"express": "^4.18.1",
"express-session": "^1.17.3",
"framer-motion": "^6.3.0",
"fslightbox-react": "^1.6.2-2",
"graphql": "^16.3.0",
@@ -41,7 +48,10 @@
"marked": "^4.0.14",
"nexus": "^1.3.0",
"node-sass": "^7.0.1",
"passport": "^0.6.0",
"passport-lnurl-auth": "^1.5.0",
"prisma": "^3.12.0",
"qrcode.react": "^3.0.2",
"react": "^18.0.0",
"react-accessible-accordion": "^5.0.0",
"react-confetti": "^6.0.1",
@@ -66,6 +76,7 @@
"react-topbar-progress-indicator": "^4.1.1",
"remirror": "^1.0.77",
"secp256k1": "^4.0.3",
"serverless-http": "^3.0.1",
"typescript": "^4.6.3",
"web-vitals": "^2.1.4",
"webln": "^0.3.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "GeneratedK1" ADD COLUMN "sid" TEXT;

View File

@@ -220,5 +220,6 @@ model Donation {
// -----------------
model GeneratedK1 {
value String @id
sid String?
createdAt DateTime @default(now())
}

View File

@@ -1,3 +1,5 @@
useDotenv: true
custom:
serverless-offline:
httpPort: 8888
@@ -17,30 +19,24 @@ functions:
- http:
path: graphql
method: post
cors: true
- http:
path: graphql
method: get
cors: true
login:
handler: functions/login/login.handler
events:
- http:
path: login
method: post
cors: true
- http:
path: login
method: get
cors: true
logout:
handler: functions/logout/logout.handler
events:
- http:
path: logout
method: post
cors: true
- http:
path: logout
method: get
cors: true

View File

@@ -5,11 +5,16 @@ import { Grid } from "react-loader-spinner";
import { useNavigate } from "react-router-dom";
import { useMeQuery } from "src/graphql"
import { CONSTS } from "src/utils";
import { QRCodeSVG } from 'qrcode.react';
import { IoQrCode } from "react-icons/io5";
import Button from "src/Components/Button/Button";
const getLnurlAuth = async () => {
const res = await fetch(CONSTS.apiEndpoint + '/login')
const res = await fetch(CONSTS.apiEndpoint + '/login', {
credentials: 'include'
})
const data = await res.json()
return data;
}
@@ -17,6 +22,7 @@ const getLnurlAuth = async () => {
export default function LoginPage() {
const [loadingLnurl, setLoadingLnurl] = useState(true)
const [showQr, setShowQr] = useState(false)
const [lnurlAuth, setLnurlAuth] = useState("");
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [error, setError] = useState(null)
@@ -49,11 +55,13 @@ export default function LoginPage() {
})
}, [])
const onLogin = () => {
const startPolling = () => {
meQuery.startPolling(1500)
}
let content = <></>
if (error)
@@ -92,10 +100,20 @@ export default function LoginPage() {
</p>
<a
href={lnurlAuth}
onClick={onLogin}
onClick={startPolling}
className='block text-black font-bolder bg-yellow-200 hover:bg-yellow-300 rounded-12 px-16 py-12 active:scale-90 transition-transform'>
Login with Lightning <BsFillLightningChargeFill className="scale-125" />
Login with WebLN <BsFillLightningChargeFill className="scale-125" />
</a>
<div className="text-gray-500 text-body5 font-bold">OR</div>
{!showQr && <button className="text-blue-500 text-body4 px-12 py-8 hover:bg-gray-100 rounded-8" onClick={() => { setShowQr(true); startPolling() }}>
Scan QR <IoQrCode width={'100%'} />
</button>}
{showQr && <QRCodeSVG
width={160}
height={160}
value={lnurlAuth}
/>}
</div>;
return (