Add nostr login (#367)
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
parent
4586fd7f70
commit
7369bd819d
|
@ -80,7 +80,8 @@ async function authMethods (user, args, { models, me }) {
|
|||
email: user.emailVerified && user.email,
|
||||
twitter: oauth.indexOf('twitter') >= 0,
|
||||
github: oauth.indexOf('github') >= 0,
|
||||
slashtags: !!user.slashtagId
|
||||
slashtags: !!user.slashtagId,
|
||||
nostr: !!user.nostrAuthPubkey
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -527,6 +528,8 @@ export default {
|
|||
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 === 'nostr') {
|
||||
user = await models.user.update({ where: { id: me.id }, data: { nostrAuthPubkey: null } })
|
||||
} else if (authType === 'email') {
|
||||
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
|
||||
} else {
|
||||
|
|
|
@ -35,6 +35,7 @@ export default gql`
|
|||
type AuthMethods {
|
||||
lightning: Boolean!
|
||||
slashtags: Boolean!
|
||||
nostr: Boolean!
|
||||
github: Boolean!
|
||||
twitter: Boolean!
|
||||
email: String
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
.login {
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
min-height: 600px;
|
||||
}
|
|
@ -2,6 +2,7 @@ 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 NostrIcon from '../svgs/nostr.svg'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
|
||||
export default function LoginButton ({ text, type, className, onClick }) {
|
||||
|
@ -19,6 +20,10 @@ export default function LoginButton ({ text, type, className, onClick }) {
|
|||
Icon = SlashtagsIcon
|
||||
variant = 'grey-medium'
|
||||
break
|
||||
case 'nostr':
|
||||
Icon = NostrIcon
|
||||
variant = 'nostr'
|
||||
break
|
||||
case 'lightning':
|
||||
default:
|
||||
Icon = LightningIcon
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useState } from 'react'
|
|||
import Alert from 'react-bootstrap/Alert'
|
||||
import { useRouter } from 'next/router'
|
||||
import { LightningAuthWithExplainer, SlashtagsAuth } from './lightning-auth'
|
||||
import NostrAuth from './nostr-auth'
|
||||
import LoginButton from './login-button'
|
||||
import { emailSchema } from '../lib/validate'
|
||||
|
||||
|
@ -59,6 +60,10 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
|
|||
return <SlashtagsAuth callbackUrl={callbackUrl} text={text} />
|
||||
}
|
||||
|
||||
if (router.query.type === 'nostr') {
|
||||
return <NostrAuth callbackUrl={callbackUrl} text={text} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.login}>
|
||||
{Header && <Header />}
|
||||
|
@ -80,6 +85,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
|
|||
)
|
||||
case 'Lightning':
|
||||
case 'Slashtags':
|
||||
case 'Nostr':
|
||||
return (
|
||||
<LoginButton
|
||||
className={`mt-2 ${styles.providerButton}`}
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import Container from 'react-bootstrap/Container'
|
||||
import Col from 'react-bootstrap/Col'
|
||||
import Row from 'react-bootstrap/Row'
|
||||
import { useRouter } from 'next/router'
|
||||
import AccordianItem from './accordian-item'
|
||||
import BackIcon from '../svgs/arrow-left-line.svg'
|
||||
import styles from './lightning-auth.module.css'
|
||||
|
||||
function ExtensionError ({ message, details }) {
|
||||
return (
|
||||
<>
|
||||
<h4 className='fw-bold text-danger pb-1'>error: {message}</h4>
|
||||
<div className='text-muted pb-4'>{details}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NostrExplainer ({ text }) {
|
||||
return (
|
||||
<>
|
||||
<ExtensionError message='nostr extension not found' details='Nostr extensions are the safest way to use your nostr identity on Stacker News.' />
|
||||
<Row className='w-100 text-muted'>
|
||||
<AccordianItem
|
||||
header={`Which extensions can I use to ${(text || 'Login').toLowerCase()} with Nostr?`}
|
||||
show
|
||||
body={
|
||||
<>
|
||||
<Row>
|
||||
<Col>
|
||||
<ul>
|
||||
<li>
|
||||
<a href='https://getalby.com'>Alby</a><br />
|
||||
available for: chrome, firefox, and safari
|
||||
</li>
|
||||
<li>
|
||||
<a href='https://www.getflamingo.org/'>Flamingo</a><br />
|
||||
available for: chrome
|
||||
</li>
|
||||
<li>
|
||||
<a href='https://github.com/fiatjaf/nos2x'>nos2x</a><br />
|
||||
available for: chrome
|
||||
</li>
|
||||
<li>
|
||||
<a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a><br />
|
||||
available for: firefox
|
||||
</li>
|
||||
<li>
|
||||
<a href='https://github.com/fiatjaf/horse'>horse</a><br />
|
||||
available for: chrome<br />
|
||||
supports hardware signing
|
||||
</li>
|
||||
</ul>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function NostrAuth ({ text, callbackUrl }) {
|
||||
const [createAuth, { data, error }] = useMutation(gql`
|
||||
mutation createAuth {
|
||||
createAuth {
|
||||
k1
|
||||
}
|
||||
}`)
|
||||
const [hasExtension, setHasExtension] = useState(undefined)
|
||||
const [extensionError, setExtensionError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
createAuth()
|
||||
}, [])
|
||||
|
||||
const k1 = data?.createAuth.k1
|
||||
|
||||
useEffect(() => {
|
||||
if (!k1) return
|
||||
setHasExtension(!!window.nostr)
|
||||
if (!window.nostr) {
|
||||
const err = { message: 'nostr extension not found' }
|
||||
console.error(err.message)
|
||||
return
|
||||
}
|
||||
console.info('nostr extension detected')
|
||||
let mounted = true;
|
||||
(async function () {
|
||||
try {
|
||||
// have them sign a message with the challenge
|
||||
let event
|
||||
try {
|
||||
event = await window.nostr.signEvent({
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['challenge', k1]],
|
||||
content: 'Stacker News Authentication'
|
||||
})
|
||||
if (!event) throw new Error('extension returned empty event')
|
||||
} catch (e) {
|
||||
if (e.message === 'window.nostr call already executing') return
|
||||
setExtensionError({ message: 'nostr extension failed to sign event', details: e.message })
|
||||
return
|
||||
}
|
||||
|
||||
// sign them in
|
||||
try {
|
||||
const { error, ok } = await signIn('nostr', {
|
||||
event: JSON.stringify(event),
|
||||
callbackUrl
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error)
|
||||
}
|
||||
if (!ok) {
|
||||
throw new Error('auth failed')
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('authorization failed', e)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return
|
||||
setExtensionError({ message: `${text} failed`, details: e.message })
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [k1, hasExtension])
|
||||
|
||||
if (error) return <div>error</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasExtension === false && <NostrExplainer text={text} />}
|
||||
{extensionError && <ExtensionError {...extensionError} />}
|
||||
{hasExtension && !extensionError &&
|
||||
<>
|
||||
<h4 className='fw-bold text-success pb-1'>nostr extension found</h4>
|
||||
<h6 className='text-muted pb-4'>authorize event signature in extension</h6>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NostrAuthWithExplainer ({ text, callbackUrl }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.login}>
|
||||
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
|
||||
<h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3>
|
||||
<NostrAuth text={text} callbackUrl={callbackUrl} />
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -58,6 +58,7 @@ export const SETTINGS_FIELDS = gql`
|
|||
authMethods {
|
||||
lightning
|
||||
slashtags
|
||||
nostr
|
||||
github
|
||||
twitter
|
||||
email
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import NextAuth from 'next-auth'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
|
@ -9,6 +10,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter'
|
|||
import { decode, getToken } from 'next-auth/jwt'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import jose1 from 'jose1'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
|
||||
function getCallbacks (req) {
|
||||
return {
|
||||
|
@ -102,6 +104,37 @@ async function pubkeyAuth (credentials, req, pubkeyColumnName) {
|
|||
return null
|
||||
}
|
||||
|
||||
async function nostrEventAuth (event) {
|
||||
// parse event
|
||||
const e = JSON.parse(event)
|
||||
|
||||
// is the event id a hash of this event
|
||||
const id = createHash('sha256').update(
|
||||
JSON.stringify(
|
||||
[0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
|
||||
)
|
||||
).digest('hex')
|
||||
if (id !== e.id) {
|
||||
throw new Error('invalid event id')
|
||||
}
|
||||
|
||||
// is the signature valid
|
||||
if (!(await schnorr.verify(e.sig, e.id, e.pubkey))) {
|
||||
throw new Error('invalid signature')
|
||||
}
|
||||
|
||||
// is the challenge present in the event
|
||||
if (!(e.tags[0].length === 2 && e.tags[0][0] === 'challenge')) {
|
||||
throw new Error('expected tags = [["challenge", <challenge>]]')
|
||||
}
|
||||
|
||||
const pubkey = e.pubkey
|
||||
const k1 = e.tags[0][1]
|
||||
await prisma.lnAuth.update({ data: { pubkey }, where: { k1 } })
|
||||
|
||||
return { k1, pubkey }
|
||||
}
|
||||
|
||||
const providers = [
|
||||
CredentialsProvider({
|
||||
id: 'lightning',
|
||||
|
@ -112,6 +145,17 @@ const providers = [
|
|||
},
|
||||
authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey')
|
||||
}),
|
||||
CredentialsProvider({
|
||||
id: 'nostr',
|
||||
name: 'Nostr',
|
||||
credentials: {
|
||||
event: { label: 'event', type: 'text' }
|
||||
},
|
||||
authorize: async ({ event }, req) => {
|
||||
const credentials = await nostrEventAuth(event)
|
||||
return pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey')
|
||||
}
|
||||
}),
|
||||
CredentialsProvider({
|
||||
id: 'slashtags',
|
||||
name: 'Slashtags',
|
||||
|
|
|
@ -21,6 +21,7 @@ import { SUPPORTED_CURRENCIES } from '../lib/currency'
|
|||
import PageLoading from '../components/page-loading'
|
||||
import { useShowModal } from '../components/modal'
|
||||
import { authErrorMessage } from '../components/login'
|
||||
import { NostrAuth } from '../components/nostr-auth'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(SETTINGS)
|
||||
|
||||
|
@ -297,6 +298,23 @@ function QRLinkButton ({ provider, unlink, status }) {
|
|||
)
|
||||
}
|
||||
|
||||
function NostrLinkButton ({ unlink, status }) {
|
||||
const showModal = useShowModal()
|
||||
const text = status ? 'Unlink' : 'Link'
|
||||
const onClick = status
|
||||
? unlink
|
||||
: () => showModal(onClose =>
|
||||
<div className='d-flex flex-column align-items-center'>
|
||||
<NostrAuth text='Unlink' />
|
||||
</div>)
|
||||
|
||||
return (
|
||||
<LoginButton
|
||||
className='d-block mt-2' type='nostr' text={text} onClick={onClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
|
||||
const router = useRouter()
|
||||
|
||||
|
@ -341,6 +359,7 @@ function AuthMethods ({ methods }) {
|
|||
email
|
||||
twitter
|
||||
github
|
||||
nostr
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { unlinkAuth } }) {
|
||||
|
@ -416,6 +435,8 @@ function AuthMethods ({ methods }) {
|
|||
status={methods[provider]} unlink={async () => await unlink(provider)}
|
||||
/>
|
||||
)
|
||||
} else if (provider === 'nostr') {
|
||||
return <NostrLinkButton key='nostr' status={methods[provider]} unlink={async () => await unlink(provider)} />
|
||||
} else {
|
||||
return (
|
||||
<LoginButton
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[nostrAuthPubkey]` on the table `users` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "nostrAuthPubkey" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users.nostrAuthPubkey_unique" ON "users"("nostrAuthPubkey");
|
|
@ -51,6 +51,7 @@ model User {
|
|||
turboTipping Boolean @default(false)
|
||||
referrerId Int?
|
||||
nostrPubkey String?
|
||||
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
|
||||
slashtagId String? @unique(map: "users.slashtagId_unique")
|
||||
noteCowboyHat Boolean @default(true)
|
||||
streak Int?
|
||||
|
|
|
@ -7,6 +7,7 @@ $twitter: #1da1f2;
|
|||
$boost: #8c25f4;
|
||||
$light: #f8f9fa;
|
||||
$dark: #212529;
|
||||
$nostr: #8d45dd;
|
||||
|
||||
$theme-colors: (
|
||||
"primary" : #FADA5E,
|
||||
|
@ -21,6 +22,7 @@ $theme-colors: (
|
|||
"grey" : #e9ecef,
|
||||
"grey-medium" : #d2d2d2,
|
||||
"grey-darkmode": #8c8c8c,
|
||||
"nostr": #8d45dd,
|
||||
);
|
||||
|
||||
$body-bg: #fcfcff;
|
||||
|
@ -227,6 +229,13 @@ mark {
|
|||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.btn-nostr, .btn-nostr:hover, .btn-nostr:active {
|
||||
fill: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-width: 6px;
|
||||
}
|
||||
|
||||
.btn-outline-grey-darkmode:hover, .btn-outline-grey-darkmode:active {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
@ -568,6 +577,13 @@ div[contenteditable]:focus,
|
|||
fill: #ffffff;
|
||||
}
|
||||
|
||||
.btn-nostr svg {
|
||||
fill: #fff;
|
||||
stroke: #fff;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-width: 6px;
|
||||
}
|
||||
|
||||
.btn-dark svg {
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 875 875">
|
||||
<path class="cls-1" d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.2 KiB |
Loading…
Reference in New Issue