Hash API keys with SHA-256 and never show them again
This commit is contained in:
parent
3730b89667
commit
17a0106fcc
@ -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) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 } }
|
||||||
|
@ -768,6 +768,7 @@ export function EmailLinkForm ({ callbackUrl }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApiKey ({ enabled, apiKey }) {
|
export function ApiKey ({ enabled, apiKey }) {
|
||||||
|
const showModal = useShowModal()
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [generateApiKey] = useMutation(
|
const [generateApiKey] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
@ -785,7 +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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -811,7 +812,7 @@ export function ApiKey ({ enabled, apiKey }) {
|
|||||||
...existing,
|
...existing,
|
||||||
privates: {
|
privates: {
|
||||||
...existing.privates,
|
...existing.privates,
|
||||||
authMethods: { ...existing.privates.authMethods, apiKey: null }
|
authMethods: { ...existing.privates.authMethods, apiKey: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -820,6 +821,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 =
|
||||||
@ -850,36 +852,34 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f
|
|||||||
<>
|
<>
|
||||||
<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>request access to API keys in ~meta</Tooltip> : <></>}
|
||||||
trigger={['hover', 'focus']}
|
trigger={['hover', 'focus']}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{apiKey
|
<Button
|
||||||
? <DeleteIcon
|
disabled={disabled}
|
||||||
style={{ cursor: 'pointer' }} className='fill-grey mx-1' width={24} height={24}
|
variant='secondary'
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deleteApiKey({ variables: { id: me.id } })
|
try {
|
||||||
}}
|
const { data } = await generateApiKey({ variables: { id: me.id } })
|
||||||
/>
|
const { generateApiKey: apiKey } = data
|
||||||
: (
|
showModal(() => <ApiKeyModal apiKey={apiKey} />, { keepOpen: true })
|
||||||
<Button
|
} catch (err) {
|
||||||
disabled={disabled} className={apiKey ? 'ms-2' : ''} variant='secondary' onClick={async () => {
|
console.error(err)
|
||||||
await generateApiKey({ variables: { id: me.id } })
|
toaster.danger('error generating api key')
|
||||||
}}
|
}
|
||||||
>Generate API key
|
}}
|
||||||
</Button>
|
>Generate API key
|
||||||
)}
|
</Button>
|
||||||
|
{apiKey &&
|
||||||
|
<DeleteIcon
|
||||||
|
style={{ cursor: 'pointer' }} className='fill-grey mx-1' width={24} height={24}
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteApiKey({ variables: { id: me.id } })
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
</div>
|
</div>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
<Info>
|
<Info>
|
||||||
@ -912,6 +912,18 @@ 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</>} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ZapUndosField = () => {
|
const ZapUndosField = () => {
|
||||||
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
|
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
|
||||||
return (
|
return (
|
||||||
|
@ -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");
|
@ -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?
|
||||||
|
Loading…
x
Reference in New Issue
Block a user