* Generate API key in settings

* Check x-api-key for GraphQL API requests

* Don't fallback to cookie if x-api-key header was provided

* Select all session fields

* Fix error if API key not found

* Fix style in settings via form-label className

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2024-03-14 21:32:34 +01:00 committed by GitHub
parent 05702456e9
commit 687012d1a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 209 additions and 7 deletions

View File

@ -46,7 +46,8 @@ async function authMethods (user, args, { models, me }) {
email: user.emailVerified && user.email,
twitter: oauth.indexOf('twitter') >= 0,
github: oauth.indexOf('github') >= 0,
nostr: !!user.nostrAuthPubkey
nostr: !!user.nostrAuthPubkey,
apiKey: user.apiKeyEnabled ? user.apiKey : null
}
}
@ -521,6 +522,26 @@ export default {
return await models.user.findUnique({ where: { id: me.id } })
},
generateApiKey: async (parent, { id }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const user = await models.user.findUnique({ where: { id: me.id } })
if (!user.apiKeyEnabled) {
throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } })
}
const [{ apiKey }] = await models.$queryRaw`UPDATE users SET "apiKey" = encode(gen_random_bytes(32), 'base64')::CHAR(32) WHERE id = ${me.id} RETURNING "apiKey"`
return apiKey
},
deleteApiKey: async (parent, { id }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
return await models.user.update({ where: { id: me.id }, data: { apiKey: null } })
},
unlinkAuth: async (parent, { authType }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })

View File

@ -36,6 +36,8 @@ export default gql`
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
toggleMute(id: ID): User
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
}
type User {
@ -100,6 +102,7 @@ export default gql`
github: Boolean!
twitter: Boolean!
email: String
apiKey: String
}
type UserPrivates {
@ -118,6 +121,7 @@ export default gql`
tipPopover: Boolean!
upvotePopover: Boolean!
hasInvites: Boolean!
apiKeyEnabled: Boolean!
"""
mirrors SettingsInput

View File

@ -27,6 +27,7 @@ export function DiscussionForm ({
const schema = discussionSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used
const shareTitle = router.query.title
const shareText = router.query.text ? decodeURI(router.query.text) : undefined
const crossposter = useCrossposter()
const toaster = useToast()
@ -88,7 +89,7 @@ export function DiscussionForm ({
<Form
initial={{
title: item?.title || shareTitle || '',
text: item?.text || '',
text: item?.text || shareText || '',
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
...SubSelectInitial({ sub: item?.subName || sub?.name })

View File

@ -99,7 +99,9 @@ export const SETTINGS_FIELDS = gql`
github
twitter
email
apiKey
}
apiKeyEnabled
}
}`

View File

@ -53,7 +53,18 @@ const apolloServer = new ApolloServer({
export default startServerAndCreateNextHandler(apolloServer, {
context: async (req, res) => {
const session = await getServerSession(req, res, getAuthOptions(req))
const apiKey = req.headers['x-api-key']
let session
if (apiKey) {
const sessionFieldSelect = { name: true, id: true, email: true }
const user = await models.user.findUnique({ where: { apiKey }, select: { ...sessionFieldSelect, apiKeyEnabled: true } })
if (user?.apiKeyEnabled) {
const { apiKeyEnabled, ...sessionFields } = user
session = { user: { ...sessionFields, apiKey: true } }
}
} else {
session = await getServerSession(req, res, getAuthOptions(req))
}
return {
models,
headers: req.headers,

View File

@ -1,4 +1,4 @@
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../../components/form'
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '../../components/form'
import Alert from 'react-bootstrap/Alert'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
@ -26,6 +26,8 @@ import { useToast } from '../../components/toast'
import { useLogger } from '../../components/logger'
import { useMe } from '../../components/me'
import { INVOICE_RETENTION_DAYS } from '../../lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import DeleteIcon from '../../svgs/delete-bin-line.svg'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@ -493,7 +495,7 @@ export default function Settings ({ ssrData }) {
<div className='text-start w-100'>
<div className='form-label'>saturday newsletter</div>
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
{settings?.authMethods && <AuthMethods methods={settings.authMethods} />}
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
</div>
</div>
</CenterLayout>
@ -606,7 +608,7 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
)
}
function AuthMethods ({ methods }) {
function AuthMethods ({ methods, apiKeyEnabled }) {
const showModal = useShowModal()
const router = useRouter()
const toaster = useToast()
@ -642,7 +644,7 @@ function AuthMethods ({ methods }) {
)
// sort to prevent hydration mismatch
const providers = Object.keys(methods).filter(k => k !== '__typename').sort()
const providers = Object.keys(methods).filter(k => k !== '__typename' && k !== 'apiKey').sort()
const unlink = async type => {
// if there's only one auth method left
@ -727,6 +729,7 @@ function AuthMethods ({ methods }) {
)
}
})}
<ApiKey apiKey={methods.apiKey} enabled={apiKeyEnabled} />
</>
)
}
@ -766,3 +769,148 @@ export function EmailLinkForm ({ callbackUrl }) {
</Form>
)
}
export function ApiKey ({ enabled, apiKey }) {
const me = useMe()
const [generateApiKey] = useMutation(
gql`
mutation generateApiKey($id: ID!) {
generateApiKey(id: $id)
}`,
{
update (cache, { data: { generateApiKey } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings (existing) {
return {
...existing,
privates: {
...existing.privates,
apiKey: generateApiKey,
authMethods: { ...existing.privates.authMethods, apiKey: generateApiKey }
}
}
}
}
})
}
}
)
const [deleteApiKey] = useMutation(
gql`
mutation deleteApiKey($id: ID!) {
deleteApiKey(id: $id) {
id
}
}`,
{
update (cache, { data: { deleteApiKey } }) {
cache.modify({
id: 'ROOT_QUERY',
fields: {
settings (existing) {
return {
...existing,
privates: {
...existing.privates,
authMethods: { ...existing.privates.authMethods, apiKey: null }
}
}
}
}
})
}
}
)
const subject = '[API Key Request] <your title here>'
const body =
encodeURI(`**[API Key Request]**
Hi, I would like to use API keys with the [Stacker News GraphQL API](/api/graphql) for the following reasons:
...
I expect to call the following GraphQL queries or mutations:
... (you can leave empty if unknown)
I estimate that I will call the GraphQL API this many times (rough estimate is fine):
... (you can leave empty if unknown)
`)
const metaLink = encodeURI(`/~meta/post?type=discussion&title=${subject}&text=${body}`)
const mailto = `mailto:hello@stacker.news?subject=${subject}&body=${body}`
// link to DM with k00b on Telegram
const telegramLink = 'https://t.me/k00bideh'
// link to DM with ek on SimpleX
const simplexLink = 'https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FxNnPk9DkTbQJ6NckWom9mi5vheo_VPLm%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAnFUiU0M8jS1JY34LxUoPr7mdJlFZwf3pFkjRrhprdQs%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion'
const disabled = !enabled
return (
<>
<div className='form-label mt-3'>api key</div>
<div className='mt-2 d-flex align-items-center'>
{apiKey &&
<>
<CopyInput
groupClassName='mb-0'
readOnly
noForm
placeholder={apiKey}
/>
</>}
<OverlayTrigger
placement='bottom'
overlay={disabled ? <Tooltip>request access to API keys in ~meta</Tooltip> : <></>}
trigger={['hover', 'focus']}
>
<div>
{apiKey
? <DeleteIcon
style={{ cursor: 'pointer' }} className='fill-grey mx-1' width={24} height={24}
onClick={async () => {
await deleteApiKey({ variables: { id: me.id } })
}}
/>
: (
<Button
disabled={disabled} className={apiKey ? 'ms-2' : ''} variant='secondary' onClick={async () => {
await generateApiKey({ variables: { id: me.id } })
}}
>Generate API key
</Button>
)}
</div>
</OverlayTrigger>
<Info>
<ul className='fw-bold'>
<li>use API keys with our <Link target='_blank' href='/api/graphql'>GraphQL API</Link> for authentication</li>
<li>you need to add the API key to the <span className='text-monospace'>X-API-Key</span> header of your requests</li>
<li>you can currently only generate API keys if we enabled it for your account</li>
<li>
you can{' '}
<Link target='_blank' href={metaLink} rel='noreferrer'>create a post in ~meta</Link> to request access
or reach out to us via
<ul>
<li><Link target='_blank' href={mailto} rel='noreferrer'>email</Link></li>
<li><Link target='_blank' href={telegramLink} rel='noreferrer'>Telegram</Link></li>
<li><Link target='_blank' href={simplexLink} rel='noreferrer'>SimpleX</Link></li>
</ul>
</li>
<li>please include following information in your request:
<ul>
<li>your nym on SN</li>
<li>what you want to achieve with authenticated API access</li>
<li>which GraphQL queries or mutations you expect to call</li>
<li>your (rough) estimate how often you will call the GraphQL API</li>
</ul>
</li>
</ul>
</Info>
</div>
</>
)
}

View File

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[apiKey]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "users" ADD COLUMN "apiKey" CHAR(32),
ADD COLUMN "apiKeyEnabled" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE UNIQUE INDEX "users.apikey_unique" ON "users"("apiKey");

View File

@ -26,6 +26,8 @@ model User {
checkedNotesAt DateTime?
foundNotesAt DateTime?
pubkey String? @unique(map: "users.pubkey_unique")
apiKey String? @db.Char(32) @unique(map: "users.apikey_unique")
apiKeyEnabled Boolean @default(false)
tipDefault Int @default(100)
bioId Int?
inviteId String?

1
svgs/delete-bin-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z"></path></svg>

After

Width:  |  Height:  |  Size: 308 B