API Keys (#915)
* 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:
parent
05702456e9
commit
687012d1a0
|
@ -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' } })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -99,7 +99,9 @@ export const SETTINGS_FIELDS = gql`
|
|||
github
|
||||
twitter
|
||||
email
|
||||
apiKey
|
||||
}
|
||||
apiKeyEnabled
|
||||
}
|
||||
}`
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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");
|
|
@ -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?
|
||||
|
|
|
@ -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 |
Loading…
Reference in New Issue