Merge pull request #980 from stackernews/hashed-api-keys

Hash API keys with SHA-256 before storing them
This commit is contained in:
Keyan 2024-03-27 13:02:57 -05:00 committed by GitHub
commit df7e944bf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 131 additions and 62 deletions

View File

@ -47,7 +47,7 @@ async function authMethods (user, args, { models, me }) {
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 apiKey: user.apiKeyEnabled ? !!user.apiKeyHash : null
} }
} }
@ -541,7 +541,14 @@ export default {
throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } }) 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"` // I trust postgres CSPRNG more than the one from JS
const [{ apiKey, apiKeyHash }] = await models.$queryRaw`
SELECT "apiKey", encode(digest("apiKey", 'sha256'), 'hex') AS "apiKeyHash"
FROM (
SELECT encode(gen_random_bytes(32), 'base64')::CHAR(32) as "apiKey"
) rng`
await models.user.update({ where: { id: me.id }, data: { apiKeyHash } })
return apiKey return apiKey
}, },
deleteApiKey: async (parent, { id }, { models, me }) => { deleteApiKey: async (parent, { id }, { models, me }) => {
@ -549,7 +556,7 @@ export default {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
} }
return await models.user.update({ where: { id: me.id }, data: { apiKey: null } }) return await models.user.update({ where: { id: me.id }, data: { apiKeyHash: null } })
}, },
unlinkAuth: async (parent, { authType }, { models, me }) => { unlinkAuth: async (parent, { authType }, { models, me }) => {
if (!me) { if (!me) {

View File

@ -104,7 +104,7 @@ export default gql`
github: Boolean! github: Boolean!
twitter: Boolean! twitter: Boolean!
email: String email: String
apiKey: String apiKey: Boolean!
} }
type UserPrivates { type UserPrivates {

View File

@ -61,7 +61,7 @@ export default function useModal () {
dialogClassName={className} dialogClassName={className}
contentClassName={className} contentClassName={className}
> >
<div className='d-flex flex-row'> <div className='d-flex flex-row align-self-end'>
{modalOptions?.overflow && {modalOptions?.overflow &&
<div className={'modal-btn modal-overflow ' + className}> <div className={'modal-btn modal-overflow ' + className}>
<ActionDropdown> <ActionDropdown>

View File

@ -56,8 +56,11 @@ export default startServerAndCreateNextHandler(apolloServer, {
const apiKey = req.headers['x-api-key'] const apiKey = req.headers['x-api-key']
let session let session
if (apiKey) { if (apiKey) {
const sessionFieldSelect = { name: true, id: true, email: true } const [user] = await models.$queryRaw`
const user = await models.user.findUnique({ where: { apiKey }, select: { ...sessionFieldSelect, apiKeyEnabled: true } }) SELECT id, name, email, "apiKeyEnabled"
FROM users
WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex')
LIMIT 1`
if (user?.apiKeyEnabled) { if (user?.apiKeyEnabled) {
const { apiKeyEnabled, ...sessionFields } = user const { apiKeyEnabled, ...sessionFields } = user
session = { user: { ...sessionFields, apiKey: true } } session = { user: { ...sessionFields, apiKey: true } }

View File

@ -767,7 +767,8 @@ export function EmailLinkForm ({ callbackUrl }) {
) )
} }
export function ApiKey ({ enabled, apiKey }) { function ApiKey ({ enabled, apiKey }) {
const showModal = useShowModal()
const me = useMe() const me = useMe()
const [generateApiKey] = useMutation( const [generateApiKey] = useMutation(
gql` gql`
@ -785,33 +786,7 @@ export function ApiKey ({ enabled, apiKey }) {
privates: { privates: {
...existing.privates, ...existing.privates,
apiKey: generateApiKey, apiKey: generateApiKey,
authMethods: { ...existing.privates.authMethods, apiKey: generateApiKey } authMethods: { ...existing.privates.authMethods, apiKey: true }
}
}
}
}
})
}
}
)
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 }
} }
} }
} }
@ -820,6 +795,7 @@ export function ApiKey ({ enabled, apiKey }) {
} }
} }
) )
const toaster = useToast()
const subject = '[API Key Request] <your title here>' const subject = '[API Key Request] <your title here>'
const body = const body =
@ -844,44 +820,42 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f
// link to DM with ek on SimpleX // 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 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 const disabled = !enabled || apiKey
return ( return (
<> <>
<div className='form-label mt-3'>api key</div> <div className='form-label mt-3'>api key</div>
<div className='mt-2 d-flex align-items-center'> <div className='mt-2 d-flex align-items-center'>
{apiKey &&
<>
<CopyInput
groupClassName='mb-0'
readOnly
noForm
placeholder={apiKey}
/>
</>}
<OverlayTrigger <OverlayTrigger
placement='bottom' placement='bottom'
overlay={disabled ? <Tooltip>request access to API keys in ~meta</Tooltip> : <></>} overlay={disabled ? <Tooltip>{apiKey ? 'you can have only one API key at a time' : 'request access to API keys in ~meta'}</Tooltip> : <></>}
trigger={['hover', 'focus']} trigger={['hover', 'focus']}
> >
<div> <div>
{apiKey
? <DeleteIcon
style={{ cursor: 'pointer' }} className='fill-grey mx-1' width={24} height={24}
onClick={async () => {
await deleteApiKey({ variables: { id: me.id } })
}}
/>
: (
<Button <Button
disabled={disabled} className={apiKey ? 'ms-2' : ''} variant='secondary' onClick={async () => { disabled={disabled}
await generateApiKey({ variables: { id: me.id } }) variant='secondary'
onClick={async () => {
try {
const { data } = await generateApiKey({ variables: { id: me.id } })
const { generateApiKey: apiKey } = data
showModal(() => <ApiKeyModal apiKey={apiKey} />, { keepOpen: true })
} catch (err) {
console.error(err)
toaster.danger('error generating api key')
}
}} }}
>Generate API key >Generate API key
</Button> </Button>
)}
</div> </div>
</OverlayTrigger> </OverlayTrigger>
{apiKey &&
<DeleteIcon
style={{ cursor: 'pointer' }} className='fill-danger mx-1' width={24} height={24}
onClick={async () => {
showModal((onClose) => <ApiKeyDeleteObstacle onClose={onClose} />)
}}
/>}
<Info> <Info>
<ul className='fw-bold'> <ul className='fw-bold'>
<li>use API keys with our <Link target='_blank' href='/api/graphql'>GraphQL API</Link> for authentication</li> <li>use API keys with our <Link target='_blank' href='/api/graphql'>GraphQL API</Link> for authentication</li>
@ -912,6 +886,71 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f
) )
} }
function ApiKeyModal ({ apiKey }) {
return (
<>
<p className='fw-bold'>
Make sure to copy your API key now.<br />
This is the only time we will show it to you.
</p>
<CopyInput readOnly noForm placeholder={apiKey} hint={<>use the <span className='text-monospace'>X-API-Key</span> header to include this key in your requests</>} />
</>
)
}
function ApiKeyDeleteObstacle ({ onClose }) {
const me = useMe()
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: false }
}
}
}
}
})
}
}
)
const toaster = useToast()
return (
<div className='text-center'>
<p className='fw-bold'>
Do you really want to delete your API key?
</p>
<div className='d-flex flex-row justify-content-end'>
<Button
variant='danger' onClick={async () => {
try {
await deleteApiKey({ variables: { id: me.id } })
onClose()
} catch (err) {
console.error(err)
toaster.danger('error deleting api key')
}
}}
>do it
</Button>
</div>
</div>
)
}
const ZapUndosField = () => { const ZapUndosField = () => {
const [checkboxField] = useField({ name: 'zapUndosEnabled' }) const [checkboxField] = useField({ name: 'zapUndosEnabled' })
return ( return (

View File

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `apiKey` on the `users` table. All the data in the column will be lost.
- A unique constraint covering the columns `[apiKeyHash]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "users.apikey_unique";
-- AlterTable
ALTER TABLE "users" DROP COLUMN "apiKey",
ADD COLUMN "apiKeyHash" CHAR(64);
-- CreateIndex
CREATE UNIQUE INDEX "users.apikeyhash_unique" ON "users"("apiKeyHash");

View File

@ -26,7 +26,7 @@ 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? @unique(map: "users.apikey_unique") @db.Char(32) apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64)
apiKeyEnabled Boolean @default(false) apiKeyEnabled Boolean @default(false)
tipDefault Int @default(100) tipDefault Int @default(100)
bioId Int? bioId Int?

View File

@ -481,6 +481,10 @@ div[contenteditable]:disabled,
.modal-content { .modal-content {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
align-items: center;
}
.modal-body {
width: fit-content;
} }
.nav-link:not(.text-success, .text-warning) { .nav-link:not(.text-success, .text-warning) {