mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
feat(server): support access token (#13372)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced user access tokens, enabling users to generate, list, and revoke personal access tokens via the GraphQL API. * Added GraphQL mutations and queries for managing access tokens, including token creation (with optional expiration), listing, and revocation. * Implemented authentication support for private API endpoints using access tokens in addition to session cookies. * **Bug Fixes** * None. * **Tests** * Added comprehensive tests for access token creation, listing, revocation, expiration handling, and authentication using tokens. * **Chores** * Updated backend models, schema, and database migrations to support access token functionality. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "access_tokens" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"token" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "access_tokens_token_key" ON "access_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "access_tokens_user_id_idx" ON "access_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -49,6 +49,7 @@ model User {
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -949,3 +950,17 @@ model CommentAttachment {
|
||||
@@id([workspaceId, docId, key])
|
||||
@@map("comment_attachments")
|
||||
}
|
||||
|
||||
model AccessToken {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
token String @unique @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("access_tokens")
|
||||
}
|
||||
|
||||
@@ -96,6 +96,21 @@ test('should be able to visit private api if signed in', async t => {
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with access token', async t => {
|
||||
const models = t.context.app.get(Models);
|
||||
const token = await models.accessToken.create({
|
||||
userId: u1.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const res = await request(server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${token.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to parse session cookie', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
await request(server)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AccessToken } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { Mocker } from './factory';
|
||||
|
||||
export type MockAccessTokenInput = Omit<
|
||||
Prisma.AccessTokenUncheckedCreateInput,
|
||||
'token'
|
||||
>;
|
||||
|
||||
export type MockedAccessToken = AccessToken;
|
||||
|
||||
export class MockAccessToken extends Mocker<
|
||||
MockAccessTokenInput,
|
||||
MockedAccessToken
|
||||
> {
|
||||
override async create(input: MockAccessTokenInput) {
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
...input,
|
||||
name: input.name ?? faker.lorem.word(),
|
||||
token: 'ut_' + faker.string.hexadecimal({ length: 37 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './user.mock';
|
||||
export * from './workspace.mock';
|
||||
export * from './workspace-user.mock';
|
||||
|
||||
import { MockAccessToken } from './access-token.mock';
|
||||
import { MockCopilotProvider } from './copilot.mock';
|
||||
import { MockDocMeta } from './doc-meta.mock';
|
||||
import { MockDocSnapshot } from './doc-snapshot.mock';
|
||||
@@ -26,6 +27,7 @@ export const Mockers = {
|
||||
DocMeta: MockDocMeta,
|
||||
DocSnapshot: MockDocSnapshot,
|
||||
DocUser: MockDocUser,
|
||||
AccessToken: MockAccessToken,
|
||||
};
|
||||
|
||||
export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };
|
||||
|
||||
@@ -28,6 +28,7 @@ import { RedisModule } from './base/redis';
|
||||
import { StorageProviderModule } from './base/storage';
|
||||
import { RateLimiterModule } from './base/throttler';
|
||||
import { WebSocketModule } from './base/websocket';
|
||||
import { AccessTokenModule } from './core/access-token';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { CommentModule } from './core/comment';
|
||||
import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
|
||||
@@ -187,7 +188,8 @@ export function buildAppModule(env: Env) {
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CustomerIoModule,
|
||||
CommentModule
|
||||
CommentModule,
|
||||
AccessTokenModule
|
||||
)
|
||||
// doc service only
|
||||
.useIf(() => env.flavors.doc, DocServiceModule)
|
||||
|
||||
8
packages/backend/server/src/core/access-token/index.ts
Normal file
8
packages/backend/server/src/core/access-token/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessTokenResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [AccessTokenResolver],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
73
packages/backend/server/src/core/access-token/resolver.ts
Normal file
73
packages/backend/server/src/core/access-token/resolver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
|
||||
@ObjectType()
|
||||
class AccessToken {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class RevealedAccessToken extends AccessToken {
|
||||
@Field()
|
||||
token!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class GenerateAccessTokenInput {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@Query(() => [AccessToken])
|
||||
async accessTokens(@CurrentUser() user: CurrentUser): Promise<AccessToken[]> {
|
||||
return await this.models.accessToken.list(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedAccessToken)
|
||||
async generateUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokeUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../../base';
|
||||
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
||||
import { AuthService } from './service';
|
||||
import { Session } from './session';
|
||||
import { Session, TokenSession } from './session';
|
||||
|
||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
|
||||
@@ -56,10 +56,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
throw new AccessDenied('Invalid internal request');
|
||||
}
|
||||
|
||||
const userSession = await this.signIn(req, res);
|
||||
if (res && userSession && userSession.expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession);
|
||||
}
|
||||
const authedUser = await this.signIn(req, res);
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||
@@ -71,14 +68,29 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!userSession) {
|
||||
if (!authedUser) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async signIn(req: Request, res?: Response): Promise<Session | null> {
|
||||
async signIn(
|
||||
req: Request,
|
||||
res?: Response
|
||||
): Promise<Session | TokenSession | null> {
|
||||
const userSession = await this.signInWithCookie(req, res);
|
||||
if (userSession) {
|
||||
return userSession;
|
||||
}
|
||||
|
||||
return await this.signInWithAccessToken(req);
|
||||
}
|
||||
|
||||
async signInWithCookie(
|
||||
req: Request,
|
||||
res?: Response
|
||||
): Promise<Session | null> {
|
||||
if (req.session) {
|
||||
return req.session;
|
||||
}
|
||||
@@ -87,6 +99,10 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
const userSession = await this.auth.getUserSessionFromRequest(req, res);
|
||||
|
||||
if (userSession) {
|
||||
if (res) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession.session);
|
||||
}
|
||||
|
||||
req.session = {
|
||||
...userSession.session,
|
||||
user: userSession.user,
|
||||
@@ -97,6 +113,25 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
|
||||
if (req.token) {
|
||||
return req.token;
|
||||
}
|
||||
|
||||
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
|
||||
|
||||
if (tokenSession) {
|
||||
req.token = {
|
||||
...tokenSession.token,
|
||||
user: tokenSession.user,
|
||||
};
|
||||
|
||||
return req.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -264,6 +264,36 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return session;
|
||||
}
|
||||
|
||||
async getTokenSessionFromRequest(req: Request) {
|
||||
const tokenHeader = req.headers.authorization;
|
||||
if (!tokenHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenValue = extractTokenFromHeader(tokenHeader);
|
||||
|
||||
if (!tokenValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = await this.models.accessToken.getByToken(tokenValue);
|
||||
|
||||
if (token) {
|
||||
const user = await this.models.user.get(token.userId);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: sessionUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
id: string,
|
||||
newPassword: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { AccessToken } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../base';
|
||||
import type { User, UserSession } from '../../models';
|
||||
@@ -40,7 +41,8 @@ import type { User, UserSession } from '../../models';
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getRequestResponseFromContext(context).req.session?.user;
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.session?.user ?? req.token?.user;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -61,3 +63,7 @@ export const Session = createParamDecorator(
|
||||
export type Session = UserSession & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
export type TokenSession = AccessToken & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
1
packages/backend/server/src/global.d.ts
vendored
1
packages/backend/server/src/global.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
session?: import('./core/auth/session').Session;
|
||||
token?: import('./core/auth/session').TokenSession;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Due } from '../../base';
|
||||
import { Models } from '..';
|
||||
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
t.is(token.userId, user.id);
|
||||
t.is(token.name, 'test');
|
||||
t.truthy(token.token);
|
||||
t.truthy(token.createdAt);
|
||||
t.is(token.expiresAt, null);
|
||||
});
|
||||
|
||||
test('should create access token with expiration', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.after('30d'),
|
||||
});
|
||||
|
||||
t.truthy(token.expiresAt);
|
||||
t.truthy(token.expiresAt! > new Date());
|
||||
});
|
||||
|
||||
test('should list access tokens without token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
await module.create(Mockers.AccessToken, { userId: user.id }, 3);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 3);
|
||||
// @ts-expect-error not exists
|
||||
t.is(listed[0].token, undefined);
|
||||
});
|
||||
|
||||
test('should be able to revoke access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
|
||||
await models.accessToken.revoke(token.id, user.id);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 0);
|
||||
});
|
||||
|
||||
test('should be able to get access token by token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found?.id, token.id);
|
||||
t.is(found?.userId, user.id);
|
||||
t.is(found?.name, token.name);
|
||||
});
|
||||
|
||||
test('should not get expired access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, {
|
||||
userId: user.id,
|
||||
expiresAt: Due.before('1s'),
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found, null);
|
||||
});
|
||||
70
packages/backend/server/src/models/access-token.ts
Normal file
70
packages/backend/server/src/models/access-token.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface CreateAccessTokenInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
return await this.db.accessToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(input: CreateAccessTokenInput) {
|
||||
let token = 'ut_' + this.crypto.randomBytes(40).toString('hex');
|
||||
token = token.substring(0, 40);
|
||||
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
token,
|
||||
...input,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(id: string, userId: string) {
|
||||
await this.db.accessToken.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByToken(token: string) {
|
||||
return await this.db.accessToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
OR: [
|
||||
{
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ApplyType } from '../base';
|
||||
import { AccessTokenModel } from './access-token';
|
||||
import { BlobModel } from './blob';
|
||||
import { CommentModel } from './comment';
|
||||
import { CommentAttachmentModel } from './comment-attachment';
|
||||
@@ -54,6 +55,7 @@ const MODELS = {
|
||||
comment: CommentModel,
|
||||
commentAttachment: CommentAttachmentModel,
|
||||
blob: BlobModel,
|
||||
accessToken: AccessTokenModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
type AccessToken {
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input AddContextBlobInput {
|
||||
blobId: String!
|
||||
contextId: String!
|
||||
@@ -812,6 +819,11 @@ input ForkChatSessionInput {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
input GenerateAccessTokenInput {
|
||||
expiresAt: DateTime
|
||||
name: String!
|
||||
}
|
||||
|
||||
input GrantDocUserRolesInput {
|
||||
docId: String!
|
||||
role: DocRole!
|
||||
@@ -1247,6 +1259,7 @@ type Mutation {
|
||||
"""Create a chat session"""
|
||||
forkCopilotSession(options: ForkChatSessionInput!): String!
|
||||
generateLicenseKey(sessionId: String!): String!
|
||||
generateUserAccessToken(input: GenerateAccessTokenInput!): RevealedAccessToken!
|
||||
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
|
||||
grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean!
|
||||
|
||||
@@ -1302,6 +1315,7 @@ type Mutation {
|
||||
revokeMember(userId: String!, workspaceId: String!): Boolean!
|
||||
revokePublicDoc(docId: String!, workspaceId: String!): DocType!
|
||||
revokePublicPage(docId: String!, workspaceId: String!): DocType! @deprecated(reason: "use revokePublicDoc instead")
|
||||
revokeUserAccessToken(id: String!): Boolean!
|
||||
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
||||
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
@@ -1536,6 +1550,8 @@ type PublicUserType {
|
||||
}
|
||||
|
||||
type Query {
|
||||
accessTokens: [AccessToken!]!
|
||||
|
||||
"""get the whole app configuration"""
|
||||
appConfig: JSONObject!
|
||||
|
||||
@@ -1685,6 +1701,14 @@ input ReplyUpdateInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type RevealedAccessToken {
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime
|
||||
id: String!
|
||||
name: String!
|
||||
token: String!
|
||||
}
|
||||
|
||||
input RevokeDocUserRoleInput {
|
||||
docId: String!
|
||||
userId: String!
|
||||
|
||||
Reference in New Issue
Block a user