diff --git a/api/resolvers/user.js b/api/resolvers/user.js index efbfe642..0e5e04ad 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -47,7 +47,7 @@ async function authMethods (user, args, { models, me }) { twitter: oauth.indexOf('twitter') >= 0, github: oauth.indexOf('github') >= 0, 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' } }) } - 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 }, deleteApiKey: async (parent, { id }, { models, me }) => { @@ -549,7 +556,7 @@ export default { 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 }) => { if (!me) { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 2300fdc3..cd2e31bd 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -104,7 +104,7 @@ export default gql` github: Boolean! twitter: Boolean! email: String - apiKey: String + apiKey: Boolean! } type UserPrivates { diff --git a/components/modal.js b/components/modal.js index 7ae025c5..19aee78e 100644 --- a/components/modal.js +++ b/components/modal.js @@ -61,7 +61,7 @@ export default function useModal () { dialogClassName={className} contentClassName={className} > -
+
{modalOptions?.overflow &&
diff --git a/pages/api/graphql.js b/pages/api/graphql.js index 14cfba3b..e19723ea 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -56,8 +56,11 @@ export default startServerAndCreateNextHandler(apolloServer, { 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 } }) + const [user] = await models.$queryRaw` + SELECT id, name, email, "apiKeyEnabled" + FROM users + WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex') + LIMIT 1` if (user?.apiKeyEnabled) { const { apiKeyEnabled, ...sessionFields } = user session = { user: { ...sessionFields, apiKey: true } } diff --git a/pages/settings/index.js b/pages/settings/index.js index 117675c0..4bc596f6 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -767,7 +767,8 @@ export function EmailLinkForm ({ callbackUrl }) { ) } -export function ApiKey ({ enabled, apiKey }) { +function ApiKey ({ enabled, apiKey }) { + const showModal = useShowModal() const me = useMe() const [generateApiKey] = useMutation( gql` @@ -785,33 +786,7 @@ export function ApiKey ({ enabled, apiKey }) { 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 } + authMethods: { ...existing.privates.authMethods, apiKey: true } } } } @@ -820,6 +795,7 @@ export function ApiKey ({ enabled, apiKey }) { } } ) + const toaster = useToast() const subject = '[API Key Request] ' 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 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 ( <>
api key
- {apiKey && - <> - - } request access to API keys in ~meta : <>} + overlay={disabled ? {apiKey ? 'you can have only one API key at a time' : 'request access to API keys in ~meta'} : <>} trigger={['hover', 'focus']} >
- {apiKey - ? { - await deleteApiKey({ variables: { id: me.id } }) - }} - /> - : ( - - )} +
+ {apiKey && + { + showModal((onClose) => ) + }} + />}
  • use API keys with our GraphQL API for authentication
  • @@ -912,6 +886,71 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f ) } +function ApiKeyModal ({ apiKey }) { + return ( + <> +

    + Make sure to copy your API key now.
    + This is the only time we will show it to you. +

    + use the X-API-Key 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 ( +
    +

    + Do you really want to delete your API key? +

    +
    + +
    +
    + ) +} + const ZapUndosField = () => { const [checkboxField] = useField({ name: 'zapUndosEnabled' }) return ( diff --git a/prisma/migrations/20240326181608_api_key_sha256/migration.sql b/prisma/migrations/20240326181608_api_key_sha256/migration.sql new file mode 100644 index 00000000..a37efe7a --- /dev/null +++ b/prisma/migrations/20240326181608_api_key_sha256/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca222290..74655e78 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,7 +26,7 @@ model User { checkedNotesAt DateTime? foundNotesAt DateTime? 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) tipDefault Int @default(100) bioId Int? diff --git a/styles/globals.scss b/styles/globals.scss index d0881a0f..bf96fa70 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -481,6 +481,10 @@ div[contenteditable]:disabled, .modal-content { background-color: var(--theme-inputBg); border-color: var(--theme-borderColor); + align-items: center; +} +.modal-body { + width: fit-content; } .nav-link:not(.text-success, .text-warning) {