From 849adf84d475487db7019d0151feb86e1242bf1f Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 10:41:29 +0800 Subject: [PATCH 01/11] Add entity class --- packages/api/src/entity/api_key.ts | 37 ++++++++++++++++++++ packages/db/migrations/0084.do.api_key.sql | 18 ++++++++++ packages/db/migrations/0084.undo.api_key.sql | 9 +++++ 3 files changed, 64 insertions(+) create mode 100644 packages/api/src/entity/api_key.ts create mode 100755 packages/db/migrations/0084.do.api_key.sql create mode 100755 packages/db/migrations/0084.undo.api_key.sql diff --git a/packages/api/src/entity/api_key.ts b/packages/api/src/entity/api_key.ts new file mode 100644 index 000000000..b4170de91 --- /dev/null +++ b/packages/api/src/entity/api_key.ts @@ -0,0 +1,37 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm' +import { User } from './user' + +@Entity() +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('text') + name!: string + + @Column('text') + key!: string + + @Column('text', { array: true }) + scopes?: string[] + + @CreateDateColumn() + createdAt!: Date + + @Column('date') + expiresAt!: Date + + @Column('date', { nullable: true }) + usedAt!: Date | null +} diff --git a/packages/db/migrations/0084.do.api_key.sql b/packages/db/migrations/0084.do.api_key.sql new file mode 100755 index 000000000..6a9cf296b --- /dev/null +++ b/packages/db/migrations/0084.do.api_key.sql @@ -0,0 +1,18 @@ +-- Type: DO +-- Name: api_key +-- Description: api_key model + +BEGIN; + +CREATE TABLE omnivore.api_key ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id uuid NOT NULL REFERENCES omnivore.user (id) ON DELETE CASCADE, + name text NOT NULL, + key text NOT NULL, + scopes text[] NOT NULL DEFAULT '{}', + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + used_at timestamptz +); + +COMMIT; diff --git a/packages/db/migrations/0084.undo.api_key.sql b/packages/db/migrations/0084.undo.api_key.sql new file mode 100755 index 000000000..788fd4ca9 --- /dev/null +++ b/packages/db/migrations/0084.undo.api_key.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: api_key +-- Description: api_key model + +BEGIN; + +DROP TABLE omnivore.api_key; + +COMMIT; From a09588d3a79ffaea8cc5e9129913cb610afcf7d8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 11:22:50 +0800 Subject: [PATCH 02/11] Update generateApiKey resolver --- packages/api/src/generated/graphql.ts | 9 ++-- packages/api/src/generated/schema.graphql | 7 ++- packages/api/src/resolvers/api_key/index.ts | 47 ++++++++++++++----- packages/api/src/resolvers/user/index.ts | 6 +-- packages/api/src/schema.ts | 7 ++- packages/api/src/utils/auth.ts | 17 ++++--- .../api/test/gql/sanitize-directive.test.ts | 4 +- packages/api/test/resolvers/user.test.ts | 4 +- packages/db/migrations/0084.do.api_key.sql | 3 +- 9 files changed, 67 insertions(+), 37 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index f6b93caa6..763a72ba0 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -570,12 +570,15 @@ export type GenerateApiKeyError = { }; export enum GenerateApiKeyErrorCode { - BadRequest = 'BAD_REQUEST' + AlreadyExists = 'ALREADY_EXISTS', + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' } export type GenerateApiKeyInput = { - expiredAt?: InputMaybe; - scope?: InputMaybe; + expiresAt: Scalars['Date']; + name: Scalars['String']; + scopes?: InputMaybe>; }; export type GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 3e228c912..88e0d0e12 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -500,12 +500,15 @@ type GenerateApiKeyError { } enum GenerateApiKeyErrorCode { + ALREADY_EXISTS BAD_REQUEST + UNAUTHORIZED } input GenerateApiKeyInput { - expiredAt: Date - scope: String + expiresAt: Date! + name: String! + scopes: [String!] } union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index 538a7c837..078076b16 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -4,33 +4,53 @@ import { GenerateApiKeySuccess, MutationGenerateApiKeyArgs, } from '../../generated/graphql' -import { generateApiKey } from '../../utils/auth' import { analytics } from '../../utils/analytics' import { env } from '../../env' import { authorized } from '../../utils/helpers' +import { getRepository } from '../../entity/utils' +import { User } from '../../entity/user' +import { ApiKey } from '../../entity/api_key' +import { generateApiKey, hashKey } from '../../utils/auth' export const generateApiKeyResolver = authorized< GenerateApiKeySuccess, GenerateApiKeyError, MutationGenerateApiKeyArgs ->((_, { input: { scope, expiredAt } }, { claims }) => { +>(async (_, { input: { name, expiresAt } }, { claims: { uid }, log }) => { try { - console.log('generateApiKeyResolver', scope, expiredAt) + log.info('generateApiKeyResolver') + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [GenerateApiKeyErrorCode.Unauthorized], + } + } - const exp = expiredAt ? new Date(expiredAt).getTime() / 1000 : null - const apiKey = generateApiKey({ - iat: new Date().getTime(), - scope: scope || 'all', - uid: claims.uid, - ...(exp && { exp }), + const existingApiKey = await getRepository(ApiKey).findOneBy({ + user: { id: uid }, + name, + }) + if (existingApiKey) { + return { + errorCodes: [GenerateApiKeyErrorCode.AlreadyExists], + } + } + + const exp = new Date(expiresAt) + const apiKey = generateApiKey() + await getRepository(ApiKey).save({ + user: { id: uid }, + name, + key: hashKey(apiKey), + expiresAt: exp, }) analytics.track({ - userId: claims.uid, - event: 'generate_api_key', + userId: uid, + event: 'api_key_generated', properties: { - scope, - expiredAt: exp, + name, + expiresAt: exp, env: env.server.apiEnv, }, }) @@ -38,6 +58,7 @@ export const generateApiKeyResolver = authorized< return { apiKey } } catch (error) { console.error(error) + return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] } } }) diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 6cd7bce63..d6c16092a 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -33,7 +33,7 @@ import { env } from '../../env' import { validateUsername } from '../../utils/usernamePolicy' import * as jwt from 'jsonwebtoken' import { createUser } from '../../services/create_user' -import { comparePassword, hashPassword } from '../../utils/auth' +import { compareHashedKey, hashKey } from '../../utils/auth' export const updateUserResolver = authorized< UpdateUserSuccess, @@ -310,7 +310,7 @@ export const loginResolver: ResolverFn< } // check if password is correct - const validPassword = comparePassword(password, user.password) + const validPassword = compareHashedKey(password, user.password) if (!validPassword) { return { errorCodes: [LoginErrorCode.InvalidCredentials] } } @@ -331,7 +331,7 @@ export const signupResolver: ResolverFn< try { // hash password - const hashedPassword = hashPassword(password) + const hashedPassword = hashKey(password) const [user, profile] = await createUser({ email, diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 5b634277e..717c57c52 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1412,8 +1412,9 @@ const schema = gql` } input GenerateApiKeyInput { - scope: String - expiredAt: Date + name: String! + scopes: [String!] + expiresAt: Date! } union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError @@ -1428,6 +1429,8 @@ const schema = gql` enum GenerateApiKeyErrorCode { BAD_REQUEST + ALREADY_EXISTS + UNAUTHORIZED } # Query: search diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index e86eb80eb..a6814fb8b 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -1,16 +1,15 @@ import * as bcrypt from 'bcryptjs' -import * as jwt from 'jsonwebtoken' -import { env } from '../env' -import { Claims } from '../resolvers/types' +import { v4 as uuidv4 } from 'uuid' -export const hashPassword = (password: string) => { - return bcrypt.hashSync(password, 10) +export const hashKey = (key: string, salt = 10) => { + return bcrypt.hashSync(key, salt) } -export const comparePassword = (password: string, hash: string) => { - return bcrypt.compareSync(password, hash) +export const compareHashedKey = (rawKey: string, hash: string) => { + return bcrypt.compareSync(rawKey, hash) } -export const generateApiKey = (claims: Claims): string => { - return jwt.sign(claims, env.server.jwtSecret) +export const generateApiKey = (): string => { + // TODO: generate random string key + return uuidv4() } diff --git a/packages/api/test/gql/sanitize-directive.test.ts b/packages/api/test/gql/sanitize-directive.test.ts index 0c81566ec..f60bfcab6 100644 --- a/packages/api/test/gql/sanitize-directive.test.ts +++ b/packages/api/test/gql/sanitize-directive.test.ts @@ -1,7 +1,7 @@ import { createTestUser, deleteTestUser } from '../db' import { graphqlRequest, request } from '../util' import { User } from '../../src/entity/user' -import { hashPassword } from '../../src/utils/auth' +import { hashKey } from '../../src/utils/auth' import 'mocha' describe('Sanitize Directive', () => { @@ -12,7 +12,7 @@ describe('Sanitize Directive', () => { let user: User before(async () => { - const hashedPassword = hashPassword(correctPassword) + const hashedPassword = hashKey(correctPassword) user = await createTestUser(username, '', hashedPassword) const res = await request .post('/local/debug/fake-user-login') diff --git a/packages/api/test/resolvers/user.test.ts b/packages/api/test/resolvers/user.test.ts index 07d49c851..cc065f6be 100644 --- a/packages/api/test/resolvers/user.test.ts +++ b/packages/api/test/resolvers/user.test.ts @@ -8,7 +8,7 @@ import { UpdateUserProfileErrorCode, } from '../../src/generated/graphql' import { User } from '../../src/entity/user' -import { hashPassword } from '../../src/utils/auth' +import { hashKey } from '../../src/utils/auth' import 'mocha' describe('User API', () => { @@ -21,7 +21,7 @@ describe('User API', () => { let anotherUser: User before(async () => { - const hashedPassword = hashPassword(correctPassword) + const hashedPassword = hashKey(correctPassword) // create test user and login user = await createTestUser(username, '', hashedPassword) const res = await request diff --git a/packages/db/migrations/0084.do.api_key.sql b/packages/db/migrations/0084.do.api_key.sql index 6a9cf296b..b258a9685 100755 --- a/packages/db/migrations/0084.do.api_key.sql +++ b/packages/db/migrations/0084.do.api_key.sql @@ -12,7 +12,8 @@ CREATE TABLE omnivore.api_key ( scopes text[] NOT NULL DEFAULT '{}', expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT current_timestamp, - used_at timestamptz + used_at timestamptz, + UNIQUE (user_id, name) ); COMMIT; From 9051a3f43ad351897fb448dcf28445490ee611d1 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 11:36:57 +0800 Subject: [PATCH 03/11] Update api key validation --- packages/api/src/apollo.ts | 9 +++++++-- packages/api/src/utils/auth.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index 3ab478a45..2db7d48bb 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -24,6 +24,7 @@ import ScalarResolvers from './scalars' import * as Sentry from '@sentry/node' import { createPubSubClient } from './datalayer/pubsub' import { initModels } from './server' +import { claimsFromApiKey } from './utils/auth' const signToken = promisify(jwt.sign) const logger = buildLogger('app.dispatch') @@ -47,8 +48,12 @@ const contextFunc: ContextFunction = async ({ variables: req.body.variables, }) - if (token && jwt.verify(token, env.server.jwtSecret)) { - claims = jwt.decode(token) as Claims + if (token) { + jwt.verify(token, env.server.jwtSecret) && + (claims = jwt.decode(token) as Claims) + if (!claims) { + claims = await claimsFromApiKey(token) + } } async function setClaims( diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index a6814fb8b..f1581e1c4 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -1,5 +1,8 @@ import * as bcrypt from 'bcryptjs' import { v4 as uuidv4 } from 'uuid' +import { Claims } from '../resolvers/types' +import { getRepository } from '../entity/utils' +import { ApiKey } from '../entity/api_key' export const hashKey = (key: string, salt = 10) => { return bcrypt.hashSync(key, salt) @@ -13,3 +16,26 @@ export const generateApiKey = (): string => { // TODO: generate random string key return uuidv4() } + +export const claimsFromApiKey = async ( + key: string +): Promise => { + const hashedKey = hashKey(key) + const apiKey = await getRepository(ApiKey).findOne({ + where: { key: hashedKey }, + relations: ['user'], + }) + if (!apiKey) { + console.error('api key not found') + return undefined + } + + // update last used + await getRepository(ApiKey).update(apiKey.id, { usedAt: new Date() }) + + return { + uid: apiKey.user.id, + iat: new Date().getTime(), + exp: apiKey.expiresAt.getTime(), + } +} From 333b0259ba5dd420a27a1c4146efe14da862a419 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 12:38:06 +0800 Subject: [PATCH 04/11] Fix tests --- packages/api/src/apollo.ts | 14 +++++--- packages/api/src/resolvers/api_key/index.ts | 4 +-- packages/api/src/resolvers/user/index.ts | 6 ++-- packages/api/src/utils/auth.ts | 36 ++++++++++++------- .../api/test/gql/sanitize-directive.test.ts | 4 +-- packages/api/test/resolvers/api_key.test.ts | 25 +++++-------- packages/api/test/resolvers/user.test.ts | 4 +-- 7 files changed, 50 insertions(+), 43 deletions(-) diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index 2db7d48bb..ab27e26e0 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -49,10 +49,16 @@ const contextFunc: ContextFunction = async ({ }) if (token) { - jwt.verify(token, env.server.jwtSecret) && - (claims = jwt.decode(token) as Claims) - if (!claims) { - claims = await claimsFromApiKey(token) + try { + jwt.verify(token, env.server.jwtSecret) && + (claims = jwt.decode(token) as Claims) + } catch (e) { + if (e instanceof jwt.JsonWebTokenError) { + logger.info(`not a jwt token, checking api key`, { token }) + claims = await claimsFromApiKey(token) + } else { + throw e + } } } diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index 078076b16..b6f55f92e 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -10,7 +10,7 @@ import { authorized } from '../../utils/helpers' import { getRepository } from '../../entity/utils' import { User } from '../../entity/user' import { ApiKey } from '../../entity/api_key' -import { generateApiKey, hashKey } from '../../utils/auth' +import { generateApiKey, hashApiKey } from '../../utils/auth' export const generateApiKeyResolver = authorized< GenerateApiKeySuccess, @@ -41,7 +41,7 @@ export const generateApiKeyResolver = authorized< await getRepository(ApiKey).save({ user: { id: uid }, name, - key: hashKey(apiKey), + key: hashApiKey(apiKey), expiresAt: exp, }) diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index d6c16092a..3c8673c5f 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -33,7 +33,7 @@ import { env } from '../../env' import { validateUsername } from '../../utils/usernamePolicy' import * as jwt from 'jsonwebtoken' import { createUser } from '../../services/create_user' -import { compareHashedKey, hashKey } from '../../utils/auth' +import { comparePassword, hashPassword } from '../../utils/auth' export const updateUserResolver = authorized< UpdateUserSuccess, @@ -310,7 +310,7 @@ export const loginResolver: ResolverFn< } // check if password is correct - const validPassword = compareHashedKey(password, user.password) + const validPassword = await comparePassword(password, user.password) if (!validPassword) { return { errorCodes: [LoginErrorCode.InvalidCredentials] } } @@ -331,7 +331,7 @@ export const signupResolver: ResolverFn< try { // hash password - const hashedPassword = hashKey(password) + const hashedPassword = await hashPassword(password) const [user, profile] = await createUser({ email, diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index f1581e1c4..2d2481916 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -3,13 +3,14 @@ import { v4 as uuidv4 } from 'uuid' import { Claims } from '../resolvers/types' import { getRepository } from '../entity/utils' import { ApiKey } from '../entity/api_key' +import crypto from 'crypto' -export const hashKey = (key: string, salt = 10) => { - return bcrypt.hashSync(key, salt) +export const hashPassword = async (password: string, salt = 10) => { + return bcrypt.hash(password, salt) } -export const compareHashedKey = (rawKey: string, hash: string) => { - return bcrypt.compareSync(rawKey, hash) +export const comparePassword = async (password: string, hash: string) => { + return bcrypt.compare(password, hash) } export const generateApiKey = (): string => { @@ -17,17 +18,26 @@ export const generateApiKey = (): string => { return uuidv4() } -export const claimsFromApiKey = async ( - key: string -): Promise => { - const hashedKey = hashKey(key) +export const hashApiKey = (apiKey: string) => { + return crypto.createHash('sha256').update(apiKey).digest('hex') +} + +export const claimsFromApiKey = async (key: string): Promise => { + const hashedKey = hashApiKey(key) const apiKey = await getRepository(ApiKey).findOne({ - where: { key: hashedKey }, + where: { + key: hashedKey, + }, relations: ['user'], }) if (!apiKey) { - console.error('api key not found') - return undefined + throw new Error('api key not found') + } + + const iat = Math.floor(Date.now() / 1000) + const exp = Math.floor(new Date(apiKey.expiresAt).getTime() / 1000) + if (exp < iat) { + throw new Error('api key expired') } // update last used @@ -35,7 +45,7 @@ export const claimsFromApiKey = async ( return { uid: apiKey.user.id, - iat: new Date().getTime(), - exp: apiKey.expiresAt.getTime(), + iat, + exp, } } diff --git a/packages/api/test/gql/sanitize-directive.test.ts b/packages/api/test/gql/sanitize-directive.test.ts index f60bfcab6..d4d8820b7 100644 --- a/packages/api/test/gql/sanitize-directive.test.ts +++ b/packages/api/test/gql/sanitize-directive.test.ts @@ -1,7 +1,7 @@ import { createTestUser, deleteTestUser } from '../db' import { graphqlRequest, request } from '../util' import { User } from '../../src/entity/user' -import { hashKey } from '../../src/utils/auth' +import { hashPassword } from '../../src/utils/auth' import 'mocha' describe('Sanitize Directive', () => { @@ -12,7 +12,7 @@ describe('Sanitize Directive', () => { let user: User before(async () => { - const hashedPassword = hashKey(correctPassword) + const hashedPassword = await hashPassword(correctPassword) user = await createTestUser(username, '', hashedPassword) const res = await request .post('/local/debug/fake-user-login') diff --git a/packages/api/test/resolvers/api_key.test.ts b/packages/api/test/resolvers/api_key.test.ts index 32aac8eab..a9a51d109 100644 --- a/packages/api/test/resolvers/api_key.test.ts +++ b/packages/api/test/resolvers/api_key.test.ts @@ -28,7 +28,8 @@ describe('generate api key', () => { let authToken: string let user: User let query: string - let expiredAt: string + let expiresAt: string + let name: string before(async () => { // create test user and login @@ -49,7 +50,8 @@ describe('generate api key', () => { query = ` mutation { generateApiKey(input: { - expiredAt: "${expiredAt}" + name: "${name}" + expiresAt: "${expiresAt}" }) { ... on GenerateApiKeySuccess { apiKey @@ -62,22 +64,10 @@ describe('generate api key', () => { ` }) - context('when no expiredAt is specified', () => { - before(() => { - expiredAt = '' - }) - - it('should generate an api key with no expiration date', async () => { - const response = await graphqlRequest(query, authToken) - expect(response.body.data.generateApiKey.apiKey).to.be.a('string') - - return testAPIKey(response.body.data.generateApiKey.apiKey).expect(200) - }) - }) - context('when api key is not expired', () => { before(() => { - expiredAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() + name = 'test' + expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() }) it('should generate an api key', async () => { @@ -90,7 +80,8 @@ describe('generate api key', () => { context('when api key is expired', () => { before(() => { - expiredAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() + name = 'test-expired' + expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() }) it('should generate an expired api key', async () => { diff --git a/packages/api/test/resolvers/user.test.ts b/packages/api/test/resolvers/user.test.ts index cc065f6be..7115ca7ed 100644 --- a/packages/api/test/resolvers/user.test.ts +++ b/packages/api/test/resolvers/user.test.ts @@ -8,7 +8,7 @@ import { UpdateUserProfileErrorCode, } from '../../src/generated/graphql' import { User } from '../../src/entity/user' -import { hashKey } from '../../src/utils/auth' +import { hashPassword } from '../../src/utils/auth' import 'mocha' describe('User API', () => { @@ -21,7 +21,7 @@ describe('User API', () => { let anotherUser: User before(async () => { - const hashedPassword = hashKey(correctPassword) + const hashedPassword = await hashPassword(correctPassword) // create test user and login user = await createTestUser(username, '', hashedPassword) const res = await request From fd4fc8a4e781f5bc530072d4f8e6f5da02e868b2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 12:44:41 +0800 Subject: [PATCH 05/11] Add list/delete api key in schema --- packages/api/src/generated/graphql.ts | 115 ++++++++++++++++++++++ packages/api/src/generated/schema.graphql | 42 ++++++++ packages/api/src/schema.ts | 42 ++++++++ 3 files changed, 199 insertions(+) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 763a72ba0..e8a6ffc45 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -34,6 +34,33 @@ export type AddPopularReadSuccess = { pageId: Scalars['String']; }; +export type ApiKey = { + __typename?: 'ApiKey'; + createdAt: Scalars['Date']; + expiresAt: Scalars['Date']; + id: Scalars['ID']; + name: Scalars['String']; + scopes?: Maybe>; + usedAt?: Maybe; +}; + +export type ApiKeysError = { + __typename?: 'ApiKeysError'; + errorCodes: Array; +}; + +export enum ApiKeysErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type ApiKeysResult = ApiKeysError | ApiKeysSuccess; + +export type ApiKeysSuccess = { + __typename?: 'ApiKeysSuccess'; + apiKeys: Array; +}; + export type ArchiveLinkError = { __typename?: 'ArchiveLinkError'; errorCodes: Array; @@ -394,6 +421,24 @@ export type CreateReminderSuccess = { reminder: Reminder; }; +export type DeleteApiKeyError = { + __typename?: 'DeleteApiKeyError'; + errorCodes: Array; +}; + +export enum DeleteApiKeyErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type DeleteApiKeyResult = DeleteApiKeyError | DeleteApiKeySuccess; + +export type DeleteApiKeySuccess = { + __typename?: 'DeleteApiKeySuccess'; + apiKey: ApiKey; +}; + export type DeleteHighlightError = { __typename?: 'DeleteHighlightError'; errorCodes: Array; @@ -832,6 +877,7 @@ export type Mutation = { createNewsletterEmail: CreateNewsletterEmailResult; createReaction: CreateReactionResult; createReminder: CreateReminderResult; + deleteApiKey: DeleteApiKeyResult; deleteHighlight: DeleteHighlightResult; deleteHighlightReply: DeleteHighlightReplyResult; deleteLabel: DeleteLabelResult; @@ -915,6 +961,11 @@ export type MutationCreateReminderArgs = { }; +export type MutationDeleteApiKeyArgs = { + id: Scalars['ID']; +}; + + export type MutationDeleteHighlightArgs = { highlightId: Scalars['ID']; }; @@ -1195,6 +1246,7 @@ export type Profile = { export type Query = { __typename?: 'Query'; + apiKeys: ApiKeysResult; article: ArticleResult; articles: ArticlesResult; articleSavingRequest: ArticleSavingRequestResult; @@ -2273,6 +2325,11 @@ export type ResolversTypes = { AddPopularReadErrorCode: AddPopularReadErrorCode; AddPopularReadResult: ResolversTypes['AddPopularReadError'] | ResolversTypes['AddPopularReadSuccess']; AddPopularReadSuccess: ResolverTypeWrapper; + ApiKey: ResolverTypeWrapper; + ApiKeysError: ResolverTypeWrapper; + ApiKeysErrorCode: ApiKeysErrorCode; + ApiKeysResult: ResolversTypes['ApiKeysError'] | ResolversTypes['ApiKeysSuccess']; + ApiKeysSuccess: ResolverTypeWrapper; ArchiveLinkError: ResolverTypeWrapper; ArchiveLinkErrorCode: ArchiveLinkErrorCode; ArchiveLinkInput: ArchiveLinkInput; @@ -2337,6 +2394,10 @@ export type ResolversTypes = { CreateReminderResult: ResolversTypes['CreateReminderError'] | ResolversTypes['CreateReminderSuccess']; CreateReminderSuccess: ResolverTypeWrapper; Date: ResolverTypeWrapper; + DeleteApiKeyError: ResolverTypeWrapper; + DeleteApiKeyErrorCode: DeleteApiKeyErrorCode; + DeleteApiKeyResult: ResolversTypes['DeleteApiKeyError'] | ResolversTypes['DeleteApiKeySuccess']; + DeleteApiKeySuccess: ResolverTypeWrapper; DeleteHighlightError: ResolverTypeWrapper; DeleteHighlightErrorCode: DeleteHighlightErrorCode; DeleteHighlightReplyError: ResolverTypeWrapper; @@ -2609,6 +2670,10 @@ export type ResolversParentTypes = { AddPopularReadError: AddPopularReadError; AddPopularReadResult: ResolversParentTypes['AddPopularReadError'] | ResolversParentTypes['AddPopularReadSuccess']; AddPopularReadSuccess: AddPopularReadSuccess; + ApiKey: ApiKey; + ApiKeysError: ApiKeysError; + ApiKeysResult: ResolversParentTypes['ApiKeysError'] | ResolversParentTypes['ApiKeysSuccess']; + ApiKeysSuccess: ApiKeysSuccess; ArchiveLinkError: ArchiveLinkError; ArchiveLinkInput: ArchiveLinkInput; ArchiveLinkResult: ResolversParentTypes['ArchiveLinkError'] | ResolversParentTypes['ArchiveLinkSuccess']; @@ -2659,6 +2724,9 @@ export type ResolversParentTypes = { CreateReminderResult: ResolversParentTypes['CreateReminderError'] | ResolversParentTypes['CreateReminderSuccess']; CreateReminderSuccess: CreateReminderSuccess; Date: Scalars['Date']; + DeleteApiKeyError: DeleteApiKeyError; + DeleteApiKeyResult: ResolversParentTypes['DeleteApiKeyError'] | ResolversParentTypes['DeleteApiKeySuccess']; + DeleteApiKeySuccess: DeleteApiKeySuccess; DeleteHighlightError: DeleteHighlightError; DeleteHighlightReplyError: DeleteHighlightReplyError; DeleteHighlightReplyResult: ResolversParentTypes['DeleteHighlightReplyError'] | ResolversParentTypes['DeleteHighlightReplySuccess']; @@ -2892,6 +2960,30 @@ export type AddPopularReadSuccessResolvers; }; +export type ApiKeyResolvers = { + createdAt?: Resolver; + expiresAt?: Resolver; + id?: Resolver; + name?: Resolver; + scopes?: Resolver>, ParentType, ContextType>; + usedAt?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type ApiKeysErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type ApiKeysResultResolvers = { + __resolveType: TypeResolveFn<'ApiKeysError' | 'ApiKeysSuccess', ParentType, ContextType>; +}; + +export type ApiKeysSuccessResolvers = { + apiKeys?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ArchiveLinkErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; message?: Resolver; @@ -3127,6 +3219,20 @@ export interface DateScalarConfig extends GraphQLScalarTypeConfig = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type DeleteApiKeyResultResolvers = { + __resolveType: TypeResolveFn<'DeleteApiKeyError' | 'DeleteApiKeySuccess', ParentType, ContextType>; +}; + +export type DeleteApiKeySuccessResolvers = { + apiKey?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeleteHighlightErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3469,6 +3575,7 @@ export type MutationResolvers; createReaction?: Resolver>; createReminder?: Resolver>; + deleteApiKey?: Resolver>; deleteHighlight?: Resolver>; deleteHighlightReply?: Resolver>; deleteLabel?: Resolver>; @@ -3568,6 +3675,7 @@ export type ProfileResolvers = { + apiKeys?: Resolver; article?: Resolver>; articles?: Resolver>; articleSavingRequest?: Resolver>; @@ -4164,6 +4272,10 @@ export type Resolvers = { AddPopularReadError?: AddPopularReadErrorResolvers; AddPopularReadResult?: AddPopularReadResultResolvers; AddPopularReadSuccess?: AddPopularReadSuccessResolvers; + ApiKey?: ApiKeyResolvers; + ApiKeysError?: ApiKeysErrorResolvers; + ApiKeysResult?: ApiKeysResultResolvers; + ApiKeysSuccess?: ApiKeysSuccessResolvers; ArchiveLinkError?: ArchiveLinkErrorResolvers; ArchiveLinkResult?: ArchiveLinkResultResolvers; ArchiveLinkSuccess?: ArchiveLinkSuccessResolvers; @@ -4204,6 +4316,9 @@ export type Resolvers = { CreateReminderResult?: CreateReminderResultResolvers; CreateReminderSuccess?: CreateReminderSuccessResolvers; Date?: GraphQLScalarType; + DeleteApiKeyError?: DeleteApiKeyErrorResolvers; + DeleteApiKeyResult?: DeleteApiKeyResultResolvers; + DeleteApiKeySuccess?: DeleteApiKeySuccessResolvers; DeleteHighlightError?: DeleteHighlightErrorResolvers; DeleteHighlightReplyError?: DeleteHighlightReplyErrorResolvers; DeleteHighlightReplyResult?: DeleteHighlightReplyResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 88e0d0e12..a8650a612 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -16,6 +16,30 @@ type AddPopularReadSuccess { pageId: String! } +type ApiKey { + createdAt: Date! + expiresAt: Date! + id: ID! + name: String! + scopes: [String!] + usedAt: Date +} + +type ApiKeysError { + errorCodes: [ApiKeysErrorCode!]! +} + +enum ApiKeysErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union ApiKeysResult = ApiKeysError | ApiKeysSuccess + +type ApiKeysSuccess { + apiKeys: [ApiKey!]! +} + type ArchiveLinkError { errorCodes: [ArchiveLinkErrorCode!]! message: String! @@ -344,6 +368,22 @@ type CreateReminderSuccess { scalar Date +type DeleteApiKeyError { + errorCodes: [DeleteApiKeyErrorCode!]! +} + +enum DeleteApiKeyErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union DeleteApiKeyResult = DeleteApiKeyError | DeleteApiKeySuccess + +type DeleteApiKeySuccess { + apiKey: ApiKey! +} + type DeleteHighlightError { errorCodes: [DeleteHighlightErrorCode!]! } @@ -738,6 +778,7 @@ type Mutation { createNewsletterEmail: CreateNewsletterEmailResult! createReaction(input: CreateReactionInput!): CreateReactionResult! createReminder(input: CreateReminderInput!): CreateReminderResult! + deleteApiKey(id: ID!): DeleteApiKeyResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! deleteLabel(id: ID!): DeleteLabelResult! @@ -859,6 +900,7 @@ type Profile { } type Query { + apiKeys: ApiKeysResult! article(slug: String!, username: String!): ArticleResult! articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult! articleSavingRequest(id: ID!): ArticleSavingRequestResult! diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 717c57c52..7fddad848 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1670,6 +1670,46 @@ const schema = gql` BAD_REQUEST } + union ApiKeysResult = ApiKeysSuccess | ApiKeysError + + type ApiKeysSuccess { + apiKeys: [ApiKey!]! + } + + type ApiKey { + id: ID! + name: String! + scopes: [String!] + createdAt: Date! + expiresAt: Date! + usedAt: Date + } + + type ApiKeysError { + errorCodes: [ApiKeysErrorCode!]! + } + + enum ApiKeysErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + union DeleteApiKeyResult = DeleteApiKeySuccess | DeleteApiKeyError + + type DeleteApiKeySuccess { + apiKey: ApiKey! + } + + type DeleteApiKeyError { + errorCodes: [DeleteApiKeyErrorCode!]! + } + + enum DeleteApiKeyErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1737,6 +1777,7 @@ const schema = gql` addPopularRead(name: String!): AddPopularReadResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! deleteWebhook(id: ID!): DeleteWebhookResult! + deleteApiKey(id: ID!): DeleteApiKeyResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed @@ -1778,6 +1819,7 @@ const schema = gql` subscriptions(sort: SortParams): SubscriptionsResult! webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! + apiKeys: ApiKeysResult! } ` From 0f2a55d2748eab20f560f8d7aae1636ab31f1635 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 13:01:20 +0800 Subject: [PATCH 06/11] Add list/delete api key resolver --- packages/api/src/resolvers/api_key/index.ts | 83 +++++++++++++++++++ .../api/src/resolvers/function_resolvers.ts | 9 +- packages/api/src/resolvers/index.ts | 1 + 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index b6f55f92e..8cd6f601f 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -1,7 +1,14 @@ import { + ApiKeysError, + ApiKeysErrorCode, + ApiKeysSuccess, + DeleteApiKeyError, + DeleteApiKeyErrorCode, + DeleteApiKeySuccess, GenerateApiKeyError, GenerateApiKeyErrorCode, GenerateApiKeySuccess, + MutationDeleteApiKeyArgs, MutationGenerateApiKeyArgs, } from '../../generated/graphql' import { analytics } from '../../utils/analytics' @@ -12,6 +19,36 @@ import { User } from '../../entity/user' import { ApiKey } from '../../entity/api_key' import { generateApiKey, hashApiKey } from '../../utils/auth' +export const apiKeysResolver = authorized( + async (_, __, { claims: { uid }, log }) => { + log.info('apiKeysResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [ApiKeysErrorCode.Unauthorized], + } + } + + const apiKeys = await getRepository(ApiKey).find({ + where: { user: { id: uid } }, + order: { usedAt: 'DESC', createdAt: 'DESC' }, + }) + + return { + apiKeys, + } + } catch (e) { + log.error(e) + + return { + errorCodes: [ApiKeysErrorCode.BadRequest], + } + } + } +) + export const generateApiKeyResolver = authorized< GenerateApiKeySuccess, GenerateApiKeyError, @@ -62,3 +99,49 @@ export const generateApiKeyResolver = authorized< return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] } } }) + +export const deleteApiKeyResolver = authorized< + DeleteApiKeySuccess, + DeleteApiKeyError, + MutationDeleteApiKeyArgs +>(async (_, { id }, { claims: { uid }, log }) => { + log.info('deleteApiKeyResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [DeleteApiKeyErrorCode.Unauthorized], + } + } + + const apiKey = await getRepository(ApiKey).findOne({ + where: { id }, + relations: ['user'], + }) + if (!apiKey) { + return { + errorCodes: [DeleteApiKeyErrorCode.NotFound], + } + } + + if (apiKey.user.id !== uid) { + return { + errorCodes: [DeleteApiKeyErrorCode.Unauthorized], + } + } + + const deletedApiKey = await getRepository(ApiKey).remove(apiKey) + deletedApiKey.id = id + + return { + apiKey: deletedApiKey, + } + } catch (e) { + log.error(e) + + return { + errorCodes: [DeleteApiKeyErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index de13e589d..4a3c828a8 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -21,6 +21,8 @@ import { } from './../generated/graphql' import { + addPopularReadResolver, + apiKeysResolver, articleSavingRequestResolver, createArticleResolver, createArticleSavingRequestResolver, @@ -28,11 +30,13 @@ import { createLabelResolver, createNewsletterEmailResolver, createReminderResolver, + deleteApiKeyResolver, deleteHighlightResolver, deleteLabelResolver, deleteNewsletterEmailResolver, deleteReminderResolver, deleteWebhookResolver, + generateApiKeyResolver, getAllUsersResolver, getArticleResolver, getArticlesResolver, @@ -90,7 +94,6 @@ import { generateUploadFilePathName, } from '../utils/uploads' import { getPageByParam } from '../elastic/pages' -import { generateApiKeyResolver } from './api_key' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -157,6 +160,7 @@ export const functionResolvers = { addPopularRead: addPopularReadResolver, setWebhook: setWebhookResolver, deleteWebhook: deleteWebhookResolver, + deleteApiKey: deleteApiKeyResolver, }, Query: { me: getMeUserResolver, @@ -178,6 +182,7 @@ export const functionResolvers = { subscriptions: subscriptionsResolver, webhooks: webhooksResolver, webhook: webhookResolver, + apiKeys: apiKeysResolver, }, User: { async sharedArticles( @@ -575,4 +580,6 @@ export const functionResolvers = { ...resultResolveTypeResolver('Webhooks'), ...resultResolveTypeResolver('DeleteWebhook'), ...resultResolveTypeResolver('Webhook'), + ...resultResolveTypeResolver('ApiKeys'), + ...resultResolveTypeResolver('DeleteApiKey'), } diff --git a/packages/api/src/resolvers/index.ts b/packages/api/src/resolvers/index.ts index 58434a7e4..e510db671 100644 --- a/packages/api/src/resolvers/index.ts +++ b/packages/api/src/resolvers/index.ts @@ -18,3 +18,4 @@ export * from './subscriptions' export * from './update' export * from './popular_reads' export * from './webhooks' +export * from './api_key' From b77b378221fa26c18eeb4490551ced0e94909368 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 17:36:30 +0800 Subject: [PATCH 07/11] Grant permissions of api_key table to omnivore_user --- .../db/migrations/{0084.do.api_key.sql => 0085.do.api_key.sql} | 2 ++ .../migrations/{0084.undo.api_key.sql => 0085.undo.api_key.sql} | 0 2 files changed, 2 insertions(+) rename packages/db/migrations/{0084.do.api_key.sql => 0085.do.api_key.sql} (86%) rename packages/db/migrations/{0084.undo.api_key.sql => 0085.undo.api_key.sql} (100%) diff --git a/packages/db/migrations/0084.do.api_key.sql b/packages/db/migrations/0085.do.api_key.sql similarity index 86% rename from packages/db/migrations/0084.do.api_key.sql rename to packages/db/migrations/0085.do.api_key.sql index b258a9685..466ed4201 100755 --- a/packages/db/migrations/0084.do.api_key.sql +++ b/packages/db/migrations/0085.do.api_key.sql @@ -16,4 +16,6 @@ CREATE TABLE omnivore.api_key ( UNIQUE (user_id, name) ); +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.api_key TO omnivore_user; + COMMIT; diff --git a/packages/db/migrations/0084.undo.api_key.sql b/packages/db/migrations/0085.undo.api_key.sql similarity index 100% rename from packages/db/migrations/0084.undo.api_key.sql rename to packages/db/migrations/0085.undo.api_key.sql From b7206457b4378ad18aacf2080345635bbb6bae52 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 18:07:54 +0800 Subject: [PATCH 08/11] Add test for revoke api key --- packages/api/src/generated/graphql.ts | 104 ++++++++--------- packages/api/src/generated/schema.graphql | 37 +++--- packages/api/src/resolvers/api_key/index.ts | 43 ++++--- .../api/src/resolvers/function_resolvers.ts | 6 +- packages/api/src/schema.ts | 15 +-- packages/api/test/resolvers/api_key.test.ts | 106 ++++++++++++++---- 6 files changed, 193 insertions(+), 118 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index e8a6ffc45..0a4c456de 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -39,6 +39,7 @@ export type ApiKey = { createdAt: Scalars['Date']; expiresAt: Scalars['Date']; id: Scalars['ID']; + key?: Maybe; name: Scalars['String']; scopes?: Maybe>; usedAt?: Maybe; @@ -421,24 +422,6 @@ export type CreateReminderSuccess = { reminder: Reminder; }; -export type DeleteApiKeyError = { - __typename?: 'DeleteApiKeyError'; - errorCodes: Array; -}; - -export enum DeleteApiKeyErrorCode { - BadRequest = 'BAD_REQUEST', - NotFound = 'NOT_FOUND', - Unauthorized = 'UNAUTHORIZED' -} - -export type DeleteApiKeyResult = DeleteApiKeyError | DeleteApiKeySuccess; - -export type DeleteApiKeySuccess = { - __typename?: 'DeleteApiKeySuccess'; - apiKey: ApiKey; -}; - export type DeleteHighlightError = { __typename?: 'DeleteHighlightError'; errorCodes: Array; @@ -630,7 +613,7 @@ export type GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess; export type GenerateApiKeySuccess = { __typename?: 'GenerateApiKeySuccess'; - apiKey: Scalars['String']; + apiKey: ApiKey; }; export type GetFollowersError = { @@ -877,7 +860,6 @@ export type Mutation = { createNewsletterEmail: CreateNewsletterEmailResult; createReaction: CreateReactionResult; createReminder: CreateReminderResult; - deleteApiKey: DeleteApiKeyResult; deleteHighlight: DeleteHighlightResult; deleteHighlightReply: DeleteHighlightReplyResult; deleteLabel: DeleteLabelResult; @@ -892,6 +874,7 @@ export type Mutation = { logOut: LogOutResult; mergeHighlight: MergeHighlightResult; reportItem: ReportItemResult; + revokeApiKey: RevokeApiKeyResult; saveArticleReadingProgress: SaveArticleReadingProgressResult; saveFile: SaveResult; savePage: SaveResult; @@ -961,11 +944,6 @@ export type MutationCreateReminderArgs = { }; -export type MutationDeleteApiKeyArgs = { - id: Scalars['ID']; -}; - - export type MutationDeleteHighlightArgs = { highlightId: Scalars['ID']; }; @@ -1031,6 +1009,11 @@ export type MutationReportItemArgs = { }; +export type MutationRevokeApiKeyArgs = { + id: Scalars['ID']; +}; + + export type MutationSaveArticleReadingProgressArgs = { input: SaveArticleReadingProgressInput; }; @@ -1420,6 +1403,24 @@ export enum ReportType { Spam = 'SPAM' } +export type RevokeApiKeyError = { + __typename?: 'RevokeApiKeyError'; + errorCodes: Array; +}; + +export enum RevokeApiKeyErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type RevokeApiKeyResult = RevokeApiKeyError | RevokeApiKeySuccess; + +export type RevokeApiKeySuccess = { + __typename?: 'RevokeApiKeySuccess'; + apiKey: ApiKey; +}; + export type SaveArticleReadingProgressError = { __typename?: 'SaveArticleReadingProgressError'; errorCodes: Array; @@ -2394,10 +2395,6 @@ export type ResolversTypes = { CreateReminderResult: ResolversTypes['CreateReminderError'] | ResolversTypes['CreateReminderSuccess']; CreateReminderSuccess: ResolverTypeWrapper; Date: ResolverTypeWrapper; - DeleteApiKeyError: ResolverTypeWrapper; - DeleteApiKeyErrorCode: DeleteApiKeyErrorCode; - DeleteApiKeyResult: ResolversTypes['DeleteApiKeyError'] | ResolversTypes['DeleteApiKeySuccess']; - DeleteApiKeySuccess: ResolverTypeWrapper; DeleteHighlightError: ResolverTypeWrapper; DeleteHighlightErrorCode: DeleteHighlightErrorCode; DeleteHighlightReplyError: ResolverTypeWrapper; @@ -2506,6 +2503,10 @@ export type ResolversTypes = { ReportItemInput: ReportItemInput; ReportItemResult: ResolverTypeWrapper; ReportType: ReportType; + RevokeApiKeyError: ResolverTypeWrapper; + RevokeApiKeyErrorCode: RevokeApiKeyErrorCode; + RevokeApiKeyResult: ResolversTypes['RevokeApiKeyError'] | ResolversTypes['RevokeApiKeySuccess']; + RevokeApiKeySuccess: ResolverTypeWrapper; SaveArticleReadingProgressError: ResolverTypeWrapper; SaveArticleReadingProgressErrorCode: SaveArticleReadingProgressErrorCode; SaveArticleReadingProgressInput: SaveArticleReadingProgressInput; @@ -2724,9 +2725,6 @@ export type ResolversParentTypes = { CreateReminderResult: ResolversParentTypes['CreateReminderError'] | ResolversParentTypes['CreateReminderSuccess']; CreateReminderSuccess: CreateReminderSuccess; Date: Scalars['Date']; - DeleteApiKeyError: DeleteApiKeyError; - DeleteApiKeyResult: ResolversParentTypes['DeleteApiKeyError'] | ResolversParentTypes['DeleteApiKeySuccess']; - DeleteApiKeySuccess: DeleteApiKeySuccess; DeleteHighlightError: DeleteHighlightError; DeleteHighlightReplyError: DeleteHighlightReplyError; DeleteHighlightReplyResult: ResolversParentTypes['DeleteHighlightReplyError'] | ResolversParentTypes['DeleteHighlightReplySuccess']; @@ -2814,6 +2812,9 @@ export type ResolversParentTypes = { ReminderSuccess: ReminderSuccess; ReportItemInput: ReportItemInput; ReportItemResult: ReportItemResult; + RevokeApiKeyError: RevokeApiKeyError; + RevokeApiKeyResult: ResolversParentTypes['RevokeApiKeyError'] | ResolversParentTypes['RevokeApiKeySuccess']; + RevokeApiKeySuccess: RevokeApiKeySuccess; SaveArticleReadingProgressError: SaveArticleReadingProgressError; SaveArticleReadingProgressInput: SaveArticleReadingProgressInput; SaveArticleReadingProgressResult: ResolversParentTypes['SaveArticleReadingProgressError'] | ResolversParentTypes['SaveArticleReadingProgressSuccess']; @@ -2964,6 +2965,7 @@ export type ApiKeyResolvers; expiresAt?: Resolver; id?: Resolver; + key?: Resolver, ParentType, ContextType>; name?: Resolver; scopes?: Resolver>, ParentType, ContextType>; usedAt?: Resolver, ParentType, ContextType>; @@ -3219,20 +3221,6 @@ export interface DateScalarConfig extends GraphQLScalarTypeConfig = { - errorCodes?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type DeleteApiKeyResultResolvers = { - __resolveType: TypeResolveFn<'DeleteApiKeyError' | 'DeleteApiKeySuccess', ParentType, ContextType>; -}; - -export type DeleteApiKeySuccessResolvers = { - apiKey?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type DeleteHighlightErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3383,7 +3371,7 @@ export type GenerateApiKeyResultResolvers = { - apiKey?: Resolver; + apiKey?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -3575,7 +3563,6 @@ export type MutationResolvers; createReaction?: Resolver>; createReminder?: Resolver>; - deleteApiKey?: Resolver>; deleteHighlight?: Resolver>; deleteHighlightReply?: Resolver>; deleteLabel?: Resolver>; @@ -3590,6 +3577,7 @@ export type MutationResolvers; mergeHighlight?: Resolver>; reportItem?: Resolver>; + revokeApiKey?: Resolver>; saveArticleReadingProgress?: Resolver>; saveFile?: Resolver>; savePage?: Resolver>; @@ -3742,6 +3730,20 @@ export type ReportItemResultResolvers; }; +export type RevokeApiKeyErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type RevokeApiKeyResultResolvers = { + __resolveType: TypeResolveFn<'RevokeApiKeyError' | 'RevokeApiKeySuccess', ParentType, ContextType>; +}; + +export type RevokeApiKeySuccessResolvers = { + apiKey?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SaveArticleReadingProgressErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4316,9 +4318,6 @@ export type Resolvers = { CreateReminderResult?: CreateReminderResultResolvers; CreateReminderSuccess?: CreateReminderSuccessResolvers; Date?: GraphQLScalarType; - DeleteApiKeyError?: DeleteApiKeyErrorResolvers; - DeleteApiKeyResult?: DeleteApiKeyResultResolvers; - DeleteApiKeySuccess?: DeleteApiKeySuccessResolvers; DeleteHighlightError?: DeleteHighlightErrorResolvers; DeleteHighlightReplyError?: DeleteHighlightReplyErrorResolvers; DeleteHighlightReplyResult?: DeleteHighlightReplyResultResolvers; @@ -4395,6 +4394,9 @@ export type Resolvers = { ReminderResult?: ReminderResultResolvers; ReminderSuccess?: ReminderSuccessResolvers; ReportItemResult?: ReportItemResultResolvers; + RevokeApiKeyError?: RevokeApiKeyErrorResolvers; + RevokeApiKeyResult?: RevokeApiKeyResultResolvers; + RevokeApiKeySuccess?: RevokeApiKeySuccessResolvers; SaveArticleReadingProgressError?: SaveArticleReadingProgressErrorResolvers; SaveArticleReadingProgressResult?: SaveArticleReadingProgressResultResolvers; SaveArticleReadingProgressSuccess?: SaveArticleReadingProgressSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index a8650a612..4d37de9b8 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -20,6 +20,7 @@ type ApiKey { createdAt: Date! expiresAt: Date! id: ID! + key: String name: String! scopes: [String!] usedAt: Date @@ -368,22 +369,6 @@ type CreateReminderSuccess { scalar Date -type DeleteApiKeyError { - errorCodes: [DeleteApiKeyErrorCode!]! -} - -enum DeleteApiKeyErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED -} - -union DeleteApiKeyResult = DeleteApiKeyError | DeleteApiKeySuccess - -type DeleteApiKeySuccess { - apiKey: ApiKey! -} - type DeleteHighlightError { errorCodes: [DeleteHighlightErrorCode!]! } @@ -554,7 +539,7 @@ input GenerateApiKeyInput { union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess type GenerateApiKeySuccess { - apiKey: String! + apiKey: ApiKey! } type GetFollowersError { @@ -778,7 +763,6 @@ type Mutation { createNewsletterEmail: CreateNewsletterEmailResult! createReaction(input: CreateReactionInput!): CreateReactionResult! createReminder(input: CreateReminderInput!): CreateReminderResult! - deleteApiKey(id: ID!): DeleteApiKeyResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! deleteLabel(id: ID!): DeleteLabelResult! @@ -793,6 +777,7 @@ type Mutation { logOut: LogOutResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! reportItem(input: ReportItemInput!): ReportItemResult! + revokeApiKey(id: ID!): RevokeApiKeyResult! saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! saveFile(input: SaveFileInput!): SaveResult! savePage(input: SavePageInput!): SaveResult! @@ -989,6 +974,22 @@ enum ReportType { SPAM } +type RevokeApiKeyError { + errorCodes: [RevokeApiKeyErrorCode!]! +} + +enum RevokeApiKeyErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union RevokeApiKeyResult = RevokeApiKeyError | RevokeApiKeySuccess + +type RevokeApiKeySuccess { + apiKey: ApiKey! +} + type SaveArticleReadingProgressError { errorCodes: [SaveArticleReadingProgressErrorCode!]! } diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index 8cd6f601f..f6c067e75 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -2,14 +2,14 @@ import { ApiKeysError, ApiKeysErrorCode, ApiKeysSuccess, - DeleteApiKeyError, - DeleteApiKeyErrorCode, - DeleteApiKeySuccess, GenerateApiKeyError, GenerateApiKeyErrorCode, GenerateApiKeySuccess, - MutationDeleteApiKeyArgs, MutationGenerateApiKeyArgs, + MutationRevokeApiKeyArgs, + RevokeApiKeyError, + RevokeApiKeyErrorCode, + RevokeApiKeySuccess, } from '../../generated/graphql' import { analytics } from '../../utils/analytics' import { env } from '../../env' @@ -32,6 +32,7 @@ export const apiKeysResolver = authorized( } const apiKeys = await getRepository(ApiKey).find({ + select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'], where: { user: { id: uid } }, order: { usedAt: 'DESC', createdAt: 'DESC' }, }) @@ -75,7 +76,7 @@ export const generateApiKeyResolver = authorized< const exp = new Date(expiresAt) const apiKey = generateApiKey() - await getRepository(ApiKey).save({ + const apiKeyData = await getRepository(ApiKey).save({ user: { id: uid }, name, key: hashApiKey(apiKey), @@ -92,7 +93,12 @@ export const generateApiKeyResolver = authorized< }, }) - return { apiKey } + return { + apiKey: { + ...apiKeyData, + key: apiKey, + }, + } } catch (error) { console.error(error) @@ -100,18 +106,18 @@ export const generateApiKeyResolver = authorized< } }) -export const deleteApiKeyResolver = authorized< - DeleteApiKeySuccess, - DeleteApiKeyError, - MutationDeleteApiKeyArgs +export const revokeApiKeyResolver = authorized< + RevokeApiKeySuccess, + RevokeApiKeyError, + MutationRevokeApiKeyArgs >(async (_, { id }, { claims: { uid }, log }) => { - log.info('deleteApiKeyResolver') + log.info('RevokeApiKeyResolver') try { const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { return { - errorCodes: [DeleteApiKeyErrorCode.Unauthorized], + errorCodes: [RevokeApiKeyErrorCode.Unauthorized], } } @@ -121,27 +127,30 @@ export const deleteApiKeyResolver = authorized< }) if (!apiKey) { return { - errorCodes: [DeleteApiKeyErrorCode.NotFound], + errorCodes: [RevokeApiKeyErrorCode.NotFound], } } if (apiKey.user.id !== uid) { return { - errorCodes: [DeleteApiKeyErrorCode.Unauthorized], + errorCodes: [RevokeApiKeyErrorCode.Unauthorized], } } const deletedApiKey = await getRepository(ApiKey).remove(apiKey) - deletedApiKey.id = id return { - apiKey: deletedApiKey, + apiKey: { + ...deletedApiKey, + id, + key: null, + }, } } catch (e) { log.error(e) return { - errorCodes: [DeleteApiKeyErrorCode.BadRequest], + errorCodes: [RevokeApiKeyErrorCode.BadRequest], } } }) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 4a3c828a8..6fba0ae11 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -30,7 +30,6 @@ import { createLabelResolver, createNewsletterEmailResolver, createReminderResolver, - deleteApiKeyResolver, deleteHighlightResolver, deleteLabelResolver, deleteNewsletterEmailResolver, @@ -56,6 +55,7 @@ import { newsletterEmailsResolver, reminderResolver, reportItemResolver, + revokeApiKeyResolver, saveArticleReadingProgressResolver, saveFileResolver, savePageResolver, @@ -160,7 +160,7 @@ export const functionResolvers = { addPopularRead: addPopularReadResolver, setWebhook: setWebhookResolver, deleteWebhook: deleteWebhookResolver, - deleteApiKey: deleteApiKeyResolver, + revokeApiKey: revokeApiKeyResolver, }, Query: { me: getMeUserResolver, @@ -581,5 +581,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('DeleteWebhook'), ...resultResolveTypeResolver('Webhook'), ...resultResolveTypeResolver('ApiKeys'), - ...resultResolveTypeResolver('DeleteApiKey'), + ...resultResolveTypeResolver('RevokeApiKey'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 7fddad848..d868c230a 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1420,7 +1420,7 @@ const schema = gql` union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError type GenerateApiKeySuccess { - apiKey: String! + apiKey: ApiKey! } type GenerateApiKeyError { @@ -1679,6 +1679,7 @@ const schema = gql` type ApiKey { id: ID! name: String! + key: String scopes: [String!] createdAt: Date! expiresAt: Date! @@ -1694,17 +1695,17 @@ const schema = gql` BAD_REQUEST } - union DeleteApiKeyResult = DeleteApiKeySuccess | DeleteApiKeyError + union RevokeApiKeyResult = RevokeApiKeySuccess | RevokeApiKeyError - type DeleteApiKeySuccess { + type RevokeApiKeySuccess { apiKey: ApiKey! } - type DeleteApiKeyError { - errorCodes: [DeleteApiKeyErrorCode!]! + type RevokeApiKeyError { + errorCodes: [RevokeApiKeyErrorCode!]! } - enum DeleteApiKeyErrorCode { + enum RevokeApiKeyErrorCode { UNAUTHORIZED BAD_REQUEST NOT_FOUND @@ -1777,7 +1778,7 @@ const schema = gql` addPopularRead(name: String!): AddPopularReadResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! deleteWebhook(id: ID!): DeleteWebhookResult! - deleteApiKey(id: ID!): DeleteApiKeyResult! + revokeApiKey(id: ID!): RevokeApiKeyResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/test/resolvers/api_key.test.ts b/packages/api/test/resolvers/api_key.test.ts index a9a51d109..40503d434 100644 --- a/packages/api/test/resolvers/api_key.test.ts +++ b/packages/api/test/resolvers/api_key.test.ts @@ -22,7 +22,7 @@ const testAPIKey = (apiKey: string): supertest.Test => { return graphqlRequest(query, apiKey) } -describe('generate api key', () => { +describe('Api Key resolver', () => { const username = 'fake_user' let authToken: string @@ -46,15 +46,18 @@ describe('generate api key', () => { await deleteTestUser(username) }) - beforeEach(() => { - query = ` + describe('generate api key', () => { + beforeEach(() => { + query = ` mutation { generateApiKey(input: { name: "${name}" expiresAt: "${expiresAt}" }) { ... on GenerateApiKeySuccess { - apiKey + apiKey { + key + } } ... on GenerateApiKeyError { errorCodes @@ -62,33 +65,92 @@ describe('generate api key', () => { } } ` - }) - - context('when api key is not expired', () => { - before(() => { - name = 'test' - expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() }) - it('should generate an api key', async () => { - const response = await graphqlRequest(query, authToken) - expect(response.body.data.generateApiKey.apiKey).to.be.a('string') + context('when api key is not expired', () => { + before(() => { + name = 'test' + expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() + }) - return testAPIKey(response.body.data.generateApiKey.apiKey).expect(200) + it('should generate an api key', async () => { + const response = await graphqlRequest(query, authToken).expect(200) + expect(response.body.data.generateApiKey.apiKey.key).to.be.a('string') + + return testAPIKey(response.body.data.generateApiKey.apiKey.key).expect( + 200 + ) + }) + }) + + context('when api key is expired', () => { + before(() => { + name = 'test-expired' + expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() + }) + + it('should generate an expired api key', async () => { + const response = await graphqlRequest(query, authToken).expect(200) + expect(response.body.data.generateApiKey.apiKey.key).to.be.a('string') + + return testAPIKey(response.body.data.generateApiKey.apiKey.key).expect( + 500 + ) + }) }) }) - context('when api key is expired', () => { - before(() => { - name = 'test-expired' - expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() + describe('revoke api key', () => { + let apiKey: string + let apiKeyId: string + + before(async () => { + query = ` + mutation { + generateApiKey(input: { + name: "test-revoke" + expiresAt: "${new Date( + Date.now() + 1000 * 60 * 60 * 24 + ).toISOString()}" + }) { + ... on GenerateApiKeySuccess { + apiKey { + id + key + } + } + ... on GenerateApiKeyError { + errorCodes + } + } + } + ` + + const response = await graphqlRequest(query, authToken) + apiKey = response.body.data.generateApiKey.apiKey.key + apiKeyId = response.body.data.generateApiKey.apiKey.id }) - it('should generate an expired api key', async () => { - const response = await graphqlRequest(query, authToken) - expect(response.body.data.generateApiKey.apiKey).to.be.a('string') + it('should revoke an api key', async () => { + query = ` + mutation { + revokeApiKey(id: "${apiKeyId}") { + ... on RevokeApiKeySuccess { + apiKey { + id + } + } + ... on RevokeApiKeyError { + errorCodes + } + } + } + ` - return testAPIKey(response.body.data.generateApiKey.apiKey).expect(500) + const response = await graphqlRequest(query, authToken).expect(200) + expect(response.body.data.revokeApiKey.apiKey.id).to.be.a('string') + + return testAPIKey(apiKey).expect(500) }) }) }) From fa9c5b3efe91de3cde2bffec190a9793cf86241e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 18:33:18 +0800 Subject: [PATCH 09/11] Add test for list api keys --- packages/api/src/entity/api_key.ts | 4 +- packages/api/test/resolvers/api_key.test.ts | 57 ++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/api/src/entity/api_key.ts b/packages/api/src/entity/api_key.ts index b4170de91..f95335bdd 100644 --- a/packages/api/src/entity/api_key.ts +++ b/packages/api/src/entity/api_key.ts @@ -29,9 +29,9 @@ export class ApiKey { @CreateDateColumn() createdAt!: Date - @Column('date') + @Column('timestamp') expiresAt!: Date - @Column('date', { nullable: true }) + @Column('timestamp', { nullable: true }) usedAt!: Date | null } diff --git a/packages/api/test/resolvers/api_key.test.ts b/packages/api/test/resolvers/api_key.test.ts index 40503d434..18e12e775 100644 --- a/packages/api/test/resolvers/api_key.test.ts +++ b/packages/api/test/resolvers/api_key.test.ts @@ -30,6 +30,7 @@ describe('Api Key resolver', () => { let query: string let expiresAt: string let name: string + let apiKeyId: string before(async () => { // create test user and login @@ -102,7 +103,6 @@ describe('Api Key resolver', () => { describe('revoke api key', () => { let apiKey: string - let apiKeyId: string before(async () => { query = ` @@ -153,4 +153,59 @@ describe('Api Key resolver', () => { return testAPIKey(apiKey).expect(500) }) }) + + describe('get api keys', () => { + before(async () => { + name = 'test-get-api-keys' + query = ` + mutation { + generateApiKey(input: { + name: "${name}" + expiresAt: "${new Date( + Date.now() + 1000 * 60 * 60 * 24 + ).toISOString()}" + }) { + ... on GenerateApiKeySuccess { + apiKey { + id + key + } + } + ... on GenerateApiKeyError { + errorCodes + } + } + } + ` + + const response = await graphqlRequest(query, authToken) + apiKeyId = response.body.data.generateApiKey.apiKey.id + }) + + it('should get api keys', async () => { + query = ` + query { + apiKeys { + ... on ApiKeysSuccess { + apiKeys { + id + name + expiresAt + usedAt + } + } + ... on ApiKeysError { + errorCodes + } + } + } + ` + + const response = await graphqlRequest(query, authToken).expect(200) + expect(response.body.data.apiKeys.apiKeys).to.be.an('array') + expect(response.body.data.apiKeys.apiKeys[0].id).to.eql(apiKeyId) + expect(response.body.data.apiKeys.apiKeys[0].name).to.eql(name) + expect(response.body.data.apiKeys.apiKeys[0].usedAt).to.be.null + }) + }) }) From 6e16631f3b5f4c74c9660206f65595afa478e579 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 May 2022 18:37:10 +0800 Subject: [PATCH 10/11] Resolve conflicts --- packages/api/src/resolvers/function_resolvers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 6fba0ae11..603383a1b 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -84,7 +84,6 @@ import { updateUserResolver, uploadFileRequestResolver, validateUsernameResolver, - addPopularReadResolver, webhookResolver, webhooksResolver, } from './index' From c0bb67a6b5dd83de4d47b6af04f498448ac87a29 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 28 May 2022 22:00:01 +0800 Subject: [PATCH 11/11] Verify api key for routers too --- packages/api/src/apollo.ts | 23 +++----------- packages/api/src/routers/article_router.ts | 10 +++---- .../api/src/routers/svc/pdf_attachments.ts | 6 ++-- packages/api/src/utils/auth.ts | 30 +++++++++++++++++++ 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index ab27e26e0..c55ee5ecf 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/require-await */ import { ContextFunction } from 'apollo-server-core' -import { Claims, ClaimsToSet, ResolverContext } from './resolvers/types' +import { ClaimsToSet, ResolverContext } from './resolvers/types' import { SetClaimsRole } from './utils/dictionary' import Knex, { Transaction } from 'knex' import { ExpressContext } from 'apollo-server-express/dist/ApolloServer' @@ -24,7 +24,7 @@ import ScalarResolvers from './scalars' import * as Sentry from '@sentry/node' import { createPubSubClient } from './datalayer/pubsub' import { initModels } from './server' -import { claimsFromApiKey } from './utils/auth' +import { getClaimsByToken } from './utils/auth' const signToken = promisify(jwt.sign) const logger = buildLogger('app.dispatch') @@ -39,28 +39,13 @@ const contextFunc: ContextFunction = async ({ req, res, }) => { - let claims: Claims | undefined - - const token = req?.cookies?.auth || req?.headers?.authorization - logger.info(`handling gql request`, { query: req.body.query, variables: req.body.variables, }) - if (token) { - try { - jwt.verify(token, env.server.jwtSecret) && - (claims = jwt.decode(token) as Claims) - } catch (e) { - if (e instanceof jwt.JsonWebTokenError) { - logger.info(`not a jwt token, checking api key`, { token }) - claims = await claimsFromApiKey(token) - } else { - throw e - } - } - } + const token = req?.cookies?.auth || req?.headers?.authorization + const claims = await getClaimsByToken(token) async function setClaims( tx: Transaction, diff --git a/packages/api/src/routers/article_router.ts b/packages/api/src/routers/article_router.ts index 930a577ba..5774483d6 100644 --- a/packages/api/src/routers/article_router.ts +++ b/packages/api/src/routers/article_router.ts @@ -6,13 +6,12 @@ import express from 'express' import { CreateArticleErrorCode } from './../generated/graphql' import { isSiteBlockedForParse } from './../utils/blocked' import cors from 'cors' -import { env } from './../env' import { buildLogger } from './../utils/logger' -import * as jwt from 'jsonwebtoken' import { corsConfig } from '../utils/corsConfig' import { createPageSaveRequest } from '../services/create_page_save_request' import { initModels } from '../server' import { kx } from '../datalayer/knex_config' +import { getClaimsByToken } from '../utils/auth' const logger = buildLogger('app.dispatch') @@ -26,11 +25,12 @@ export function articleRouter() { } const token = req?.cookies?.auth || req?.headers?.authorization - if (!token || !jwt.verify(token, env.server.jwtSecret)) { - return res.status(401).send({ errorCode: 'UNAUTHORIZED' }) + const claims = await getClaimsByToken(token) + if (!claims) { + return res.status(401).send('UNAUTHORIZED') } - const { uid } = (jwt.decode(token) || {}) as { uid: string } + const { uid } = claims logger.info('Article saving request', { body: req.body, diff --git a/packages/api/src/routers/svc/pdf_attachments.ts b/packages/api/src/routers/svc/pdf_attachments.ts index c408d821b..85ea23d34 100644 --- a/packages/api/src/routers/svc/pdf_attachments.ts +++ b/packages/api/src/routers/svc/pdf_attachments.ts @@ -1,6 +1,5 @@ import express from 'express' import { env } from '../../env' -import * as jwt from 'jsonwebtoken' import { PageType, UploadFileStatus } from '../../generated/graphql' import { generateUploadFilePathName, @@ -17,6 +16,7 @@ import { generateSlug } from '../../utils/helpers' import { createPubSubClient } from '../../datalayer/pubsub' import { ArticleSavingRequestStatus, Page } from '../../elastic/types' import { createPage } from '../../elastic/pages' +import { getClaimsByToken } from '../../utils/auth' export function pdfAttachmentsRouter() { const router = express.Router() @@ -31,7 +31,7 @@ export function pdfAttachmentsRouter() { } const token = req?.headers?.authorization - if (!token || !jwt.verify(token, env.server.jwtSecret)) { + if (!(await getClaimsByToken(token))) { return res.status(401).send('UNAUTHORIZED') } @@ -94,7 +94,7 @@ export function pdfAttachmentsRouter() { } const token = req?.headers?.authorization - if (!token || !jwt.verify(token, env.server.jwtSecret)) { + if (!(await getClaimsByToken(token))) { return res.status(401).send('UNAUTHORIZED') } diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index 2d2481916..12b34cce3 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -4,6 +4,8 @@ import { Claims } from '../resolvers/types' import { getRepository } from '../entity/utils' import { ApiKey } from '../entity/api_key' import crypto from 'crypto' +import * as jwt from 'jsonwebtoken' +import { env } from '../env' export const hashPassword = async (password: string, salt = 10) => { return bcrypt.hash(password, salt) @@ -49,3 +51,31 @@ export const claimsFromApiKey = async (key: string): Promise => { exp, } } + +// verify jwt token first +// if valid then decode and return claims +// if expired then throw error +// if not valid then verify api key +export const getClaimsByToken = async ( + token: string | undefined +): Promise => { + let claims: Claims | undefined + + if (!token) { + return undefined + } + + try { + jwt.verify(token, env.server.jwtSecret) && + (claims = jwt.decode(token) as Claims) + } catch (e) { + if (e instanceof jwt.JsonWebTokenError) { + console.log(`not a jwt token, checking api key`, { token }) + claims = await claimsFromApiKey(token) + } else { + throw e + } + } + + return claims +}