refactor(server): auth (#5895)
Remove `next-auth` and implement our own Authorization/Authentication system from scratch.
## Server
- [x] tokens
- [x] function
- [x] encryption
- [x] AuthController
- [x] /api/auth/sign-in
- [x] /api/auth/sign-out
- [x] /api/auth/session
- [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!)
- [x] OAuthPlugin
- [x] OAuthController
- [x] /oauth/login
- [x] /oauth/callback
- [x] Providers
- [x] Google
- [x] GitHub
## Client
- [x] useSession
- [x] cloudSignIn
- [x] cloudSignOut
## NOTE:
Tests will be adding in the future
This commit is contained in:
40
packages/backend/server/src/core/user/controller.ts
Normal file
40
packages/backend/server/src/core/user/controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { AvatarStorage } from '../storage';
|
||||
|
||||
@Controller('/api/avatars')
|
||||
export class UserAvatarController {
|
||||
constructor(private readonly storage: AvatarStorage) {}
|
||||
|
||||
@Get('/:id')
|
||||
async getAvatar(@Res() res: Response, @Param('id') id: string) {
|
||||
if (this.storage.provider.type !== 'fs') {
|
||||
throw new ForbiddenException(
|
||||
'Only available when avatar storage provider set to fs.'
|
||||
);
|
||||
}
|
||||
|
||||
const { body, metadata } = await this.storage.get(id);
|
||||
|
||||
if (!body) {
|
||||
throw new NotFoundException(`Avatar ${id} not found.`);
|
||||
}
|
||||
|
||||
// metadata should always exists if body is not null
|
||||
if (metadata) {
|
||||
res.setHeader('content-type', metadata.contentType);
|
||||
res.setHeader('last-modified', metadata.lastModified.toISOString());
|
||||
res.setHeader('content-length', metadata.contentLength);
|
||||
}
|
||||
|
||||
body.pipe(res);
|
||||
}
|
||||
}
|
||||
20
packages/backend/server/src/core/user/index.ts
Normal file
20
packages/backend/server/src/core/user/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserManagementResolver } from './management';
|
||||
import { UserResolver } from './resolver';
|
||||
import { UserService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, FeatureModule, QuotaModule],
|
||||
providers: [UserResolver, UserManagementResolver, UserService],
|
||||
controllers: [UserAvatarController],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
export { UserService } from './service';
|
||||
export { UserType } from './types';
|
||||
91
packages/backend/server/src/core/user/management.ts
Normal file
91
packages/backend/server/src/core/user/management.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { UserService } from './service';
|
||||
import { UserType } from './types';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Resolver(() => UserType)
|
||||
export class UserManagementResolver {
|
||||
constructor(
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
} else {
|
||||
const user = await this.users.createAnonymousUser(email);
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User ${email} not found`);
|
||||
}
|
||||
return this.feature.removeEarlyAccess(user.id);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<UserType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
// allow query other user's subscription
|
||||
ctx.isAdminQuery = true;
|
||||
return this.feature.listEarlyAccess().then(users => {
|
||||
return users.map(sessionUser);
|
||||
});
|
||||
}
|
||||
}
|
||||
208
packages/backend/server/src/core/user/resolver.ts
Normal file
208
packages/backend/server/src/core/user/resolver.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { isNil, omitBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
EventEmitter,
|
||||
type FileUpload,
|
||||
PaymentRequiredException,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { QuotaService } from '../quota';
|
||||
import { AvatarStorage } from '../storage';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
RemoveAvatar,
|
||||
UpdateUserInput,
|
||||
UserOrLimitedUser,
|
||||
UserQuotaType,
|
||||
UserType,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly storage: AvatarStorage,
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly event: EventEmitter
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Query(() => UserOrLimitedUser, {
|
||||
name: 'user',
|
||||
description: 'Get user by email',
|
||||
nullable: true,
|
||||
})
|
||||
@Public()
|
||||
async user(
|
||||
@CurrentUser() currentUser?: CurrentUser,
|
||||
@Args('email') email?: string
|
||||
): Promise<typeof UserOrLimitedUser | null> {
|
||||
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
||||
throw new PaymentRequiredException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.users.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
// return empty response when user not exists
|
||||
if (!user) return null;
|
||||
|
||||
if (currentUser) {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
// only return limited info when not logged in
|
||||
return {
|
||||
email: user.email,
|
||||
hasPassword: !!user.password,
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 10, ttl: 60 } })
|
||||
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true })
|
||||
async getQuota(@CurrentUser() me: User) {
|
||||
const quota = await this.quota.getUserQuota(me.id);
|
||||
|
||||
return quota.feature;
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 10, ttl: 60 } })
|
||||
@ResolveField(() => Int, {
|
||||
name: 'invoiceCount',
|
||||
description: 'Get user invoice count',
|
||||
})
|
||||
async invoiceCount(@CurrentUser() user: CurrentUser) {
|
||||
return this.prisma.userInvoice.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType, {
|
||||
name: 'uploadAvatar',
|
||||
description: 'Upload user avatar',
|
||||
})
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
|
||||
const link = await this.storage.put(
|
||||
`${user.id}-avatar`,
|
||||
avatar.createReadStream(),
|
||||
{
|
||||
contentType: avatar.mimetype,
|
||||
}
|
||||
);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
avatarUrl: link,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType, {
|
||||
name: 'updateProfile',
|
||||
})
|
||||
async updateUserProfile(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input', { type: () => UpdateUserInput }) input: UpdateUserInput
|
||||
): Promise<UserType> {
|
||||
input = omitBy(input, isNil);
|
||||
|
||||
if (Object.keys(input).length === 0) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: input,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => RemoveAvatar, {
|
||||
name: 'removeAvatar',
|
||||
description: 'Remove user avatar',
|
||||
})
|
||||
async removeAvatar(@CurrentUser() user: CurrentUser) {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { avatarUrl: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => DeleteAccount)
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<DeleteAccount> {
|
||||
const deletedUser = await this.users.deleteUser(user.id);
|
||||
this.event.emit('user.deleted', deletedUser);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
112
packages/backend/server/src/core/user/service.ts
Normal file
112
packages/backend/server/src/core/user/service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Quota_FreePlanV1_1 } from '../quota/schema';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
defaultUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
avatarUrl: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
get userCreatingData(): Partial<Prisma.UserCreateInput> {
|
||||
return {
|
||||
name: 'Unnamed',
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by invite sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(data: Prisma.UserCreateInput) {
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
...this.userCreatingData,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAnonymousUser(
|
||||
email: string,
|
||||
data?: Partial<Prisma.UserCreateInput>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.createUser({
|
||||
email,
|
||||
name: 'Unnamed',
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { id },
|
||||
select: this.defaultUserSelect,
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: this.defaultUserSelect,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* supposed to be used only for `Credential SignIn`
|
||||
*/
|
||||
async findUserWithHashedPasswordByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateUser(
|
||||
email: string,
|
||||
data?: Partial<Prisma.UserCreateInput>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
return this.createAnonymousUser(email, data);
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
123
packages/backend/server/src/core/user/types.ts
Normal file
123
packages/backend/server/src/core/user/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
createUnionType,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
ObjectType,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
|
||||
@ObjectType('UserQuotaHumanReadable')
|
||||
export class UserQuotaHumanReadableType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field({ name: 'blobLimit' })
|
||||
blobLimit!: string;
|
||||
|
||||
@Field({ name: 'storageQuota' })
|
||||
storageQuota!: string;
|
||||
|
||||
@Field({ name: 'historyPeriod' })
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: string;
|
||||
}
|
||||
|
||||
@ObjectType('UserQuota')
|
||||
export class UserQuotaType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'blobLimit' })
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'storageQuota' })
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'historyPeriod' })
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: number;
|
||||
|
||||
@Field({ name: 'humanReadable' })
|
||||
humanReadable!: UserQuotaHumanReadableType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements CurrentUser {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'User name' })
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'User email' })
|
||||
email!: string;
|
||||
|
||||
@Field(() => String, { description: 'User avatar url', nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User email verified',
|
||||
})
|
||||
emailVerified!: boolean;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword!: boolean | null;
|
||||
|
||||
@Field(() => Date, {
|
||||
deprecationReason: 'useless',
|
||||
description: 'User email verified',
|
||||
nullable: true,
|
||||
})
|
||||
createdAt?: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class LimitedUserType implements Partial<User> {
|
||||
@Field({ description: 'User email' })
|
||||
email!: string;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword!: boolean | null;
|
||||
}
|
||||
|
||||
export const UserOrLimitedUser = createUnionType({
|
||||
name: 'UserOrLimitedUser',
|
||||
types: () => [UserType, LimitedUserType] as const,
|
||||
resolveType(value) {
|
||||
if (value.id) {
|
||||
return UserType;
|
||||
}
|
||||
return LimitedUserType;
|
||||
},
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class DeleteAccount {
|
||||
@Field()
|
||||
success!: boolean;
|
||||
}
|
||||
@ObjectType()
|
||||
export class RemoveAvatar {
|
||||
@Field()
|
||||
success!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateUserInput implements Partial<User> {
|
||||
@Field({ description: 'User name', nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user