Skip to content

Commit

Permalink
feat: add user api key backend support
Browse files Browse the repository at this point in the history
  • Loading branch information
moonrailgun committed Nov 3, 2024
1 parent 7aec9e7 commit f7b1d33
Show file tree
Hide file tree
Showing 10 changed files with 156 additions and 14 deletions.
44 changes: 43 additions & 1 deletion src/server/model/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { jwtVerify } from '../middleware/auth.js';
import { TRPCError } from '@trpc/server';
import { Prisma } from '@prisma/client';
import { AdapterUser } from '@auth/core/adapters';
import { md5 } from '../utils/common.js';
import { md5, sha256 } from '../utils/common.js';
import { logger } from '../utils/logger.js';
import { promUserCounter } from '../utils/prometheus/client.js';

Expand Down Expand Up @@ -341,3 +341,45 @@ export async function leaveWorkspace(userId: string, workspaceId: string) {
throw new Error('Leave Workspace Failed.');
}
}

/**
* Generate User Api Key, for user to call api
*/
export async function generateUserApiKey(userId: string, expiredAt?: Date) {
const apiKey = `sk_${sha256(`${userId}.${Date.now()}`)}`;

const result = await prisma.userApiKey.create({
data: {
apiKey,
userId,
expiredAt,
},
});

return result.apiKey;
}

/**
* Verify User Api Key
*/
export async function verifyUserApiKey(apiKey: string) {
const result = await prisma.userApiKey.findUnique({
where: {
apiKey,
},
select: {
user: true,
expiredAt: true,
},
});

if (result?.expiredAt && result.expiredAt.valueOf() < Date.now()) {
throw new Error('Api Key has been expired.');
}

if (!result) {
throw new Error('Api Key not found');
}

return result.user;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "UserApiKey" (
"apiKey" VARCHAR(128) NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
"expiredAt" TIMESTAMP(3),

CONSTRAINT "UserApiKey_pkey" PRIMARY KEY ("apiKey")
);

-- CreateIndex
CREATE UNIQUE INDEX "UserApiKey_apiKey_key" ON "UserApiKey"("apiKey");

-- AddForeignKey
ALTER TABLE "UserApiKey" ADD CONSTRAINT "UserApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
11 changes: 11 additions & 0 deletions src/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ model User {
accounts Account[]
sessions Session[]
workspaces WorkspacesOnUsers[]
apiKeys UserApiKey[]
}

model UserApiKey {
apiKey String @id @unique @db.VarChar(128)
userId String
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
expiredAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Account {
Expand Down
1 change: 1 addition & 0 deletions src/server/prisma/zod/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./user.js"
export * from "./userapikey.js"
export * from "./account.js"
export * from "./session.js"
export * from "./verificationtoken.js"
Expand Down
4 changes: 3 additions & 1 deletion src/server/prisma/zod/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema } from "./index.js"
import { CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteUserApiKey, RelatedUserApiKeyModelSchema } from "./index.js"

export const UserModelSchema = z.object({
id: z.string(),
Expand All @@ -21,6 +21,7 @@ export interface CompleteUser extends z.infer<typeof UserModelSchema> {
accounts: CompleteAccount[]
sessions: CompleteSession[]
workspaces: CompleteWorkspacesOnUsers[]
apiKeys: CompleteUserApiKey[]
}

/**
Expand All @@ -32,4 +33,5 @@ export const RelatedUserModelSchema: z.ZodSchema<CompleteUser> = z.lazy(() => Us
accounts: RelatedAccountModelSchema.array(),
sessions: RelatedSessionModelSchema.array(),
workspaces: RelatedWorkspacesOnUsersModelSchema.array(),
apiKeys: RelatedUserApiKeyModelSchema.array(),
}))
24 changes: 24 additions & 0 deletions src/server/prisma/zod/userapikey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteUser, RelatedUserModelSchema } from "./index.js"

export const UserApiKeyModelSchema = z.object({
apiKey: z.string(),
userId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
expiredAt: z.date().nullish(),
})

export interface CompleteUserApiKey extends z.infer<typeof UserApiKeyModelSchema> {
user: CompleteUser
}

/**
* RelatedUserApiKeyModelSchema contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedUserApiKeyModelSchema: z.ZodSchema<CompleteUserApiKey> = z.lazy(() => UserApiKeyModelSchema.extend({
user: RelatedUserModelSchema,
}))
25 changes: 20 additions & 5 deletions src/server/trpc/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getSession } from '@auth/express';
import { authConfig } from '../model/auth.js';
import { get } from 'lodash-es';
import { promTrpcRequest } from '../utils/prometheus/client.js';
import { verifyUserApiKey } from '../model/user.js';

export async function createContext({ req }: { req: Request }) {
const authorization = req.headers['authorization'] ?? '';
Expand Down Expand Up @@ -57,16 +58,30 @@ const isUser = middleware(async (opts) => {
const token = opts.ctx.token;

if (token) {
try {
const user = jwtVerify(token);
if (token.startsWith('sk_')) {
// auth with api key
const user = await verifyUserApiKey(token);

return opts.next({
ctx: {
user,
id: user.id,
username: user.username,
role: user.role,
},
});
} catch (err) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'TokenInvalid' });
} else {
// auth with jwt
try {
const user = jwtVerify(token);

return opts.next({
ctx: {
user,
},
});
} catch (err) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'TokenInvalid' });
}
}
}

Expand Down
14 changes: 13 additions & 1 deletion src/server/utils/__tests__/common.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { md5 } from '../common.js';
import { md5, sha256 } from '../common.js';

describe('md5', () => {
test('should return the correct md5 hash', () => {
Expand All @@ -21,3 +21,15 @@ describe('md5', () => {
expect(result1).not.toEqual(result2);
});
});

describe('sha256', () => {
test('should return the correct sha256 hash', () => {
const input = 'test';
const expectedHash =
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08';

const result = sha256(input);

expect(result).toEqual(expectedHash);
});
});
7 changes: 7 additions & 0 deletions src/server/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export function hashUuid(...args: string[]) {
return v5(hash(...args), v5.DNS);
}

export function sha512(input: string) {
return hash(input);
}

export function sha256(input: string) {
return crypto.createHash('sha256').update(input).digest('hex');
}
/**
* generate hash with md5
* which use in unimportant scene
Expand Down
24 changes: 18 additions & 6 deletions src/server/ws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { socketEventBus } from './shared.js';
import { isCuid } from '../utils/common.js';
import { logger } from '../utils/logger.js';
import { getAuthSession, UserAuthPayload } from '../model/auth.js';
import { verifyUserApiKey } from '../model/user.js';

export function initSocketio(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
Expand All @@ -28,12 +29,23 @@ export function initSocketio(httpServer: HTTPServer) {
let user: UserAuthPayload;

if (token) {
user = jwtVerify(token);
logger.info(
'[WebSocket] Authenticated via JWT:',
user.id,
user.username
);
if (token.startsWith('sk_')) {
// auth with api key
const _user = await verifyUserApiKey(token);

user = {
id: _user.id,
username: _user.username,
role: _user.role,
};
} else {
user = jwtVerify(token);
logger.info(
'[WebSocket] Authenticated via JWT:',
user.id,
user.username
);
}
} else {
const session = await getAuthSession(
socket.request,
Expand Down

0 comments on commit f7b1d33

Please sign in to comment.