feat: lnurl-auth login

This commit is contained in:
MTG2000
2022-06-01 18:29:38 +03:00
parent 9f8b723d8b
commit 3023d8e20b
32 changed files with 1266 additions and 365 deletions

View File

@@ -1,2 +1,3 @@
REACT_APP_API_END_POINT = https://makers.bolt.fun/.netlify/functions/graphql
REACT_APP_GRAPHQL_END_POINT = https://makers.bolt.fun/.netlify/functions/graphql
REACT_APP_AUTH_END_POINT = https://makers.bolt.fun/.netlify/functions/auth

View File

@@ -1 +1,2 @@
REACT_APP_API_END_POINT = http://localhost:8888/dev/graphql
REACT_APP_GRAPHQL_END_POINT = http://localhost:8888/dev/graphql
REACT_APP_AUTH_END_POINT = http://localhost:8888/dev/auth

View File

@@ -1,2 +1,3 @@
REACT_APP_FOR_GITHUB=true
REACT_APP_API_END_POINT = https://makers.bolt.fun/.netlify/functions/graphql
REACT_APP_GRAPHQL_END_POINT = https://makers.bolt.fun/.netlify/functions/graphql
REACT_APP_AUTH_END_POINT = https://makers.bolt.fun/.netlify/functions/auth

87
functions/auth/login.js Normal file
View File

@@ -0,0 +1,87 @@
const { prisma } = require('../prisma');
const LnurlService = require('./services/lnurl.service')
const cookie = require('cookie')
const jose = require('jose');
const { CONSTS } = require('../utils');
async function generateAuthUrl() {
const data = await LnurlService.generateAuthUrl();
return {
status: "OK",
body: JSON.stringify(data)
};
}
async function login(tag, k1, sig, key) {
if (tag !== 'login') {
return { status: 'ERROR', reason: 'Not a login request' }
}
const result = LnurlService.verifySig(sig, k1, key)
if (!result) {
return { status: 'ERROR', reason: 'Invalid Signature' }
}
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`
}
})
}
// Set cookies on the user's headers
const hour = 3600000
const maxAge = 30 * 24 * hour
const jwtSecret = CONSTS.JWT_SECRET;
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,
})
return {
status: 'OK',
'headers': {
'Set-Cookie': authCookie,
'Cache-Control': 'no-cache',
},
}
}
exports.handler = async (event, context) => {
const { tag, k1, sig, key } = event.queryStringParameters ?? {}
if (event.httpMethod !== "GET") {
return { statusCode: 405, body: "Method Not Allowed" };
}
if (!sig || !key) {
return generateAuthUrl();
}
else {
return login(tag, k1, sig, key)
}
};

29
functions/auth/logout.js Normal file
View File

@@ -0,0 +1,29 @@
const cookie = require('cookie')
exports.handler = async (event, context) => {
// const hour = 3600000
// const maxAge = 30 * 24 * hour;
// const _cookie = cookie.serialize('Hello', `Bearer hello`, {
// // secure: true,
// // httpOnly: true,
// path: '/',
// maxAge
// })
const hour = 3600000
const twoWeeks = 14 * 24 * hour
const myCookie = cookie.serialize('Authorization', '', {
secure: true,
httpOnly: true,
path: '/',
maxAge: -1,
})
console.log(myCookie);
return {
status: 'OK',
'headers': {
'Set-Cookie': myCookie,
'Cache-Control': 'no-cache',
}
}
};

View File

@@ -0,0 +1,97 @@
const lnurl = require('lnurl')
const crypto = require('crypto')
const { prisma } = require('../../prisma')
async function generateSecret() {
let secret = null
const maxAttempts = 5
let attempt = 0
while (secret === null && attempt < maxAttempts) {
secret = crypto.randomBytes(32).toString('hex')
const hash = createHash(secret)
const isUsed = await isHashUsed(hash);
if (isUsed) {
secret = null
}
attempt++
}
if (!secret) {
const message = 'Too many failed attempts to generate unique secret'
throw new Error(message)
}
return secret
}
function isHashUsed(hash) {
return prisma.generatedK1.findFirst({ where: { value: hash } })
}
function addHash(hash) {
prisma.generatedK1.create({
data: {
value: hash,
}
})
}
function removeHash(hash) {
prisma.generatedK1.delete({
where: {
value: hash,
}
})
}
function removeExpiredHashes() {
const now = new Date();
const lastHourDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 1, now.getMinutes());
prisma.generatedK1.deleteMany({
where: {
createdAt: {
lt: lastHourDate
}
}
})
}
async function generateAuthUrl() {
const hostname = 'http://localhost:8888/dev/auth/login'
const secret = await generateSecret()
addHash(createHash(secret))
const url = `${hostname}?tag=login&k1=${secret}`
return {
url,
encoded: lnurl.encode(url).toUpperCase(),
secret,
}
}
function verifySig(sig, k1, key) {
if (!lnurl.verifyAuthorizationSignature(sig, k1, key)) {
const message = 'Signature verification failed'
throw new Error(message)
}
const hash = createHash(k1)
return { key, hash }
}
function createHash(data) {
if (!(typeof data === 'string' || Buffer.isBuffer(data))) {
throw new Error(
JSON.stringify({ status: 'ERROR', reason: 'Secret must be a string or a Buffer' })
)
}
if (typeof data === 'string') {
data = Buffer.from(data, 'hex')
}
return crypto.createHash('sha256').update(data).digest('hex')
}
module.exports = {
generateAuthUrl: generateAuthUrl,
verifySig: verifySig,
removeHash: removeHash,
removeExpiredHashes: removeExpiredHashes
}

View File

@@ -0,0 +1,13 @@
const { prisma } = require('../../prisma')
const getUserByPubKey = (pubKey) => {
if (!pubKey) return null;
return prisma.user.findFirst({
where: { pubKey }
})
}
module.exports = {
getUserByPubKey,
}

View File

@@ -1,12 +1,27 @@
const { ApolloServer } = require("apollo-server-lambda");
const schema = require('./schema')
const cookie = require('cookie')
const jose = require('jose');
const { CONSTS } = require('../utils');
const server = new ApolloServer({
schema,
context: () => {
context: async ({ event }) => {
const cookies = cookie.parse(event.headers.Cookie ?? '');
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 { userPubKey: payload.pubKey }
}
return {
};
},
});
@@ -22,6 +37,7 @@ const apolloHandler = server.createHandler({
// https://github.com/vendia/serverless-express/issues/427#issuecomment-924580007
const handler = (event, context, ...args) => {
return apolloHandler(
{
...event,

View File

@@ -296,6 +296,7 @@ export interface NexusGenFieldTypes {
getProject: NexusGenRootTypes['Project']; // Project!
getTrendingPosts: NexusGenRootTypes['Post'][]; // [Post!]!
hottestProjects: NexusGenRootTypes['Project'][]; // [Project!]!
me: NexusGenRootTypes['User'] | null; // User
newProjects: NexusGenRootTypes['Project'][]; // [Project!]!
popularTopics: NexusGenRootTypes['Topic'][]; // [Topic!]!
projectsByCategory: NexusGenRootTypes['Project'][]; // [Project!]!
@@ -475,6 +476,7 @@ export interface NexusGenFieldTypeNames {
getProject: 'Project'
getTrendingPosts: 'Post'
hottestProjects: 'Project'
me: 'User'
newProjects: 'Project'
popularTopics: 'Topic'
projectsByCategory: 'Project'

View File

@@ -145,6 +145,7 @@ type Query {
getProject(id: Int!): Project!
getTrendingPosts: [Post!]!
hottestProjects(skip: Int = 0, take: Int = 50): [Project!]!
me: User
newProjects(skip: Int = 0, take: Int = 50): [Project!]!
popularTopics: [Topic!]!
projectsByCategory(category_id: Int!, skip: Int = 0, take: Int = 10): [Project!]!

View File

@@ -4,7 +4,7 @@ const {
extendType,
nonNull,
} = require('nexus');
const { prisma } = require('../prisma')
const { prisma } = require('../../prisma')
const Category = objectType({

View File

@@ -7,8 +7,8 @@ const {
extendType,
nonNull,
} = require('nexus');
const { BOLT_FUN_LIGHTNING_ADDRESS } = require('../helpers/consts');
const { prisma } = require('../prisma');
const { prisma } = require('../../prisma');
const { CONSTS } = require('../../utils');
const { getPaymetRequestForItem, hexToUint8Array } = require('./helpers');
@@ -45,7 +45,7 @@ const donateMutation = extendType({
resolve: async (_, args) => {
const { amount_in_sat } = args;
const lightning_address = BOLT_FUN_LIGHTNING_ADDRESS;
const lightning_address = CONSTS.BOLT_FUN_LIGHTNING_ADDRESS;
const pr = await getPaymetRequestForItem(lightning_address, args.amount_in_sat);
const invoice = parsePaymentRequest({ request: pr });

View File

@@ -5,7 +5,7 @@ const {
extendType,
nonNull,
} = require('nexus');
const { prisma } = require('../prisma')
const { prisma } = require('../../prisma')

View File

@@ -10,7 +10,7 @@ const {
arg,
} = require('nexus');
const { paginationArgs } = require('./helpers');
const { prisma } = require('../prisma')
const { prisma } = require('../../prisma')
const POST_TYPE = enumType({

View File

@@ -5,7 +5,7 @@ const {
extendType,
nonNull,
} = require('nexus')
const { prisma } = require('../prisma')
const { prisma } = require('../../prisma')
const { paginationArgs, getLnurlDetails, lightningAddressToLnurl } = require('./helpers');

View File

@@ -1,4 +1,7 @@
const { objectType } = require("nexus");
const { objectType, extendType } = require("nexus");
const { getUserByPubKey } = require("../../auth/utils/helperFuncs");
const User = objectType({
name: 'User',
@@ -10,7 +13,23 @@ const User = objectType({
})
const me = extendType({
type: "Query",
definition(t) {
t.field('me', {
type: "User",
async resolve(parent, args, context) {
const user = await getUserByPubKey(context.userPubKey)
return user
}
})
}
})
module.exports = {
// Types
User
User,
// Queries
me,
}

View File

@@ -10,8 +10,9 @@ const {
const { parsePaymentRequest } = require('invoices');
const { getPaymetRequestForItem, hexToUint8Array } = require('./helpers');
const { createHash } = require('crypto');
const { prisma } = require('../prisma');
const { BOLT_FUN_LIGHTNING_ADDRESS } = require('../helpers/consts');
const { prisma } = require('../../prisma');
const { CONSTS } = require('../../utils');
// the types of items we can vote to
@@ -131,7 +132,7 @@ const voteMutation = extendType({
resolve: async (_, args) => {
const { item_id, item_type, amount_in_sat } = args;
const lightning_address = (await getLightningAddress(item_id, item_type)) ?? BOLT_FUN_LIGHTNING_ADDRESS;
const lightning_address = (await getLightningAddress(item_id, item_type)) ?? CONSTS.BOLT_FUN_LIGHTNING_ADDRESS;
const pr = await getPaymetRequestForItem(lightning_address, args.amount_in_sat);
const invoice = parsePaymentRequest({ request: pr });

View File

@@ -1,5 +1,9 @@
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;
module.exports = {
const CONSTS = {
JWT_SECRET,
BOLT_FUN_LIGHTNING_ADDRESS,
}
}
module.exports = CONSTS;

4
functions/utils/index.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
CONSTS: require('./consts')
}

1099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,14 +24,18 @@
"apollo-server-lambda": "^3.6.7",
"axios": "^0.26.1",
"chance": "^1.1.8",
"cookie": "^0.5.0",
"crypto": "^1.0.1",
"dayjs": "^1.11.1",
"env-cmd": "^10.1.0",
"framer-motion": "^6.3.0",
"fslightbox-react": "^1.6.2-2",
"graphql": "^16.3.0",
"invoices": "^2.0.6",
"jose": "^4.8.1",
"linkify-html": "^3.0.5",
"linkifyjs": "^3.0.5",
"lnurl": "^0.24.1",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"marked": "^4.0.14",
@@ -61,6 +65,7 @@
"react-tooltip": "^4.2.21",
"react-topbar-progress-indicator": "^4.1.1",
"remirror": "^1.0.77",
"secp256k1": "^4.0.3",
"typescript": "^4.6.3",
"web-vitals": "^2.1.4",
"webln": "^0.3.0",

View File

@@ -0,0 +1,23 @@
/*
Warnings:
- A unique constraint covering the columns `[pubKey]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email" TEXT,
ADD COLUMN "pubKey" TEXT,
ADD COLUMN "website" TEXT,
ALTER COLUMN "avatar" DROP NOT NULL,
ALTER COLUMN "name" DROP NOT NULL;
-- CreateTable
CREATE TABLE "GeneratedK1" (
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GeneratedK1_pkey" PRIMARY KEY ("value")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_pubKey_key" ON "User"("pubKey");

View File

@@ -36,10 +36,14 @@ model Vote {
// -----------------
model User {
id Int @id @default(autoincrement())
name String @unique
id Int @id @default(autoincrement())
pubKey String? @unique
name String? @unique
email String?
website String?
lightning_address String?
avatar String
avatar String?
stories Story[]
questions Question[]
@@ -199,3 +203,11 @@ model Donation {
donor User? @relation(fields: [donor_id], references: [id])
donor_id Int?
}
// -----------------
// Auth
// -----------------
model GeneratedK1 {
value String @id
createdAt DateTime @default(now())
}

View File

@@ -22,3 +22,25 @@ functions:
path: graphql
method: get
cors: true
login:
handler: functions/auth/login.handler
events:
- http:
path: auth/login
method: post
cors: true
- http:
path: auth/login
method: get
cors: true
logout:
handler: functions/auth/logout.handler
events:
- http:
path: auth/logout
method: post
cors: true
- http:
path: auth/logout
method: get
cors: true

View File

@@ -6,6 +6,7 @@ import { Wallet_Service } from "./services";
import { Navigate, Route, Routes } from "react-router-dom";
import { useWrapperSetup } from "./utils/Wrapper";
import LoadingPage from "./Components/LoadingPage/LoadingPage";
import LoginPage from "./features/Auth/pages/LoginPage";
// Pages
const FeedPage = React.lazy(() => import("./features/Posts/pages/FeedPage/FeedPage"))
@@ -56,6 +57,8 @@ function App() {
<Route path="/donate" element={<DonatePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Navigate to="/products" />} />
</Routes>
</Suspense>

View File

@@ -14,7 +14,7 @@ import {
ControlledMenu,
} from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import { FiAward, FiChevronDown, FiFeather, FiMic } from "react-icons/fi";
import { FiAward, FiChevronDown, FiFeather, FiLogIn, FiMic } from "react-icons/fi";
export default function NavDesktop() {
@@ -137,6 +137,7 @@ export default function NavDesktop() {
</ul>
<div className="ml-auto"></div>
<motion.div
animate={searchOpen ? { opacity: 0 } : { opacity: 1 }}
className="flex"
@@ -160,7 +161,9 @@ export default function NavDesktop() {
<BsSearch className='scale-125 text-gray-400' />
</IconButton>}
</motion.div>
<Link to='/login' className="ml-16 font-bold hover:text-primary-800 hover:underline">
Login <FiLogIn />
</Link>
<div className="relative h-24">
<motion.div
initial={{

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react"
import { BsFillLightningChargeFill } from "react-icons/bs";
import { Grid } from "react-loader-spinner";
import { useMeQuery } from "src/graphql"
export default function LoginPage() {
const [loadingLnurl, setLoadingLnurl] = useState(true)
const [lnurlAuth, setLnurlAuth] = useState("");
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isPollilng, setIsPollilng] = useState(false)
const meQuery = useMeQuery({
onCompleted: (data) => {
const stateChanged = Boolean(data.me) !== isLoggedIn;
if (stateChanged)
setIsPollilng(false);
setIsLoggedIn(Boolean(data.me));
}
})
useEffect(() => {
(async () => {
const res = await fetch(process.env.REACT_APP_AUTH_END_POINT! + '/login')
const data = await res.json()
setLoadingLnurl(false);
setLnurlAuth(data.encoded)
})()
}, [])
const { startPolling, stopPolling } = meQuery;
useEffect(() => {
if (isPollilng)
startPolling(1500);
else
stopPolling();
}, [isPollilng, startPolling, stopPolling])
const onLogin = () => {
setIsPollilng(true);
}
const logout = async () => {
await fetch(process.env.REACT_APP_AUTH_END_POINT! + '/logout', {
method: "GET",
'credentials': "include"
})
setIsPollilng(true);
}
return (
<div className="min-h-[80vh] flex flex-col justify-center items-center">
{loadingLnurl && <div className="flex flex-col gap-24 items-center">
<Grid color="var(--primary)" width="150" />
<p className="text-body3 font-bold">Fetching Lnurl-Auth...</p>
</div>}
{!loadingLnurl &&
(isLoggedIn ?
<div className="flex flex-col justify-center items-center">
<h3 className="text-body4">
Hello: <span className="font-bold">@{meQuery.data?.me?.name.slice(0, 10)}...</span>
</h3>
<img src={meQuery.data?.me?.avatar} className='w-80 h-80 object-cover' alt="" />
<button
onClick={logout}
className="text-black font-bolder bg-gray-200 rounded-12 px-16 py-12 mt-36 active:scale-90 transition-transform">
{isPollilng ? "Logging you out..." : "Logout"}
</button>
</div> :
<a
href={lnurlAuth}
onClick={onLogin}
className='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" />
</a>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
query Me {
me {
id
name
avatar
}
}

View File

@@ -193,6 +193,7 @@ export type Query = {
getProject: Project;
getTrendingPosts: Array<Post>;
hottestProjects: Array<Project>;
me: Maybe<User>;
newProjects: Array<Project>;
popularTopics: Array<Topic>;
projectsByCategory: Array<Project>;
@@ -350,6 +351,11 @@ export type SearchProjectsQueryVariables = Exact<{
export type SearchProjectsQuery = { __typename?: 'Query', searchProjects: Array<{ __typename?: 'Project', id: number, thumbnail_image: string, title: string, category: { __typename?: 'Category', title: string, id: number } }> };
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = { __typename?: 'Query', me: { __typename?: 'User', id: number, name: string, avatar: string } | null };
export type DonationsStatsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -536,6 +542,42 @@ export function useSearchProjectsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpt
export type SearchProjectsQueryHookResult = ReturnType<typeof useSearchProjectsQuery>;
export type SearchProjectsLazyQueryHookResult = ReturnType<typeof useSearchProjectsLazyQuery>;
export type SearchProjectsQueryResult = Apollo.QueryResult<SearchProjectsQuery, SearchProjectsQueryVariables>;
export const MeDocument = gql`
query Me {
me {
id
name
avatar
}
}
`;
/**
* __useMeQuery__
*
* To run a query within a React component, call `useMeQuery` and pass it any options that fit your needs.
* When your component renders, `useMeQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useMeQuery({
* variables: {
* },
* });
*/
export function useMeQuery(baseOptions?: Apollo.QueryHookOptions<MeQuery, MeQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export function useMeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MeQuery, MeQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
export const DonationsStatsDocument = gql`
query DonationsStats {
getDonationsStats {

View File

@@ -4,11 +4,12 @@ import { RetryLink } from "@apollo/client/link/retry";
let apiClientUri = '/.netlify/functions/graphql';
if (process.env.REACT_APP_API_END_POINT)
apiClientUri = process.env.REACT_APP_API_END_POINT
if (process.env.REACT_APP_GRAPHQL_END_POINT)
apiClientUri = process.env.REACT_APP_GRAPHQL_END_POINT
const httpLink = new HttpLink({
uri: apiClientUri
uri: apiClientUri,
credentials: 'include'
});
const errorLink = onError(({ graphQLErrors, networkError }) => {