Add nostr login (#367)

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
ekzyis 2023-08-08 02:50:01 +02:00 committed by GitHub
parent 4586fd7f70
commit 7369bd819d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 275 additions and 2 deletions

View File

@ -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 {

View File

@ -35,6 +35,7 @@ export default gql`
type AuthMethods {
lightning: Boolean!
slashtags: Boolean!
nostr: Boolean!
github: Boolean!
twitter: Boolean!
email: String

View File

@ -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;
}

View File

@ -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

View File

@ -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}`}

160
components/nostr-auth.js Normal file
View File

@ -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>
)
}

View File

@ -58,6 +58,7 @@ export const SETTINGS_FIELDS = gql`
authMethods {
lightning
slashtags
nostr
github
twitter
email

View File

@ -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',

View File

@ -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

View File

@ -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");

View File

@ -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?

View File

@ -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;
}

3
svgs/nostr.svg Normal file
View File

@ -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