diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index 3ab478a45..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,6 +24,7 @@ import ScalarResolvers from './scalars' import * as Sentry from '@sentry/node' import { createPubSubClient } from './datalayer/pubsub' import { initModels } from './server' +import { getClaimsByToken } from './utils/auth' const signToken = promisify(jwt.sign) const logger = buildLogger('app.dispatch') @@ -38,18 +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 && jwt.verify(token, env.server.jwtSecret)) { - claims = jwt.decode(token) as Claims - } + const token = req?.cookies?.auth || req?.headers?.authorization + const claims = await getClaimsByToken(token) async function setClaims( tx: Transaction, diff --git a/packages/api/src/entity/api_key.ts b/packages/api/src/entity/api_key.ts new file mode 100644 index 000000000..f95335bdd --- /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('timestamp') + expiresAt!: Date + + @Column('timestamp', { nullable: true }) + usedAt!: Date | null +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index acd827f79..7cff57dde 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -34,6 +34,34 @@ export type AddPopularReadSuccess = { pageId: Scalars['String']; }; +export type ApiKey = { + __typename?: 'ApiKey'; + createdAt: Scalars['Date']; + expiresAt: Scalars['Date']; + id: Scalars['ID']; + key?: Maybe; + 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; @@ -570,19 +598,22 @@ 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; export type GenerateApiKeySuccess = { __typename?: 'GenerateApiKeySuccess'; - apiKey: Scalars['String']; + apiKey: ApiKey; }; export type GetFollowersError = { @@ -843,6 +874,7 @@ export type Mutation = { logOut: LogOutResult; mergeHighlight: MergeHighlightResult; reportItem: ReportItemResult; + revokeApiKey: RevokeApiKeyResult; saveArticleReadingProgress: SaveArticleReadingProgressResult; saveFile: SaveResult; savePage: SaveResult; @@ -977,6 +1009,11 @@ export type MutationReportItemArgs = { }; +export type MutationRevokeApiKeyArgs = { + id: Scalars['ID']; +}; + + export type MutationSaveArticleReadingProgressArgs = { input: SaveArticleReadingProgressInput; }; @@ -1192,6 +1229,7 @@ export type Profile = { export type Query = { __typename?: 'Query'; + apiKeys: ApiKeysResult; article: ArticleResult; articles: ArticlesResult; articleSavingRequest: ArticleSavingRequestResult; @@ -1365,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; @@ -2272,6 +2328,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; @@ -2444,6 +2505,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; @@ -2608,6 +2673,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']; @@ -2745,6 +2814,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']; @@ -2891,6 +2963,31 @@ export type AddPopularReadSuccessResolvers; }; +export type ApiKeyResolvers = { + createdAt?: Resolver; + expiresAt?: Resolver; + id?: Resolver; + key?: Resolver, ParentType, ContextType>; + 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; @@ -3276,7 +3373,7 @@ export type GenerateApiKeyResultResolvers = { - apiKey?: Resolver; + apiKey?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -3482,6 +3579,7 @@ export type MutationResolvers; mergeHighlight?: Resolver>; reportItem?: Resolver>; + revokeApiKey?: Resolver>; saveArticleReadingProgress?: Resolver>; saveFile?: Resolver>; savePage?: Resolver>; @@ -3567,6 +3665,7 @@ export type ProfileResolvers = { + apiKeys?: Resolver; article?: Resolver>; articles?: Resolver>; articleSavingRequest?: Resolver>; @@ -3633,6 +3732,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; @@ -4163,6 +4276,10 @@ export type Resolvers = { AddPopularReadError?: AddPopularReadErrorResolvers; AddPopularReadResult?: AddPopularReadResultResolvers; AddPopularReadSuccess?: AddPopularReadSuccessResolvers; + ApiKey?: ApiKeyResolvers; + ApiKeysError?: ApiKeysErrorResolvers; + ApiKeysResult?: ApiKeysResultResolvers; + ApiKeysSuccess?: ApiKeysSuccessResolvers; ArchiveLinkError?: ArchiveLinkErrorResolvers; ArchiveLinkResult?: ArchiveLinkResultResolvers; ArchiveLinkSuccess?: ArchiveLinkSuccessResolvers; @@ -4279,6 +4396,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 c2a0925b5..53b41e78a 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -16,6 +16,31 @@ type AddPopularReadSuccess { pageId: String! } +type ApiKey { + createdAt: Date! + expiresAt: Date! + id: ID! + key: String + 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! @@ -500,18 +525,21 @@ 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 type GenerateApiKeySuccess { - apiKey: String! + apiKey: ApiKey! } type GetFollowersError { @@ -749,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! @@ -856,6 +885,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! @@ -944,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 538a7c837..f6c067e75 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -1,43 +1,156 @@ import { + ApiKeysError, + ApiKeysErrorCode, + ApiKeysSuccess, GenerateApiKeyError, GenerateApiKeyErrorCode, GenerateApiKeySuccess, MutationGenerateApiKeyArgs, + MutationRevokeApiKeyArgs, + RevokeApiKeyError, + RevokeApiKeyErrorCode, + RevokeApiKeySuccess, } 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, 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({ + select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'], + 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, 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() + const apiKeyData = await getRepository(ApiKey).save({ + user: { id: uid }, + name, + key: hashApiKey(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, }, }) - return { apiKey } + return { + apiKey: { + ...apiKeyData, + key: apiKey, + }, + } } catch (error) { console.error(error) + return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] } } }) + +export const revokeApiKeyResolver = authorized< + RevokeApiKeySuccess, + RevokeApiKeyError, + MutationRevokeApiKeyArgs +>(async (_, { id }, { claims: { uid }, log }) => { + log.info('RevokeApiKeyResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [RevokeApiKeyErrorCode.Unauthorized], + } + } + + const apiKey = await getRepository(ApiKey).findOne({ + where: { id }, + relations: ['user'], + }) + if (!apiKey) { + return { + errorCodes: [RevokeApiKeyErrorCode.NotFound], + } + } + + if (apiKey.user.id !== uid) { + return { + errorCodes: [RevokeApiKeyErrorCode.Unauthorized], + } + } + + const deletedApiKey = await getRepository(ApiKey).remove(apiKey) + + return { + apiKey: { + ...deletedApiKey, + id, + key: null, + }, + } + } catch (e) { + log.error(e) + + return { + errorCodes: [RevokeApiKeyErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index de13e589d..603383a1b 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, @@ -33,6 +35,7 @@ import { deleteNewsletterEmailResolver, deleteReminderResolver, deleteWebhookResolver, + generateApiKeyResolver, getAllUsersResolver, getArticleResolver, getArticlesResolver, @@ -52,6 +55,7 @@ import { newsletterEmailsResolver, reminderResolver, reportItemResolver, + revokeApiKeyResolver, saveArticleReadingProgressResolver, saveFileResolver, savePageResolver, @@ -80,7 +84,6 @@ import { updateUserResolver, uploadFileRequestResolver, validateUsernameResolver, - addPopularReadResolver, webhookResolver, webhooksResolver, } from './index' @@ -90,7 +93,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 +159,7 @@ export const functionResolvers = { addPopularRead: addPopularReadResolver, setWebhook: setWebhookResolver, deleteWebhook: deleteWebhookResolver, + revokeApiKey: revokeApiKeyResolver, }, Query: { me: getMeUserResolver, @@ -178,6 +181,7 @@ export const functionResolvers = { subscriptions: subscriptionsResolver, webhooks: webhooksResolver, webhook: webhookResolver, + apiKeys: apiKeysResolver, }, User: { async sharedArticles( @@ -575,4 +579,6 @@ export const functionResolvers = { ...resultResolveTypeResolver('Webhooks'), ...resultResolveTypeResolver('DeleteWebhook'), ...resultResolveTypeResolver('Webhook'), + ...resultResolveTypeResolver('ApiKeys'), + ...resultResolveTypeResolver('RevokeApiKey'), } 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' diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 6cd7bce63..3c8673c5f 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -310,7 +310,7 @@ export const loginResolver: ResolverFn< } // check if password is correct - const validPassword = comparePassword(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 = hashPassword(password) + const hashedPassword = await hashPassword(password) const [user, profile] = await createUser({ email, 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/schema.ts b/packages/api/src/schema.ts index 1160f7314..236056d1f 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1415,14 +1415,15 @@ const schema = gql` } input GenerateApiKeyInput { - scope: String - expiredAt: Date + name: String! + scopes: [String!] + expiresAt: Date! } union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError type GenerateApiKeySuccess { - apiKey: String! + apiKey: ApiKey! } type GenerateApiKeyError { @@ -1431,6 +1432,8 @@ const schema = gql` enum GenerateApiKeyErrorCode { BAD_REQUEST + ALREADY_EXISTS + UNAUTHORIZED } # Query: search @@ -1670,6 +1673,47 @@ const schema = gql` BAD_REQUEST } + union ApiKeysResult = ApiKeysSuccess | ApiKeysError + + type ApiKeysSuccess { + apiKeys: [ApiKey!]! + } + + type ApiKey { + id: ID! + name: String! + key: String + scopes: [String!] + createdAt: Date! + expiresAt: Date! + usedAt: Date + } + + type ApiKeysError { + errorCodes: [ApiKeysErrorCode!]! + } + + enum ApiKeysErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + union RevokeApiKeyResult = RevokeApiKeySuccess | RevokeApiKeyError + + type RevokeApiKeySuccess { + apiKey: ApiKey! + } + + type RevokeApiKeyError { + errorCodes: [RevokeApiKeyErrorCode!]! + } + + enum RevokeApiKeyErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1737,6 +1781,7 @@ const schema = gql` addPopularRead(name: String!): AddPopularReadResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! deleteWebhook(id: ID!): DeleteWebhookResult! + revokeApiKey(id: ID!): RevokeApiKeyResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed @@ -1778,6 +1823,7 @@ const schema = gql` subscriptions(sort: SortParams): SubscriptionsResult! webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! + apiKeys: ApiKeysResult! } ` diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index e86eb80eb..12b34cce3 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -1,16 +1,81 @@ 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' +import crypto from 'crypto' import * as jwt from 'jsonwebtoken' import { env } from '../env' -import { Claims } from '../resolvers/types' -export const hashPassword = (password: string) => { - return bcrypt.hashSync(password, 10) +export const hashPassword = async (password: string, salt = 10) => { + return bcrypt.hash(password, salt) } -export const comparePassword = (password: string, hash: string) => { - return bcrypt.compareSync(password, hash) +export const comparePassword = async (password: string, hash: string) => { + return bcrypt.compare(password, 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() +} + +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, + }, + relations: ['user'], + }) + if (!apiKey) { + 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 + await getRepository(ApiKey).update(apiKey.id, { usedAt: new Date() }) + + return { + uid: apiKey.user.id, + iat, + 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 } diff --git a/packages/api/test/gql/sanitize-directive.test.ts b/packages/api/test/gql/sanitize-directive.test.ts index 0c81566ec..d4d8820b7 100644 --- a/packages/api/test/gql/sanitize-directive.test.ts +++ b/packages/api/test/gql/sanitize-directive.test.ts @@ -12,7 +12,7 @@ describe('Sanitize Directive', () => { let user: User before(async () => { - const hashedPassword = hashPassword(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..18e12e775 100644 --- a/packages/api/test/resolvers/api_key.test.ts +++ b/packages/api/test/resolvers/api_key.test.ts @@ -22,13 +22,15 @@ 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 let user: User let query: string - let expiredAt: string + let expiresAt: string + let name: string + let apiKeyId: string before(async () => { // create test user and login @@ -45,14 +47,18 @@ describe('generate api key', () => { await deleteTestUser(username) }) - beforeEach(() => { - query = ` + describe('generate api key', () => { + beforeEach(() => { + query = ` mutation { generateApiKey(input: { - expiredAt: "${expiredAt}" + name: "${name}" + expiresAt: "${expiresAt}" }) { ... on GenerateApiKeySuccess { - apiKey + apiKey { + key + } } ... on GenerateApiKeyError { errorCodes @@ -60,44 +66,146 @@ 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') + 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 not expired', () => { - before(() => { - expiredAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString() + describe('revoke api key', () => { + let apiKey: 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 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(200) + const response = await graphqlRequest(query, authToken).expect(200) + expect(response.body.data.revokeApiKey.apiKey.id).to.be.a('string') + + return testAPIKey(apiKey).expect(500) }) }) - context('when api key is expired', () => { - before(() => { - expiredAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() + 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 generate an expired api key', async () => { - const response = await graphqlRequest(query, authToken) - expect(response.body.data.generateApiKey.apiKey).to.be.a('string') + it('should get api keys', async () => { + query = ` + query { + apiKeys { + ... on ApiKeysSuccess { + apiKeys { + id + name + expiresAt + usedAt + } + } + ... on ApiKeysError { + errorCodes + } + } + } + ` - return testAPIKey(response.body.data.generateApiKey.apiKey).expect(500) + 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 }) }) }) diff --git a/packages/api/test/resolvers/user.test.ts b/packages/api/test/resolvers/user.test.ts index 07d49c851..7115ca7ed 100644 --- a/packages/api/test/resolvers/user.test.ts +++ b/packages/api/test/resolvers/user.test.ts @@ -21,7 +21,7 @@ describe('User API', () => { let anotherUser: User before(async () => { - const hashedPassword = hashPassword(correctPassword) + const hashedPassword = await hashPassword(correctPassword) // create test user and login user = await createTestUser(username, '', hashedPassword) const res = await request diff --git a/packages/db/migrations/0085.do.api_key.sql b/packages/db/migrations/0085.do.api_key.sql new file mode 100755 index 000000000..466ed4201 --- /dev/null +++ b/packages/db/migrations/0085.do.api_key.sql @@ -0,0 +1,21 @@ +-- 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, + UNIQUE (user_id, name) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.api_key TO omnivore_user; + +COMMIT; diff --git a/packages/db/migrations/0085.undo.api_key.sql b/packages/db/migrations/0085.undo.api_key.sql new file mode 100755 index 000000000..788fd4ca9 --- /dev/null +++ b/packages/db/migrations/0085.undo.api_key.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: api_key +-- Description: api_key model + +BEGIN; + +DROP TABLE omnivore.api_key; + +COMMIT;