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,
|
email: user.emailVerified && user.email,
|
||||||
twitter: oauth.indexOf('twitter') >= 0,
|
twitter: oauth.indexOf('twitter') >= 0,
|
||||||
github: oauth.indexOf('github') >= 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 } })
|
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 }) => {
|
unlinkAuth: async (parent, { authType }, { models, me }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
|
|
@ -36,6 +36,8 @@ export default gql`
|
||||||
subscribeUserPosts(id: ID): User
|
subscribeUserPosts(id: ID): User
|
||||||
subscribeUserComments(id: ID): User
|
subscribeUserComments(id: ID): User
|
||||||
toggleMute(id: ID): User
|
toggleMute(id: ID): User
|
||||||
|
generateApiKey(id: ID!): String
|
||||||
|
deleteApiKey(id: ID!): User
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
type User {
|
||||||
|
@ -100,6 +102,7 @@ export default gql`
|
||||||
github: Boolean!
|
github: Boolean!
|
||||||
twitter: Boolean!
|
twitter: Boolean!
|
||||||
email: String
|
email: String
|
||||||
|
apiKey: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserPrivates {
|
type UserPrivates {
|
||||||
|
@ -118,6 +121,7 @@ export default gql`
|
||||||
tipPopover: Boolean!
|
tipPopover: Boolean!
|
||||||
upvotePopover: Boolean!
|
upvotePopover: Boolean!
|
||||||
hasInvites: Boolean!
|
hasInvites: Boolean!
|
||||||
|
apiKeyEnabled: Boolean!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mirrors SettingsInput
|
mirrors SettingsInput
|
||||||
|
|
|
@ -27,6 +27,7 @@ export function DiscussionForm ({
|
||||||
const schema = discussionSchema({ client, me, existingBoost: item?.boost })
|
const schema = discussionSchema({ client, me, existingBoost: item?.boost })
|
||||||
// if Web Share Target API was used
|
// if Web Share Target API was used
|
||||||
const shareTitle = router.query.title
|
const shareTitle = router.query.title
|
||||||
|
const shareText = router.query.text ? decodeURI(router.query.text) : undefined
|
||||||
const crossposter = useCrossposter()
|
const crossposter = useCrossposter()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
|
||||||
|
@ -88,7 +89,7 @@ export function DiscussionForm ({
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
title: item?.title || shareTitle || '',
|
title: item?.title || shareTitle || '',
|
||||||
text: item?.text || '',
|
text: item?.text || shareText || '',
|
||||||
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
||||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
|
|
|
@ -99,7 +99,9 @@ export const SETTINGS_FIELDS = gql`
|
||||||
github
|
github
|
||||||
twitter
|
twitter
|
||||||
email
|
email
|
||||||
|
apiKey
|
||||||
}
|
}
|
||||||
|
apiKeyEnabled
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,18 @@ const apolloServer = new ApolloServer({
|
||||||
|
|
||||||
export default startServerAndCreateNextHandler(apolloServer, {
|
export default startServerAndCreateNextHandler(apolloServer, {
|
||||||
context: async (req, res) => {
|
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 {
|
return {
|
||||||
models,
|
models,
|
||||||
headers: req.headers,
|
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 Alert from 'react-bootstrap/Alert'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
|
@ -26,6 +26,8 @@ import { useToast } from '../../components/toast'
|
||||||
import { useLogger } from '../../components/logger'
|
import { useLogger } from '../../components/logger'
|
||||||
import { useMe } from '../../components/me'
|
import { useMe } from '../../components/me'
|
||||||
import { INVOICE_RETENTION_DAYS } from '../../lib/constants'
|
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 })
|
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='text-start w-100'>
|
||||||
<div className='form-label'>saturday newsletter</div>
|
<div className='form-label'>saturday newsletter</div>
|
||||||
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CenterLayout>
|
</CenterLayout>
|
||||||
|
@ -606,7 +608,7 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthMethods ({ methods }) {
|
function AuthMethods ({ methods, apiKeyEnabled }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
@ -642,7 +644,7 @@ function AuthMethods ({ methods }) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// sort to prevent hydration mismatch
|
// 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 => {
|
const unlink = async type => {
|
||||||
// if there's only one auth method left
|
// 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>
|
</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?
|
checkedNotesAt DateTime?
|
||||||
foundNotesAt DateTime?
|
foundNotesAt DateTime?
|
||||||
pubkey String? @unique(map: "users.pubkey_unique")
|
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)
|
tipDefault Int @default(100)
|
||||||
bioId Int?
|
bioId Int?
|
||||||
inviteId String?
|
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