slashtags auth

This commit is contained in:
keyan 2023-01-18 12:49:20 -06:00
parent 48448ea1ef
commit 9644a9f867
25 changed files with 1257 additions and 150 deletions

View File

@ -39,6 +39,9 @@ export default {
LnAuth: {
encodedUrl: async (lnAuth, args, { models }) => {
return encodedUrl(process.env.LNAUTH_URL, 'login', lnAuth.k1)
},
slashtagUrl: async (lnAuth, args, { models, slashtags }) => {
return slashtags.formatURL(lnAuth.k1)
}
},
LnWith: {

View File

@ -54,7 +54,8 @@ async function authMethods (user, args, { models, me }) {
lightning: !!user.pubkey,
email: user.emailVerified && user.email,
twitter: oauth.indexOf('twitter') >= 0,
github: oauth.indexOf('github') >= 0
github: oauth.indexOf('github') >= 0,
slashtags: !!user.slashtagId
}
}
@ -402,27 +403,25 @@ export default {
throw new AuthenticationError('you must be logged in')
}
let user
if (authType === 'twitter' || authType === 'github') {
const user = await models.user.findUnique({ where: { id: me.id } })
user = await models.user.findUnique({ where: { id: me.id } })
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
if (!account) {
throw new UserInputError('no such account')
}
await models.account.delete({ where: { id: account.id } })
return await authMethods(user, undefined, { models, me })
} else if (authType === 'lightning') {
user = await models.user.update({ where: { id: me.id }, data: { pubkey: null } })
} else if (authType === 'slashtags') {
user = await models.user.update({ where: { id: me.id }, data: { slashtagId: null } })
} else if (authType === 'email') {
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
} else {
throw new UserInputError('no such account')
}
if (authType === 'lightning') {
const user = await models.user.update({ where: { id: me.id }, data: { pubkey: null } })
return await authMethods(user, undefined, { models, me })
}
if (authType === 'email') {
const user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
return await authMethods(user, undefined, { models, me })
}
throw new UserInputError('no such account')
return await authMethods(user, undefined, { models, me })
},
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
if (!me) {

61
api/slashtags/index.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@ import { getSession } from 'next-auth/client'
import resolvers from './resolvers'
import typeDefs from './typeDefs'
import models from './models'
import slashtags from './slashtags'
import { print } from 'graphql'
import lnd from './lnd'
import search from './search'
@ -26,7 +27,8 @@ export default async function getSSRApolloClient (req, me = null) {
? session.user
: me,
lnd,
search
search,
slashtags
}
}),
cache: new InMemoryCache()

View File

@ -17,6 +17,7 @@ export default gql`
k1: String!
pubkey: String
encodedUrl: String!
slashtagUrl: String!
}
type LnWith {

View File

@ -32,9 +32,10 @@ export default gql`
type AuthMethods {
lightning: Boolean!
email: String
twitter: Boolean!
slashtags: Boolean!
github: Boolean!
twitter: Boolean!
email: String
}
type User {

View File

@ -1,4 +1,4 @@
import LnQR from './lnqr'
import Qr from './qr'
export function Invoice ({ invoice }) {
let variant = 'default'
@ -14,5 +14,5 @@ export function Invoice ({ invoice }) {
status = 'expired'
}
return <LnQR webLn value={invoice.bolt11} statusVariant={variant} status={status} />
return <Qr webLn value={invoice.bolt11} statusVariant={variant} status={status} />
}

View File

@ -73,7 +73,6 @@ function ItemEmbed ({ item }) {
const youtube = item.url?.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i)
if (youtube?.groups?.id) {
console.log(youtube?.groups?.start)
return (
<div style={{ maxWidth: '640px', paddingRight: '15px' }}>
<YouTube

View File

@ -3,12 +3,12 @@ import { signIn } from 'next-auth/client'
import { useEffect } from 'react'
import { Col, Container, Row } from 'react-bootstrap'
import AccordianItem from './accordian-item'
import LnQR, { LnQRSkeleton } from './lnqr'
import Qr, { QrSkeleton } from './qr'
import styles from './lightning-auth.module.css'
import BackIcon from '../svgs/arrow-left-line.svg'
import { useRouter } from 'next/router'
function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) {
const query = gql`
{
lnAuth(k1: "${k1}") {
@ -19,12 +19,12 @@ function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
const { data } = useQuery(query, { pollInterval: 1000 })
if (data && data.lnAuth.pubkey) {
signIn('credentials', { ...data.lnAuth, callbackUrl })
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })
}
// output pubkey and k1
return (
<LnQR value={encodedUrl} status='waiting for you' />
<Qr value={encodedUrl || slashtagUrl} asIs={!!slashtagUrl} status='waiting for you' />
)
}
@ -84,7 +84,15 @@ function LightningExplainer ({ text, children }) {
)
}
export function LightningAuth ({ text, callbackUrl }) {
export function LightningAuthWithExplainer ({ text, callbackUrl }) {
return (
<LightningExplainer text={text}>
<LightningAuth callbackUrl={callbackUrl} />
</LightningExplainer>
)
}
export function LightningAuth ({ callbackUrl }) {
// query for challenge
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
@ -100,9 +108,24 @@ export function LightningAuth ({ text, callbackUrl }) {
if (error) return <div>error</div>
return (
<LightningExplainer text={text}>
{data ? <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <LnQRSkeleton status='generating' />}
</LightningExplainer>
)
return data ? <QrAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <QrSkeleton status='generating' />
}
export function SlashtagsAuth ({ callbackUrl }) {
// query for challenge
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
createAuth {
k1
slashtagUrl
}
}`)
useEffect(() => {
createAuth()
}, [])
if (error) return <div>error</div>
return data ? <QrAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <QrSkeleton status='generating' />
}

View File

@ -1,7 +1,9 @@
import GithubIcon from '../svgs/github-fill.svg'
import TwitterIcon from '../svgs/twitter-fill.svg'
import LightningIcon from '../svgs/bolt.svg'
import SlashtagsIcon from '../svgs/slashtags.svg'
import { Button } from 'react-bootstrap'
export default function LoginButton ({ text, type, className, onClick }) {
let Icon, variant
switch (type) {
@ -17,6 +19,10 @@ export default function LoginButton ({ text, type, className, onClick }) {
Icon = LightningIcon
variant = 'primary'
break
case 'slashtags':
Icon = SlashtagsIcon
variant = 'grey-medium'
break
}
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()
@ -25,7 +31,7 @@ export default function LoginButton ({ text, type, className, onClick }) {
<Button className={className} variant={variant} onClick={onClick}>
<Icon
width={20}
height={20} className='mr-2'
height={20} className='mr-3'
/>
{text} {name}
</Button>

View File

@ -1,15 +1,12 @@
import { signIn } from 'next-auth/client'
import Button from 'react-bootstrap/Button'
import styles from './login.module.css'
import GithubIcon from '../svgs/github-fill.svg'
import TwitterIcon from '../svgs/twitter-fill.svg'
import LightningIcon from '../svgs/bolt.svg'
import { Form, Input, SubmitButton } from '../components/form'
import * as Yup from 'yup'
import { useState } from 'react'
import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router'
import { LightningAuth } from './lightning-auth'
import { LightningAuthWithExplainer, SlashtagsAuth } from './lightning-auth'
import LoginButton from './login-button'
export const EmailSchema = Yup.object({
email: Yup.string().email('email is no good').required('required')
@ -48,7 +45,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
Callback: 'Try signing with a different account.',
OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.',
EmailSignin: 'Check your email address.',
CredentialsSignin: 'Lightning auth failed.',
CredentialsSignin: 'Auth failed',
default: 'Unable to sign in.'
}
@ -56,7 +53,11 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
const router = useRouter()
if (router.query.type === 'lightning') {
return <LightningAuth callbackUrl={callbackUrl} text={text} />
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} />
}
if (router.query.type === 'slashtags') {
return <SlashtagsAuth callbackUrl={callbackUrl} text={text} />
}
return (
@ -69,44 +70,41 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
dismissible
>{errorMessage}
</Alert>}
<Button
className={`mt-2 ${styles.providerButton}`}
variant='primary'
onClick={() => router.push({
pathname: router.pathname,
query: { ...router.query, type: 'lightning' }
})}
>
<LightningIcon
width={20}
height={20}
className='mr-3'
/>{text || 'Login'} with Lightning
</Button>
{providers && Object.values(providers).map(provider => {
if (provider.name === 'Email' || provider.name === 'Lightning') {
return null
switch (provider.name) {
case 'Email':
return (
<div className='w-100' key={provider.name}>
<div className='mt-2 text-center text-muted font-weight-bold'>or</div>
<EmailLoginForm text={text} callbackUrl={callbackUrl} />
</div>
)
case 'Lightning':
case 'Slashtags':
return (
<LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.name}
type={provider.name.toLowerCase()}
onClick={() => router.push({
pathname: router.pathname,
query: { ...router.query, type: provider.name.toLowerCase() }
})}
text={`${text || 'Login'} with`}
/>
)
default:
return (
<LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.name}
type={provider.name.toLowerCase()}
onClick={() => signIn(provider.id, { callbackUrl })}
text={`${text || 'Login'} with`}
/>
)
}
const [variant, Icon] =
provider.name === 'Twitter'
? ['twitter', TwitterIcon]
: ['dark', GithubIcon]
return (
<Button
className={`mt-2 ${styles.providerButton}`}
key={provider.name}
variant={variant}
onClick={() => signIn(provider.id, { callbackUrl })}
>
<Icon
className='mr-3'
/>{text || 'Login'} with {provider.name}
</Button>
)
})}
<div className='mt-2 text-center text-muted font-weight-bold'>or</div>
<EmailLoginForm text={text} callbackUrl={callbackUrl} />
{Footer && <Footer />}
</div>
)

View File

@ -4,8 +4,8 @@ import InvoiceStatus from './invoice-status'
import { requestProvider } from 'webln'
import { useEffect } from 'react'
export default function LnQR ({ value, webLn, statusVariant, status }) {
const qrValue = 'lightning:' + value.toUpperCase()
export default function Qr ({ asIs, value, webLn, statusVariant, status }) {
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
useEffect(() => {
async function effect () {
@ -36,7 +36,7 @@ export default function LnQR ({ value, webLn, statusVariant, status }) {
)
}
export function LnQRSkeleton ({ status }) {
export function QrSkeleton ({ status }) {
return (
<>
<div className='h-auto w-100 clouds' style={{ paddingTop: 'min(300px + 2rem, 100%)', maxWidth: 'calc(300px + 2rem)' }} />

View File

@ -52,9 +52,10 @@ export const SETTINGS_FIELDS = gql`
greeterMode
authMethods {
lightning
email
twitter
slashtags
github
twitter
email
}
}`

930
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,8 @@
"@lexical/react": "^0.7.5",
"@opensearch-project/opensearch": "^1.1.0",
"@prisma/client": "^2.30.3",
"@synonymdev/slashtags-auth": "^1.0.0-alpha.5",
"@synonymdev/slashtags-sdk": "^1.0.0-alpha.36",
"apollo-server-micro": "^3.11.1",
"async-retry": "^1.3.1",
"aws-sdk": "^2.1248.0",

View File

@ -62,7 +62,8 @@ export default (req, res) => NextAuth(req, res, {
},
providers: [
Providers.Credentials({
// The name to display on the sign in form (e.g. 'Sign in with...')
id: 'lightning',
// The name to display on the sign in form (e.g. 'Sign in with...')
name: 'Lightning',
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
@ -75,6 +76,7 @@ export default (req, res) => NextAuth(req, res, {
const { k1, pubkey } = credentials
try {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
await prisma.lnAuth.delete({ where: { k1 } })
if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { pubkey } })
const session = await getSession({ req })
@ -89,7 +91,45 @@ export default (req, res) => NextAuth(req, res, {
throw new Error('account not linked')
}
await prisma.lnAuth.delete({ where: { k1 } })
return user
}
} catch (error) {
console.log(error)
}
return null
}
}),
Providers.Credentials({
id: 'slashtags',
// The name to display on the sign in form (e.g. 'Sign in with...')
name: 'Slashtags',
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
// e.g. domain, username, password, 2FA token, etc.
credentials: {
pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', type: 'text' }
},
async authorize (credentials, req) {
const { k1, pubkey } = credentials
try {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
await prisma.lnAuth.delete({ where: { k1 } })
if (lnauth.pubkey === pubkey) {
let user = await prisma.user.findUnique({ where: { slashtagId: pubkey } })
const session = await getSession({ req })
if (!user) {
// if we are logged in, update rather than create
if (session?.user) {
user = await prisma.user.update({ where: { id: session.user.id }, data: { slashtagId: pubkey } })
} else {
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), slashtagId: pubkey } })
}
} else if (session && session.user?.id !== user.id) {
throw new Error('account not linked')
}
return user
}
} catch (error) {

View File

@ -5,6 +5,7 @@ import lnd from '../../api/lnd'
import typeDefs from '../../api/typeDefs'
import { getSession } from 'next-auth/client'
import search from '../../api/search'
import slashtags from '../../api/slashtags'
const apolloServer = new ApolloServer({
typeDefs,
@ -40,7 +41,8 @@ const apolloServer = new ApolloServer({
me: session
? session.user
: null,
search
search,
slashtags
}
}
})

View File

@ -4,6 +4,8 @@
import secp256k1 from 'secp256k1'
import models from '../../api/models'
const HOUR = 1000 * 60 * 60
export default async ({ query }, res) => {
try {
const sig = Buffer.from(query.sig, 'hex')
@ -11,6 +13,11 @@ export default async ({ query }, res) => {
const key = Buffer.from(query.key, 'hex')
const signature = secp256k1.signatureImport(sig)
if (secp256k1.ecdsaVerify(signature, k1, key)) {
const auth = await models.lnAuth.findUnique({ where: { k1: query.k1 } })
if (!auth || auth.pubkey || auth.createdAt < Date.now() - HOUR) {
return res.status(400).json({ status: 'ERROR', reason: 'token expired' })
}
await models.lnAuth.update({ where: { k1: query.k1 }, data: { pubkey: query.key } })
return res.status(200).json({ status: 'OK' })
}

View File

@ -1,6 +1,6 @@
import { useQuery } from '@apollo/client'
import { Invoice } from '../../components/invoice'
import { LnQRSkeleton } from '../../components/lnqr'
import { QrSkeleton } from '../../components/qr'
import LayoutCenter from '../../components/layout-center'
import { useRouter } from 'next/router'
import { INVOICE } from '../../fragments/wallet'
@ -24,7 +24,7 @@ function LoadInvoice () {
return <div>error</div>
}
if (!data || loading) {
return <LnQRSkeleton status='loading' />
return <QrSkeleton status='loading' />
}
return <Invoice invoice={data.invoice} />

View File

@ -8,7 +8,7 @@ import { getGetServerSideProps } from '../api/ssrApollo'
import LoginButton from '../components/login-button'
import { signIn } from 'next-auth/client'
import ModalButton from '../components/modal-button'
import { LightningAuth } from '../components/lightning-auth'
import { LightningAuth, SlashtagsAuth } from '../components/lightning-auth'
import { SETTINGS, SET_SETTINGS } from '../fragments/users'
import { useRouter } from 'next/router'
import Info from '../components/info'
@ -300,14 +300,11 @@ function AuthMethods ({ methods }) {
)
const [obstacle, setObstacle] = useState()
const providers = Object.keys(methods).filter(k => k !== '__typename')
const unlink = async type => {
// if there's only one auth method left
let links = 0
links += methods.lightning ? 1 : 0
links += methods.email ? 1 : 0
links += methods.twitter ? 1 : 0
links += methods.github ? 1 : 0
const links = providers.reduce((t, p) => t + (methods[p] ? 1 : 0), 0)
if (links === 1) {
setObstacle(type)
} else {
@ -349,63 +346,78 @@ function AuthMethods ({ methods }) {
</Modal.Body>
</Modal>
<div className='form-label mt-3'>auth methods</div>
{methods.lightning
? <LoginButton
className='d-block' type='lightning' text='Unlink' onClick={
async () => {
await unlink('lightning')
}
{providers && providers.map(provider => {
switch (provider) {
case 'email':
return methods.email
? (
<div className='mt-2 d-flex align-items-center'>
<Input
name='email'
placeholder={methods.email}
groupClassName='mb-0'
readOnly
noForm
/>
<Button
className='ml-2' variant='secondary' onClick={
async () => {
await unlink('email')
}
}
>Unlink Email
</Button>
</div>
)
: <div className='mt-2'><EmailLinkForm /></div>
case 'lightning':
return methods.lightning
? <LoginButton
className='d-block' type='lightning' text='Unlink' onClick={
async () => {
await unlink('lightning')
}
}
/>
: (
<ModalButton clicker={<LoginButton className='d-block' type='lightning' text='Link' />}>
<div className='d-flex flex-column align-items-center'>
<LightningAuth />
</div>
</ModalButton>)
case 'slashtags':
return methods.slashtags
? <LoginButton
className='d-block mt-2' type='slashtags' text='Unlink' onClick={
async () => {
await unlink('slashtags')
}
}
/>
: (
<ModalButton clicker={<LoginButton className='d-block mt-2' type='slashtags' text='Link' />}>
<div className='d-flex flex-column align-items-center'>
<SlashtagsAuth />
</div>
</ModalButton>)
default:
return (
<LoginButton
className='mt-2 d-block'
key={provider}
type={provider.toLowerCase()}
onClick={async () => {
if (methods[provider]) {
await unlink(provider)
} else {
signIn(provider)
}
}}
text={methods[provider] ? 'Unlink' : 'Link'}
/>
)
}
/>
: (
<ModalButton clicker={<LoginButton className='d-block' type='lightning' text='Link' />}>
<div className='d-flex flex-column align-items-center'>
<LightningAuth />
</div>
</ModalButton>)}
<LoginButton
className='d-block mt-2' type='twitter' text={methods.twitter ? 'Unlink' : 'Link'} onClick={
async () => {
if (methods.twitter) {
await unlink('twitter')
} else {
signIn('twitter')
}
}
}
/>
<LoginButton
className='d-block mt-2' type='github' text={methods.github ? 'Unlink' : 'Link'} onClick={
async () => {
if (methods.github) {
await unlink('github')
} else {
signIn('github')
}
}
}
/>
{methods.email
? (
<div className='mt-2 d-flex align-items-center'>
<Input
name='email'
placeholder={methods.email}
groupClassName='mb-0'
readOnly
noForm
/>
<Button
className='ml-2' variant='secondary' onClick={
async () => {
await unlink('email')
}
}
>Unlink Email
</Button>
</div>
)
: <div className='mt-2'><EmailLinkForm /></div>}
})}
</>
)
}

View File

@ -4,7 +4,7 @@ import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import * as Yup from 'yup'
import { gql, useMutation, useQuery } from '@apollo/client'
import LnQR, { LnQRSkeleton } from '../components/lnqr'
import Qr, { QrSkeleton } from '../components/qr'
import LayoutCenter from '../components/layout-center'
import InputGroup from 'react-bootstrap/InputGroup'
import { WithdrawlSkeleton } from './withdrawals/[id]'
@ -95,7 +95,7 @@ export function FundForm () {
}, [])
if (called && !error) {
return <LnQRSkeleton status='generating' />
return <QrSkeleton status='generating' />
}
return (
@ -226,7 +226,7 @@ function LnQRWith ({ k1, encodedUrl }) {
router.push(`/withdrawals/${data.lnWith.withdrawalId}`)
}
return <LnQR value={encodedUrl} status='waiting for you' />
return <Qr value={encodedUrl} status='waiting for you' />
}
export function LnWithdrawal () {
@ -246,7 +246,7 @@ export function LnWithdrawal () {
if (error) return <div>error</div>
if (!data) {
return <LnQRSkeleton status='generating' />
return <QrSkeleton status='generating' />
}
return <LnQRWith {...data.createWith} />

View File

@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[slashtagId]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "users" ADD COLUMN "slashtagId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "users.slashtagId_unique" ON "users"("slashtagId");

View File

@ -37,6 +37,7 @@ model User {
checkedNotesAt DateTime?
fiatCurrency String @default("USD")
pubkey String? @unique
slashtagId String? @unique
trust Float @default(0)
upvoteTrust Float @default(0)
lastSeenAt DateTime?

View File

@ -206,6 +206,10 @@ div[contenteditable]:disabled,
fill: #212529;
}
.btn-grey-medium svg {
fill: #212529;
}
.fresh {
background-color: var(--theme-clickToContextColor);
border-radius: .4rem;

4
svgs/slashtags.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70 88.9103V70.1764L130 54V72.7339L70 88.9103ZM130 100.355L70 116.532V97.6448L130 81.4685V100.355ZM130 127.824L70 144V125.266L130 109.09V127.824Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M110.525 0.563842C165.505 6.37341 205.246 55.6284 199.436 110.525C193.627 165.505 144.372 205.246 89.4754 199.436C34.4951 193.627 -5.24572 144.372 0.563842 89.4754C6.37341 34.4951 55.6284 -5.24572 110.525 0.563842ZM108.335 21.4446C65.1426 16.8138 25.9912 48.4717 21.4446 91.6645C16.8138 134.857 48.4717 174.009 91.6645 178.555C134.857 183.186 174.009 151.528 178.555 108.335C183.186 65.1426 151.528 25.9912 108.335 21.4446Z"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B