Account switching

This commit is contained in:
ekzyis 2023-11-17 05:00:53 +01:00
parent 5137f1423d
commit 470e0dfc7a
12 changed files with 278 additions and 51 deletions

View File

@ -27,6 +27,8 @@ import HiddenWalletSummary from './hidden-wallet-summary'
import { clearNotifications } from '../lib/badge'
import { useServiceWorker } from './serviceworker'
import SubSelect from './sub-select'
import { useShowModal } from './modal'
import SwitchAccountDialog from './switch-account'
function WalletSummary ({ me }) {
if (!me) return null
@ -86,6 +88,7 @@ function NotificationBell () {
function NavProfileMenu ({ me, dropNavKey }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const showModal = useShowModal()
return (
<div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'>
@ -124,6 +127,7 @@ function NavProfileMenu ({ me, dropNavKey }) {
</Link>
</div>
<Dropdown.Divider />
<Dropdown.Item onClick={() => showModal(onClose => <SwitchAccountDialog onClose={onClose} />)}>switch account</Dropdown.Item>
<Dropdown.Item
onClick={async () => {
try {

View File

@ -11,7 +11,7 @@ import BackIcon from '../svgs/arrow-left-line.svg'
import { useRouter } from 'next/router'
import { SSR } from '../lib/constants'
function QrAuth ({ k1, encodedUrl, callbackUrl }) {
function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
const query = gql`
{
lnAuth(k1: "${k1}") {
@ -23,7 +23,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl }) {
useEffect(() => {
if (data?.lnAuth?.pubkey) {
signIn('lightning', { ...data.lnAuth, callbackUrl })
signIn('lightning', { ...data.lnAuth, callbackUrl, multiAuth })
}
}, [data?.lnAuth])
@ -89,15 +89,15 @@ function LightningExplainer ({ text, children }) {
)
}
export function LightningAuthWithExplainer ({ text, callbackUrl }) {
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
return (
<LightningExplainer text={text}>
<LightningAuth callbackUrl={callbackUrl} />
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
</LightningExplainer>
)
}
export function LightningAuth ({ callbackUrl }) {
export function LightningAuth ({ callbackUrl, multiAuth }) {
// query for challenge
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
@ -113,5 +113,5 @@ export function LightningAuth ({ callbackUrl }) {
if (error) return <div>error</div>
return data ? <QrAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <QrSkeleton status='generating' />
return data ? <QrAuth {...data.createAuth} callbackUrl={callbackUrl} multiAuth={multiAuth} /> : <QrSkeleton status='generating' />
}

View File

@ -48,12 +48,12 @@ export function authErrorMessage (error) {
return error && (authErrorMessages[error] ?? authErrorMessages.default)
}
export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) {
export default function Login ({ providers, callbackUrl, error, multiAuth, text, Header, Footer }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
const router = useRouter()
if (router.query.type === 'lightning') {
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} />
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
}
if (router.query.type === 'nostr') {

View File

@ -8,15 +8,21 @@ export const MeContext = React.createContext({
})
export function MeProvider ({ me, children }) {
const { data } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
return (
<MeContext.Provider value={data?.me || me}>
<MeContext.Provider value={{ me: data?.me || me, refetch }}>
{children}
</MeContext.Provider>
)
}
export function useMe () {
return useContext(MeContext)
const { me } = useContext(MeContext)
return me
}
export function useMeRefresh () {
const { refetch } = useContext(MeContext)
return refetch
}

View File

@ -0,0 +1,110 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import AnonIcon from '../svgs/spy-fill.svg'
import { useRouter } from 'next/router'
import cookie from 'cookie'
import { useMe, useMeRefresh } from './me'
import Image from 'react-bootstrap/Image'
import Link from 'next/link'
const AccountContext = createContext()
export const AccountProvider = ({ children }) => {
const me = useMe()
const [accounts, setAccounts] = useState()
useEffect(() => {
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
const accounts = multiAuthCookie
? JSON.parse(multiAuthCookie)
: me ? [{ id: me.id, name: me.name, photoId: me.photoId }] : []
setAccounts(accounts)
}, [])
const addAccount = useCallback(user => {
setAccounts(accounts => [...accounts, user])
}, [setAccounts])
const removeAccount = useCallback(userId => {
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
}, [setAccounts])
return <AccountContext.Provider value={{ accounts, addAccount, removeAccount }}>{children}</AccountContext.Provider>
}
const useAccounts = () => useContext(AccountContext)
const AnonAccount = () => {
const me = useMe()
const refreshMe = useMeRefresh()
return (
<div
className='d-flex flex-column me-2 my-1 text-center'
>
<AnonIcon
className='fill-muted'
width='135' height='135' style={{ cursor: 'pointer' }} onClick={() => {
document.cookie = 'multi_auth.user-id=anonymous'
refreshMe()
}}
/>
<div className='fst-italic'>anonymous</div>
{!me && <div className='text-muted fst-italic'>selected</div>}
</div>
)
}
const Account = ({ account, className }) => {
const me = useMe()
const refreshMe = useMeRefresh()
const src = account.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${account.photoId}` : '/dorian400.jpg'
return (
<div
className='d-flex flex-column me-2 my-1 text-center'
>
<Image
width='135' height='135' src={src} style={{ cursor: 'pointer' }} onClick={() => {
document.cookie = `multi_auth.user-id=${account.id}`
refreshMe()
}}
/>
<Link href={`/${account.name}`}>@{account.name}</Link>
{Number(me?.id) === Number(account.id) && <div className='text-muted fst-italic'>selected</div>}
</div>
)
}
const AddAccount = () => {
const router = useRouter()
return (
<div className='d-flex flex-column me-2 my-1 text-center'>
<Image
width='135' height='135' src='https://imgs.search.brave.com/t8qv-83e1m_kaajLJoJ0GNID5ch0WvBGmy7Pkyr4kQY/rs:fit:860:0:0/g:ce/aHR0cHM6Ly91cGxv/YWQud2lraW1lZGlh/Lm9yZy93aWtpcGVk/aWEvY29tbW9ucy84/Lzg5L1BvcnRyYWl0/X1BsYWNlaG9sZGVy/LnBuZw' style={{ cursor: 'pointer' }} onClick={() => {
router.push({
pathname: '/login',
query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
})
}}
/>
<div className='fst-italic'>+ add account</div>
</div>
)
}
export default function SwitchAccountDialog () {
const { accounts } = useAccounts()
return (
<>
<h3>Switch Account</h3>
<div className='my-2'>
<div className='d-flex flex-row flex-wrap'>
<AnonAccount />
{
accounts.map((account) => <Account key={account.id} account={account} />)
}
<AddAccount />
</div>
</div>
</>
)
}

View File

@ -8,6 +8,7 @@ export const ME = gql`
id
name
bioId
photoId
privates {
autoDropBolt11s
diagnostics

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'
export function middleware (request) {
const referrerMiddleware = (request) => {
const regex = /(\/.*)?\/r\/([\w_]+)/
const m = regex.exec(request.nextUrl.pathname)
@ -13,6 +13,23 @@ export function middleware (request) {
return resp
}
export const config = {
matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)']
const multiAuthMiddleware = (request) => {
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
const userId = request.cookies?.get('multi_auth.user-id')?.value
const sessionCookieName = '__Secure-next-auth.session-token'
const hasSession = request.cookies?.has(sessionCookieName)
if (userId && hasSession) {
const userJWT = request.cookies.get(`multi_auth.${userId}`)?.value
if (userJWT) request.cookies.set(sessionCookieName, userJWT)
}
const response = NextResponse.next({ request })
return response
}
export function middleware (request) {
const referrerRegexp = /(\/.*)?\/r\/([\w_]+)/
if (referrerRegexp.test(request.nextUrl.pathname)) {
return referrerMiddleware(request)
}
return multiAuthMiddleware(request)
}

56
package-lock.json generated
View File

@ -28,6 +28,7 @@
"bootstrap": "^5.3.2",
"canonical-json": "0.0.4",
"clipboard-copy": "^4.0.1",
"cookie": "^0.6.0",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
"domino": "^2.1.6",
@ -504,6 +505,14 @@
}
}
},
"node_modules/@auth/core/node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@auth/prisma-adapter": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz",
@ -5723,9 +5732,9 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
@ -7341,6 +7350,14 @@
"node": ">= 0.10.0"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -11443,6 +11460,14 @@
}
}
},
"node_modules/next-auth/node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next-plausible": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.11.1.tgz",
@ -16845,6 +16870,13 @@
"oauth4webapi": "^2.0.6",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"dependencies": {
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
}
}
},
"@auth/prisma-adapter": {
@ -20626,9 +20658,9 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"cookie-signature": {
"version": "1.0.6",
@ -21811,6 +21843,11 @@
"vary": "~1.1.2"
},
"dependencies": {
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -24577,6 +24614,13 @@
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"dependencies": {
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
}
}
},
"next-plausible": {

View File

@ -31,6 +31,7 @@
"bootstrap": "^5.3.2",
"canonical-json": "0.0.4",
"clipboard-copy": "^4.0.1",
"cookie": "^0.6.0",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
"domino": "^2.1.6",

View File

@ -18,6 +18,7 @@ import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { LoggerProvider } from '../components/logger'
import { ChainFeeProvider } from '../components/chain-fee.js'
import { AccountProvider } from '../components/switch-account'
import dynamic from 'next/dynamic'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -95,22 +96,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<MeProvider me={me}>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
<PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
<AccountProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
<PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</AccountProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</MeProvider>

View File

@ -7,10 +7,12 @@ import EmailProvider from 'next-auth/providers/email'
import prisma from '../../../api/models'
import nodemailer from 'nodemailer'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { getToken } from 'next-auth/jwt'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
import { datePivot } from '../../../lib/time'
import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node'
import { schnorr } from '@noble/curves/secp256k1'
import { sendUserNotification } from '../../../api/webPush'
import cookie from 'cookie'
function getCallbacks (req) {
return {
@ -77,8 +79,8 @@ function getCallbacks (req) {
}
}
async function pubkeyAuth (credentials, req, pubkeyColumnName) {
const { k1, pubkey } = credentials
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
const { k1, pubkey, multiAuth } = credentials
try {
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
await prisma.lnAuth.delete({ where: { k1 } })
@ -88,11 +90,47 @@ async function pubkeyAuth (credentials, req, pubkeyColumnName) {
if (!user) {
// if we are logged in, update rather than create
if (token?.id) {
// TODO: consider multiauth if logged in but user does not exist yet
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
} else {
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
}
} else if (token && token?.id !== user.id) {
if (multiAuth) {
// we want to add a new account to 'switch accounts'
const secret = process.env.NEXTAUTH_SECRET
// default expiration for next-auth JWTs is in 1 month
const expiresAt = datePivot(new Date(), { months: 1 })
const cookieOptions = {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
expires: expiresAt
}
const userJWT = await encodeJWT({
token: {
id: user.id,
name: user.name,
email: user.email
},
secret
})
const me = await prisma.user.findUnique({ where: { id: token.id } })
const tokenJWT = await encodeJWT({ token, secret })
// NOTE: why can't I put this in a function with a for loop?!
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${user.id}`, userJWT, cookieOptions))
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${me.id}`, tokenJWT, cookieOptions))
res.appendHeader('Set-Cookie',
cookie.serialize('multi_auth',
JSON.stringify([
{ id: user.id, name: user.name, photoId: user.photoId },
{ id: me.id, name: me.name, photoId: me.photoId }
]),
{ ...cookieOptions, httpOnly: false }))
// don't switch accounts, we only want to add. switching is done in client via "pointer cookie"
return token
}
return null
}
@ -136,7 +174,7 @@ async function nostrEventAuth (event) {
return { k1, pubkey }
}
const providers = [
const getProviders = res => [
CredentialsProvider({
id: 'lightning',
name: 'Lightning',
@ -144,7 +182,9 @@ const providers = [
pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', type: 'text' }
},
authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey')
authorize: async (credentials, req) => {
return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'pubkey')
}
}),
CredentialsProvider({
id: 'nostr',
@ -154,7 +194,7 @@ const providers = [
},
authorize: async ({ event }, req) => {
const credentials = await nostrEventAuth(event)
return await pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey')
return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'nostrAuthPubkey')
}
}),
GitHubProvider({
@ -188,9 +228,9 @@ const providers = [
})
]
export const getAuthOptions = req => ({
export const getAuthOptions = (req, res) => ({
callbacks: getCallbacks(req),
providers,
providers: getProviders(res),
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt'
@ -203,7 +243,7 @@ export const getAuthOptions = req => ({
})
export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req))
await NextAuth(req, res, getAuthOptions(req, res))
}
async function sendVerificationRequest ({

View File

@ -6,7 +6,7 @@ import { StaticLayout } from '../components/layout'
import Login from '../components/login'
import { isExternal } from '../lib/url'
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) {
const session = await getServerSession(req, res, getAuthOptions(req))
// prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264
@ -22,9 +22,9 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
callbackUrl = '/'
}
if (session && callbackUrl) {
// in the cause of auth linking we want to pass the error back to
// settings
if (session && callbackUrl && !multiAuth) {
// in the cause of auth linking we want to pass the error back to settings
// in the case of multiauth, don't redirect if there is already a session
if (error) {
const url = new URL(callbackUrl, process.env.PUBLIC_URL)
url.searchParams.set('error', error)
@ -43,7 +43,8 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
props: {
providers: await getProviders(),
callbackUrl,
error
error,
multiAuth
}
}
}