Merge pull request #716 from omnivore-app/api-token

Api tokens
This commit is contained in:
Hongbo Wu
2022-05-31 14:22:23 +08:00
committed by GitHub
17 changed files with 651 additions and 83 deletions

View File

@ -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<ExpressContext, ResolverContext> = 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,

View File

@ -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
}

View File

@ -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<Scalars['String']>;
name: Scalars['String'];
scopes?: Maybe<Array<Scalars['String']>>;
usedAt?: Maybe<Scalars['Date']>;
};
export type ApiKeysError = {
__typename?: 'ApiKeysError';
errorCodes: Array<ApiKeysErrorCode>;
};
export enum ApiKeysErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type ApiKeysResult = ApiKeysError | ApiKeysSuccess;
export type ApiKeysSuccess = {
__typename?: 'ApiKeysSuccess';
apiKeys: Array<ApiKey>;
};
export type ArchiveLinkError = {
__typename?: 'ArchiveLinkError';
errorCodes: Array<ArchiveLinkErrorCode>;
@ -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<Scalars['Date']>;
scope?: InputMaybe<Scalars['String']>;
expiresAt: Scalars['Date'];
name: Scalars['String'];
scopes?: InputMaybe<Array<Scalars['String']>>;
};
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<RevokeApiKeyErrorCode>;
};
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<SaveArticleReadingProgressErrorCode>;
@ -2272,6 +2328,11 @@ export type ResolversTypes = {
AddPopularReadErrorCode: AddPopularReadErrorCode;
AddPopularReadResult: ResolversTypes['AddPopularReadError'] | ResolversTypes['AddPopularReadSuccess'];
AddPopularReadSuccess: ResolverTypeWrapper<AddPopularReadSuccess>;
ApiKey: ResolverTypeWrapper<ApiKey>;
ApiKeysError: ResolverTypeWrapper<ApiKeysError>;
ApiKeysErrorCode: ApiKeysErrorCode;
ApiKeysResult: ResolversTypes['ApiKeysError'] | ResolversTypes['ApiKeysSuccess'];
ApiKeysSuccess: ResolverTypeWrapper<ApiKeysSuccess>;
ArchiveLinkError: ResolverTypeWrapper<ArchiveLinkError>;
ArchiveLinkErrorCode: ArchiveLinkErrorCode;
ArchiveLinkInput: ArchiveLinkInput;
@ -2444,6 +2505,10 @@ export type ResolversTypes = {
ReportItemInput: ReportItemInput;
ReportItemResult: ResolverTypeWrapper<ReportItemResult>;
ReportType: ReportType;
RevokeApiKeyError: ResolverTypeWrapper<RevokeApiKeyError>;
RevokeApiKeyErrorCode: RevokeApiKeyErrorCode;
RevokeApiKeyResult: ResolversTypes['RevokeApiKeyError'] | ResolversTypes['RevokeApiKeySuccess'];
RevokeApiKeySuccess: ResolverTypeWrapper<RevokeApiKeySuccess>;
SaveArticleReadingProgressError: ResolverTypeWrapper<SaveArticleReadingProgressError>;
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<ContextType = ResolverContext, Parent
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ApiKeyResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKey'] = ResolversParentTypes['ApiKey']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
expiresAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
key?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
scopes?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
usedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ApiKeysErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKeysError'] = ResolversParentTypes['ApiKeysError']> = {
errorCodes?: Resolver<Array<ResolversTypes['ApiKeysErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ApiKeysResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKeysResult'] = ResolversParentTypes['ApiKeysResult']> = {
__resolveType: TypeResolveFn<'ApiKeysError' | 'ApiKeysSuccess', ParentType, ContextType>;
};
export type ApiKeysSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKeysSuccess'] = ResolversParentTypes['ApiKeysSuccess']> = {
apiKeys?: Resolver<Array<ResolversTypes['ApiKey']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ArchiveLinkErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ArchiveLinkError'] = ResolversParentTypes['ArchiveLinkError']> = {
errorCodes?: Resolver<Array<ResolversTypes['ArchiveLinkErrorCode']>, ParentType, ContextType>;
message?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@ -3276,7 +3373,7 @@ export type GenerateApiKeyResultResolvers<ContextType = ResolverContext, ParentT
};
export type GenerateApiKeySuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GenerateApiKeySuccess'] = ResolversParentTypes['GenerateApiKeySuccess']> = {
apiKey?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
apiKey?: Resolver<ResolversTypes['ApiKey'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -3482,6 +3579,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
saveFile?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSaveFileArgs, 'input'>>;
savePage?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSavePageArgs, 'input'>>;
@ -3567,6 +3665,7 @@ export type ProfileResolvers<ContextType = ResolverContext, ParentType extends R
};
export type QueryResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = {
apiKeys?: Resolver<ResolversTypes['ApiKeysResult'], ParentType, ContextType>;
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
articles?: Resolver<ResolversTypes['ArticlesResult'], ParentType, ContextType, Partial<QueryArticlesArgs>>;
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, RequireFields<QueryArticleSavingRequestArgs, 'id'>>;
@ -3633,6 +3732,20 @@ export type ReportItemResultResolvers<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RevokeApiKeyErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RevokeApiKeyError'] = ResolversParentTypes['RevokeApiKeyError']> = {
errorCodes?: Resolver<Array<ResolversTypes['RevokeApiKeyErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RevokeApiKeyResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RevokeApiKeyResult'] = ResolversParentTypes['RevokeApiKeyResult']> = {
__resolveType: TypeResolveFn<'RevokeApiKeyError' | 'RevokeApiKeySuccess', ParentType, ContextType>;
};
export type RevokeApiKeySuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RevokeApiKeySuccess'] = ResolversParentTypes['RevokeApiKeySuccess']> = {
apiKey?: Resolver<ResolversTypes['ApiKey'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveArticleReadingProgressErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveArticleReadingProgressError'] = ResolversParentTypes['SaveArticleReadingProgressError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SaveArticleReadingProgressErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4163,6 +4276,10 @@ export type Resolvers<ContextType = ResolverContext> = {
AddPopularReadError?: AddPopularReadErrorResolvers<ContextType>;
AddPopularReadResult?: AddPopularReadResultResolvers<ContextType>;
AddPopularReadSuccess?: AddPopularReadSuccessResolvers<ContextType>;
ApiKey?: ApiKeyResolvers<ContextType>;
ApiKeysError?: ApiKeysErrorResolvers<ContextType>;
ApiKeysResult?: ApiKeysResultResolvers<ContextType>;
ApiKeysSuccess?: ApiKeysSuccessResolvers<ContextType>;
ArchiveLinkError?: ArchiveLinkErrorResolvers<ContextType>;
ArchiveLinkResult?: ArchiveLinkResultResolvers<ContextType>;
ArchiveLinkSuccess?: ArchiveLinkSuccessResolvers<ContextType>;
@ -4279,6 +4396,9 @@ export type Resolvers<ContextType = ResolverContext> = {
ReminderResult?: ReminderResultResolvers<ContextType>;
ReminderSuccess?: ReminderSuccessResolvers<ContextType>;
ReportItemResult?: ReportItemResultResolvers<ContextType>;
RevokeApiKeyError?: RevokeApiKeyErrorResolvers<ContextType>;
RevokeApiKeyResult?: RevokeApiKeyResultResolvers<ContextType>;
RevokeApiKeySuccess?: RevokeApiKeySuccessResolvers<ContextType>;
SaveArticleReadingProgressError?: SaveArticleReadingProgressErrorResolvers<ContextType>;
SaveArticleReadingProgressResult?: SaveArticleReadingProgressResultResolvers<ContextType>;
SaveArticleReadingProgressSuccess?: SaveArticleReadingProgressSuccessResolvers<ContextType>;

View File

@ -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!]!
}

View File

@ -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<ApiKeysSuccess, ApiKeysError>(
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],
}
}
})

View File

@ -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'),
}

View File

@ -18,3 +18,4 @@ export * from './subscriptions'
export * from './update'
export * from './popular_reads'
export * from './webhooks'
export * from './api_key'

View File

@ -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,

View File

@ -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,

View File

@ -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')
}

View File

@ -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!
}
`

View File

@ -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<Claims> => {
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<Claims | undefined> => {
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
}

View File

@ -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')

View File

@ -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
})
})
})

View File

@ -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

View File

@ -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;

View File

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: api_key
-- Description: api_key model
BEGIN;
DROP TABLE omnivore.api_key;
COMMIT;