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:
liuyi
2024-03-12 10:00:09 +00:00
parent af49e8cc41
commit fb3a0e7b8f
148 changed files with 3407 additions and 2851 deletions

View File

@@ -0,0 +1,212 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Body,
Controller,
Get,
Header,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
Config,
PaymentRequiredException,
URLHelper,
} from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
import { Public } from './guard';
import { AuthService, parseAuthUserSeqNum } from './service';
import { TokenService, TokenType } from './token';
class SignInCredential {
email!: string;
password?: string;
}
@Controller('/api/auth')
export class AuthController {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
) {}
@Public()
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
@Req() req: Request,
@Res() res: Response,
@Body() credential: SignInCredential,
@Query('redirect_uri') redirectUri = this.url.home
) {
validators.assertValidEmail(credential.email);
const canSignIn = await this.auth.canSignIn(credential.email);
if (!canSignIn) {
throw new PaymentRequiredException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
);
}
if (credential.password) {
validators.assertValidPassword(credential.password);
const user = await this.auth.signIn(
credential.email,
credential.password
);
await this.auth.setCookie(req, res, user);
res.send(user);
} else {
// send email magic link
const user = await this.user.findUserByEmail(credential.email);
const result = await this.sendSignInEmail(
{ email: credential.email, signUp: !user },
redirectUri
);
if (result.rejected.length) {
throw new Error('Failed to send sign-in email.');
}
res.send({
email: credential.email,
});
}
}
async sendSignInEmail(
{ email, signUp }: { email: string; signUp: boolean },
redirectUri: string
) {
const token = await this.token.createToken(TokenType.SignIn, email);
const magicLink = this.url.link('/api/auth/magic-link', {
token,
email,
redirect_uri: redirectUri,
});
const result = await this.auth.sendSignInEmail(email, magicLink, signUp);
return result;
}
@Get('/sign-out')
async signOut(
@Req() req: Request,
@Res() res: Response,
@Query('redirect_uri') redirectUri?: string
) {
const session = await this.auth.signOut(
req.cookies[AuthService.sessionCookieName],
parseAuthUserSeqNum(req.headers[AuthService.authUserSeqHeaderName])
);
if (session) {
res.cookie(AuthService.sessionCookieName, session.id, {
expires: session.expiresAt ?? void 0, // expiredAt is `string | null`
...this.auth.cookieOptions,
});
} else {
res.clearCookie(AuthService.sessionCookieName);
}
if (redirectUri) {
return this.url.safeRedirect(res, redirectUri);
} else {
return res.send(null);
}
}
@Public()
@Get('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
@Res() res: Response,
@Query('token') token?: string,
@Query('email') email?: string,
@Query('redirect_uri') redirectUri = this.url.home
) {
if (!token || !email) {
throw new BadRequestException('Invalid Sign-in mail Token');
}
email = decodeURIComponent(email);
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
credential: email,
});
if (!valid) {
throw new BadRequestException('Invalid Sign-in mail Token');
}
const user = await this.user.findOrCreateUser(email, {
emailVerifiedAt: new Date(),
});
await this.auth.setCookie(req, res, user);
return this.url.safeRedirect(res, redirectUri);
}
@Get('/authorize')
async authorize(
@CurrentUser() user: CurrentUser,
@Query('redirect_uri') redirect_uri?: string
) {
const session = await this.auth.createUserSession(
user,
undefined,
this.config.auth.accessToken.ttl
);
this.url.link(redirect_uri ?? '/open-app/redirect', {
token: session.sessionId,
});
}
@Public()
@Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
return {
user,
};
}
@Public()
@Get('/sessions')
async currentSessionUsers(@Req() req: Request) {
const token = req.cookies[AuthService.sessionCookieName];
if (!token) {
return {
users: [],
};
}
return {
users: await this.auth.getUserList(token),
};
}
@Public()
@Get('/challenge')
async challenge() {
// TODO: impl in following PR
return {
challenge: randomUUID(),
resource: randomUUID(),
};
}
}

View File

@@ -0,0 +1,55 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import { User } from '@prisma/client';
import { getRequestResponseFromContext } from '../../fundamentals';
function getUserFromContext(context: ExecutionContext) {
return getRequestResponseFromContext(context).req.user;
}
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token or session cookie is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user')
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*
* ```typescript
* // for public apis
* \@Public()
* \@Get('/session')
* session(@currentUser() user?: CurrentUser) {
* return user
* }
* ```
*/
// interface and variable don't conflict
// eslint-disable-next-line no-redeclare
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
export interface CurrentUser
extends Omit<User, 'password' | 'createdAt' | 'emailVerifiedAt'> {
hasPassword: boolean | null;
emailVerified: boolean;
}

View File

@@ -1,67 +1,74 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import type {
CanActivate,
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import {
createParamDecorator,
Inject,
Injectable,
SetMetadata,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import type { NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import { ModuleRef, Reflector } from '@nestjs/core';
import { getRequestResponseFromContext } from '../../fundamentals';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
import { Config, getRequestResponseFromContext } from '../../fundamentals';
import { AuthService, parseAuthUserSeqNum } from './service';
export function getUserFromContext(context: ExecutionContext) {
return getRequestResponseFromContext(context).req.user;
function extractTokenFromHeader(authorization: string) {
if (!/^Bearer\s/i.test(authorization)) {
return;
}
return authorization.substring(7);
}
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
@Injectable()
class AuthGuard implements CanActivate {
export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
constructor(
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private readonly auth: AuthService,
private readonly prisma: PrismaClient,
private readonly config: Config,
private readonly ref: ModuleRef,
private readonly reflector: Reflector
) {}
onModuleInit() {
this.auth = this.ref.get(AuthService, { strict: false });
}
async canActivate(context: ExecutionContext) {
const { req, res } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
const { req } = getRequestResponseFromContext(context);
// check cookie
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
// backward compatibility for client older then 0.12
// TODO: remove
if (!sessionToken) {
sessionToken =
req.cookies[
this.config.https
? '__Secure-next-auth.session-token'
: 'next-auth.session-token'
];
}
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const user = await this.auth.getUser(sessionToken, userSeq);
if (user) {
req.user = user;
}
}
// api is public
const isPublic = this.reflector.get<boolean>(
@@ -69,63 +76,15 @@ class AuthGuard implements CanActivate {
context.getHandler()
);
// FIXME(@forehalo): @Publicable() is duplicated with @CurrentUser() user?: User
// ^ optional
// we can prefetch user session in each request even before this `Guard`
// api can be public, but if user is logged in, we can get user info
const isPublicable = this.reflector.get<boolean>(
'isPublicable',
context.getHandler()
);
if (isPublic) {
return true;
} else if (!token) {
if (!req.cookies) {
return isPublicable;
}
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
const { body = {}, cookies, status = 200 } = session;
if (!body && !isPublicable) {
throw new UnauthorizedException('You are not signed in.');
}
// @ts-expect-error body is user here
req.user = body.user;
if (cookies && res) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
return Boolean(
status === 200 &&
typeof body !== 'string' &&
// ignore body if api is publicable
(Object.keys(body).length || isPublicable)
);
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
return !!req.user;
}
}
return false;
if (!req.user) {
throw new UnauthorizedException('You are not signed in.');
}
return true;
}
}
@@ -140,7 +99,7 @@ class AuthGuard implements CanActivate {
* ```typescript
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: User) {
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
@@ -151,5 +110,3 @@ export const Auth = () => {
// api is public accessible
export const Public = () => SetMetadata('isPublic', true);
// api is public accessible, but if user is logged in, we can get user info
export const Publicable = () => SetMetadata('isPublicable', true);

View File

@@ -1,18 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { NextAuthController } from './next-auth.controller';
import { NextAuthOptionsProvider } from './next-auth-options';
import { FeatureModule } from '../features';
import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService } from './token';
@Global()
@Module({
providers: [AuthService, AuthResolver, NextAuthOptionsProvider],
exports: [AuthService, NextAuthOptionsProvider],
controllers: [NextAuthController],
imports: [FeatureModule, UserModule],
providers: [AuthService, AuthResolver, TokenService],
exports: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
export * from './guard';
export { TokenType } from './resolver';
export { ClientTokenType } from './resolver';
export { AuthService };
export * from './current-user';

View File

@@ -1,286 +0,0 @@
import { PrismaAdapter } from '@auth/prisma-adapter';
import { FactoryProvider, Logger } from '@nestjs/common';
import { verify } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client';
import { assign, omit } from 'lodash-es';
import { NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Email from 'next-auth/providers/email';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { Config, MailService, SessionService } from '../../fundamentals';
import { FeatureType } from '../features';
import { Quota_FreePlanV1_1 } from '../quota';
import {
decode,
encode,
sendVerificationRequest,
SendVerificationRequestParams,
} from './utils';
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
const TrustedProviders = ['google'];
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
provide: NextAuthOptionsProvide,
useFactory(
config: Config,
prisma: PrismaClient,
mailer: MailService,
session: SessionService
) {
const logger = new Logger('NextAuth');
const prismaAdapter = PrismaAdapter(prisma);
// createUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
prismaAdapter.createUser = async data => {
const userData = {
name: data.name,
email: data.email,
avatarUrl: '',
emailVerified: data.emailVerified,
features: {
create: {
reason: 'created by email sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
};
if (data.email && !data.name) {
userData.name = data.email.split('@')[0];
}
if (data.image) {
userData.avatarUrl = data.image;
}
// @ts-expect-error third part library type mismatch
return createUser(userData);
};
// linkAccount exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const linkAccount = prismaAdapter.linkAccount!.bind(prismaAdapter);
prismaAdapter.linkAccount = async account => {
// google account must be a verified email
if (TrustedProviders.includes(account.provider)) {
await prisma.user.update({
where: {
id: account.userId,
},
data: {
emailVerified: new Date(),
},
});
}
return linkAccount(account) as Promise<void>;
};
// getUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!;
prismaAdapter.getUser = async id => {
const result = await getUser(id);
if (result) {
// @ts-expect-error Third part library type mismatch
result.image = result.avatarUrl;
// @ts-expect-error Third part library type mismatch
result.hasPassword = Boolean(result.password);
}
return result;
};
prismaAdapter.createVerificationToken = async data => {
await session.set(
`${data.identifier}:${data.token}`,
Date.now() + session.sessionTtl
);
return data;
};
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
const expires = await session.get(`${identifier}:${token}`);
if (expires) {
return { identifier, token, expires: new Date(expires) };
} else {
return null;
}
};
const nextAuthOptions: NextAuthOptions = {
providers: [],
// @ts-expect-error Third part library type mismatch
adapter: prismaAdapter,
debug: !config.node.prod,
logger: {
debug(code, metadata) {
logger.debug(`${code}: ${JSON.stringify(metadata)}`);
},
error(code, metadata) {
if (metadata instanceof Error) {
// @ts-expect-error assign code to error
metadata.code = code;
logger.error(metadata);
} else if (metadata.error instanceof Error) {
assign(metadata.error, omit(metadata, 'error'), { code });
logger.error(metadata.error);
}
},
warn(code) {
logger.warn(code);
},
},
};
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Credentials.default({
name: 'Password',
credentials: {
email: {
label: 'Email',
type: 'text',
placeholder: 'torvalds@osdl.org',
},
password: { label: 'Password', type: 'password' },
},
async authorize(
credentials:
| Record<'email' | 'password' | 'hashedPassword', string>
| undefined
) {
if (!credentials) {
return null;
}
const { password, hashedPassword } = credentials;
if (!password || !hashedPassword) {
return null;
}
if (!(await verify(hashedPassword, password))) {
return null;
}
return credentials;
},
})
);
if (config.mailer && mailer) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Email.default({
sendVerificationRequest: (params: SendVerificationRequestParams) =>
sendVerificationRequest(config, logger, mailer, session, params),
})
);
}
if (config.auth.oauthProviders.github) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Github.default({
clientId: config.auth.oauthProviders.github.clientId,
clientSecret: config.auth.oauthProviders.github.clientSecret,
allowDangerousEmailAccountLinking: true,
})
);
}
if (config.auth.oauthProviders.google?.enabled) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Google.default({
clientId: config.auth.oauthProviders.google.clientId,
clientSecret: config.auth.oauthProviders.google.clientSecret,
checks: 'nonce',
allowDangerousEmailAccountLinking: true,
authorization: {
params: { scope: 'openid email profile', prompt: 'select_account' },
},
})
);
}
if (nextAuthOptions.providers.length > 1) {
// not only credentials provider
nextAuthOptions.session = { strategy: 'database' };
}
nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) =>
encode(config, prisma, token, maxAge),
decode: async ({ token }) => decode(config, token),
};
nextAuthOptions.secret ??= config.auth.nextAuthSecret;
nextAuthOptions.callbacks = {
session: async ({ session, user, token }) => {
if (session.user) {
if (user) {
// @ts-expect-error Third part library type mismatch
session.user.id = user.id;
// @ts-expect-error Third part library type mismatch
session.user.image = user.image ?? user.avatarUrl;
// @ts-expect-error Third part library type mismatch
session.user.emailVerified = user.emailVerified;
// @ts-expect-error Third part library type mismatch
session.user.hasPassword = Boolean(user.password);
} else {
// technically the sub should be the same as id
// @ts-expect-error Third part library type mismatch
session.user.id = token.sub;
// @ts-expect-error Third part library type mismatch
session.user.emailVerified = token.emailVerified;
// @ts-expect-error Third part library type mismatch
session.user.hasPassword = token.hasPassword;
}
if (token && token.picture) {
session.user.image = token.picture;
}
}
return session;
},
signIn: async ({ profile, user }) => {
if (!config.featureFlags.earlyAccessPreview) {
return true;
}
const email = profile?.email ?? user.email;
if (email) {
// FIXME: cannot inject FeatureManagementService here
// it will cause prisma.account to be undefined
// then prismaAdapter.getUserByAccount will throw error
if (email.endsWith('@toeverything.info')) return true;
return prisma.userFeatures
.count({
where: {
user: {
email: {
equals: email,
mode: 'insensitive',
},
},
feature: {
feature: FeatureType.EarlyAccess,
},
activated: true,
},
})
.then(count => count > 0);
}
return false;
},
redirect({ url }) {
return url;
},
};
nextAuthOptions.pages = {
newUser: '/auth/onboarding',
};
return nextAuthOptions;
},
inject: [Config, PrismaClient, MailService, SessionService],
};

View File

@@ -1,411 +0,0 @@
import { URLSearchParams } from 'node:url';
import {
All,
BadRequestException,
Controller,
Get,
Inject,
Logger,
Next,
NotFoundException,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { hash, verify } from '@node-rs/argon2';
import { PrismaClient, type User } from '@prisma/client';
import type { NextFunction, Request, Response } from 'express';
import { pick } from 'lodash-es';
import { nanoid } from 'nanoid';
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import {
AuthThrottlerGuard,
Config,
metrics,
SessionService,
Throttle,
} from '../../fundamentals';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
const BASE_URL = '/api/auth/';
const DEFAULT_SESSION_EXPIRE_DATE = 2592000 * 1000; // 30 days
@Controller(BASE_URL)
export class NextAuthController {
private readonly callbackSession;
private readonly logger = new Logger('NextAuthController');
constructor(
readonly config: Config,
readonly prisma: PrismaClient,
private readonly authService: AuthService,
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private readonly session: SessionService
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.callbackSession = nextAuthOptions.callbacks!.session;
}
@UseGuards(AuthThrottlerGuard)
@Throttle({
default: {
limit: 60,
ttl: 60,
},
})
@Get('/challenge')
async getChallenge(@Res() res: Response) {
const challenge = nanoid();
const resource = nanoid();
await this.session.set(challenge, resource, 5 * 60 * 1000);
res.json({ challenge, resource });
}
@UseGuards(AuthThrottlerGuard)
@Throttle({
default: {
limit: 60,
ttl: 60,
},
})
@All('*')
async auth(
@Req() req: Request,
@Res() res: Response,
@Query() query: Record<string, any>,
@Next() next: NextFunction
) {
if (req.path === '/api/auth/signin' && req.method === 'GET') {
const query = req.query
? // @ts-expect-error req.query is satisfy with the Record<string, any>
`?${new URLSearchParams(req.query).toString()}`
: '';
res.redirect(`/signin${query}`);
return;
}
const [action, providerId] = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part
.split('/') as [AuthAction, string]; // as array of strings;
metrics.auth.counter('call_counter').add(1, { action, providerId });
const credentialsSignIn =
req.method === 'POST' && providerId === 'credentials';
let userId: string | undefined;
if (credentialsSignIn) {
const { email } = req.body;
if (email) {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
if (!user) {
req.statusCode = 401;
req.statusMessage = 'User not found';
req.body = null;
throw new NotFoundException(`User not found`);
} else {
userId = user.id;
req.body = {
...req.body,
name: user.name,
email: user.email,
image: user.avatarUrl,
hashedPassword: user.password,
};
}
}
}
const options = this.nextAuthOptions;
if (req.method === 'POST' && action === 'session') {
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
metrics.auth
.counter('call_fails_counter')
.add(1, { reason: 'invalid_session_data' });
throw new BadRequestException(`Invalid new session data`);
}
const user = await this.updateSession(req, req.body.data);
// callbacks.session existed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.callbacks!.session = ({ session }) => {
return {
user: {
...pick(user, 'id', 'name', 'email'),
image: user.avatarUrl,
hasPassword: !!user.password,
},
expires: session.expires,
};
};
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.callbacks!.session = this.callbackSession;
}
if (
this.config.auth.captcha.enable &&
req.method === 'POST' &&
action === 'signin' &&
// TODO: add credentials support in frontend
['email'].includes(providerId)
) {
const isVerified = await this.verifyChallenge(req, res);
if (!isVerified) return;
}
const { status, headers, body, redirect, cookies } = await AuthHandler({
req: {
body: req.body,
query: query,
method: req.method,
action,
providerId,
error: query.error ?? providerId,
cookies: req.cookies,
},
options,
});
if (headers) {
for (const { key, value } of headers) {
res.setHeader(key, value);
}
}
if (cookies) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined;
const secureCookiePrefix = '__Secure-';
const sessionCookieName = `next-auth.session-token`;
// next-auth credentials login only support JWT strategy
// https://next-auth.js.org/configuration/providers/credentials
// let's store the session token in the database
if (
credentialsSignIn &&
(nextAuthTokenCookie = cookies?.find(
({ name }) =>
name === sessionCookieName ||
name === `${secureCookiePrefix}${sessionCookieName}`
))
) {
const cookieExpires = new Date();
cookieExpires.setTime(
cookieExpires.getTime() + DEFAULT_SESSION_EXPIRE_DATE
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await this.nextAuthOptions.adapter!.createSession!({
sessionToken: nextAuthTokenCookie.value,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
userId: userId!,
expires: cookieExpires,
});
}
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
this.logger.log(`Early access redirect headers: ${req.headers}`);
metrics.auth
.counter('call_fails_counter')
.add(1, { reason: 'no_early_access_permission' });
if (
!req.headers?.referer ||
checkUrlOrigin(req.headers.referer, 'https://accounts.google.com')
) {
res.redirect('https://community.affine.pro/c/insider-general/');
} else {
res.status(403);
res.json({
url: 'https://community.affine.pro/c/insider-general/',
error: `You don't have early access permission`,
});
}
return;
}
if (status) {
res.status(status);
}
if (redirect) {
if (providerId === 'credentials') {
res.send(JSON.stringify({ ok: true, url: redirect }));
} else if (
action === 'callback' ||
action === 'error' ||
(providerId !== 'credentials' &&
// login in the next-auth page, /api/auth/signin, auto redirect.
// otherwise, return the json value to allow frontend to handle the redirect.
req.headers?.referer?.includes?.('/api/auth/signin'))
) {
res.redirect(redirect);
} else {
res.json({ url: redirect });
}
} else if (typeof body === 'string') {
res.send(body);
} else if (body && typeof body === 'object') {
res.json(body);
} else {
next();
}
}
private async updateSession(
req: Request,
newSession: Partial<Omit<User, 'id'>> & { oldPassword?: string }
): Promise<User> {
const { name, email, password, oldPassword } = newSession;
if (!name && !email && !password) {
throw new BadRequestException(`Invalid new session data`);
}
if (password) {
const user = await this.verifyUserFromRequest(req);
const { password: userPassword } = user;
if (!oldPassword) {
if (userPassword) {
throw new BadRequestException(
`Old password is required to update password`
);
}
} else {
if (!userPassword) {
throw new BadRequestException(`No existed password`);
}
if (await verify(userPassword, oldPassword)) {
await this.prisma.user.update({
where: {
id: user.id,
},
data: {
...pick(newSession, 'email', 'name'),
password: await hash(password),
},
});
}
}
return user;
} else {
const user = await this.verifyUserFromRequest(req);
return this.prisma.user.update({
where: {
id: user.id,
},
data: pick(newSession, 'name', 'email'),
});
}
}
private async verifyChallenge(req: Request, res: Response): Promise<boolean> {
const challenge = req.query?.challenge;
if (typeof challenge === 'string' && challenge) {
const resource = await this.session.get(challenge);
if (!resource) {
this.rejectResponse(res, 'Invalid Challenge');
return false;
}
const isChallengeVerified =
await this.authService.verifyChallengeResponse(
req.query?.token,
resource
);
this.logger.debug(
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
);
if (!isChallengeVerified) {
this.rejectResponse(res, 'Invalid Challenge Response');
return false;
}
} else {
const isTokenVerified = await this.authService.verifyCaptchaToken(
req.query?.token,
req.headers['CF-Connecting-IP'] as string
);
if (!isTokenVerified) {
this.rejectResponse(res, 'Invalid Captcha Response');
return false;
}
}
return true;
}
private async verifyUserFromRequest(req: Request): Promise<User> {
const token = req.headers.authorization;
if (!token) {
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
const { body } = session;
// @ts-expect-error check if body.user exists
if (body && body.user && body.user.id) {
const user = await this.prisma.user.findUnique({
where: {
// @ts-expect-error body.user.id exists
id: body.user.id,
},
});
if (user) {
return user;
}
}
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.authService.verify(jwt);
const user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
if (user) {
return user;
}
}
}
throw new BadRequestException(`User not found`);
}
rejectResponse(res: Response, error: string, status = 400) {
res.status(status);
res.json({
url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({
error,
}).toString()}`,
error,
});
}
}
const checkUrlOrigin = (url: string, origin: string) => {
try {
return new URL(url).origin === origin;
} catch (e) {
return false;
}
};

View File

@@ -10,24 +10,22 @@ import {
Mutation,
ObjectType,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { Request } from 'express';
import { nanoid } from 'nanoid';
import type { Request, Response } from 'express';
import {
CloudThrottlerGuard,
Config,
SessionService,
Throttle,
} from '../../fundamentals';
import { UserType } from '../users';
import { Auth, CurrentUser } from './guard';
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
import { Public } from './guard';
import { AuthService } from './service';
import { TokenService, TokenType } from './token';
@ObjectType()
export class TokenType {
@ObjectType('tokenType')
export class ClientTokenType {
@Field()
token!: string;
@@ -50,46 +48,57 @@ export class AuthResolver {
constructor(
private readonly config: Config,
private readonly auth: AuthService,
private readonly session: SessionService
private readonly token: TokenService
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Public()
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
nullable: true,
})
currentUser(@CurrentUser() user?: CurrentUser): UserType | undefined {
return user;
}
@Throttle({
default: {
limit: 20,
ttl: 60,
},
})
@ResolveField(() => TokenType)
async token(
@Context() ctx: { req: Request },
@CurrentUser() currentUser: UserType,
@ResolveField(() => ClientTokenType, {
name: 'token',
deprecationReason: 'use [/api/auth/authorize]',
})
async clientToken(
@CurrentUser() currentUser: CurrentUser,
@Parent() user: UserType
) {
): Promise<ClientTokenType> {
if (user.id !== currentUser.id) {
throw new BadRequestException('Invalid user');
throw new ForbiddenException('Invalid user');
}
let sessionToken: string | undefined;
// only return session if the request is from the same origin & path == /open-app
if (
ctx.req.headers.referer &&
ctx.req.headers.host &&
new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') &&
ctx.req.headers.host === new URL(this.config.origin).host
) {
const cookiePrefix = this.config.node.prod ? '__Secure-' : '';
const sessionCookieName = `${cookiePrefix}next-auth.session-token`;
sessionToken = ctx.req.cookies?.[sessionCookieName];
}
const session = await this.auth.createUserSession(
user,
undefined,
this.config.auth.accessToken.ttl
);
return {
sessionToken,
token: this.auth.sign(user),
refresh: this.auth.refresh(user),
sessionToken: session.sessionId,
token: session.sessionId,
refresh: '',
};
}
@Public()
@Throttle({
default: {
limit: 10,
@@ -98,16 +107,19 @@ export class AuthResolver {
})
@Mutation(() => UserType)
async signUp(
@Context() ctx: { req: Request },
@Context() ctx: { req: Request; res: Response },
@Args('name') name: string,
@Args('email') email: string,
@Args('password') password: string
) {
validators.assertValidCredential({ email, password });
const user = await this.auth.signUp(name, email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
return user;
}
@Public()
@Throttle({
default: {
limit: 10,
@@ -116,11 +128,13 @@ export class AuthResolver {
})
@Mutation(() => UserType)
async signIn(
@Context() ctx: { req: Request },
@Context() ctx: { req: Request; res: Response },
@Args('email') email: string,
@Args('password') password: string
) {
validators.assertValidCredential({ email, password });
const user = await this.auth.signIn(email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
return user;
}
@@ -132,28 +146,26 @@ export class AuthResolver {
},
})
@Mutation(() => UserType)
@Auth()
async changePassword(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('token') token: string,
@Args('newPassword') newPassword: string
) {
const id = await this.session.get(token);
if (!user.emailVerified) {
throw new ForbiddenException('Please verify the email first');
}
if (
!id ||
(id !== user.id &&
// change password after sign in with email link
// we only create user account after user sign in with email link
id !== user.email)
) {
validators.assertValidPassword(newPassword);
// NOTE: Set & Change password are using the same token type.
const valid = await this.token.verifyToken(
TokenType.ChangePassword,
token,
{
credential: user.id,
}
);
if (!valid) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changePassword(user.email, newPassword);
await this.session.delete(token);
return user;
}
@@ -165,25 +177,24 @@ export class AuthResolver {
},
})
@Mutation(() => UserType)
@Auth()
async changeEmail(
@CurrentUser() user: UserType,
@Args('token') token: string
@CurrentUser() user: CurrentUser,
@Args('token') token: string,
@Args('email') email: string
) {
const key = await this.session.get(token);
if (!key) {
validators.assertValidEmail(email);
// @see [sendChangeEmail]
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
credential: user.id,
});
if (!valid) {
throw new ForbiddenException('Invalid token');
}
// email has set token in `sendVerifyChangeEmail`
const [id, email] = key.split(',');
if (!id || id !== user.id || !email) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changeEmail(id, email);
await this.session.delete(token);
email = decodeURIComponent(email);
await this.auth.changeEmail(user.id, email);
await this.auth.sendNotificationChangeEmail(email);
return user;
@@ -196,19 +207,29 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendChangePasswordEmail(
@CurrentUser() user: UserType,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
// @deprecated
@Args('email', { nullable: true }) _email?: string
) {
const token = nanoid();
await this.session.set(token, user.id);
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
}
const token = await this.token.createToken(
TokenType.ChangePassword,
user.id
);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
const res = await this.auth.sendChangePasswordEmail(
user.email,
url.toString()
);
return !res.rejected.length;
}
@@ -219,19 +240,27 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendSetPasswordEmail(
@CurrentUser() user: UserType,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
@Args('email', { nullable: true }) _email?: string
) {
const token = nanoid();
await this.session.set(token, user.id);
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
}
const token = await this.token.createToken(
TokenType.ChangePassword,
user.id
);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
const res = await this.auth.sendSetPasswordEmail(
user.email,
url.toString()
);
return !res.rejected.length;
}
@@ -249,19 +278,22 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendChangeEmail(
@CurrentUser() user: UserType,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
// @deprecated
@Args('email', { nullable: true }) _email?: string
) {
const token = nanoid();
await this.session.set(token, user.id);
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
}
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendChangeEmail(email, url.toString());
const res = await this.auth.sendChangeEmail(user.email, url.toString());
return !res.rejected.length;
}
@@ -272,34 +304,92 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendVerifyChangeEmail(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('token') token: string,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const id = await this.session.get(token);
if (!id || id !== user.id) {
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
credential: user.id,
});
if (!valid) {
throw new ForbiddenException('Invalid token');
}
const hasRegistered = await this.auth.getUserByEmail(email);
if (hasRegistered) {
throw new BadRequestException(`Invalid user email`);
if (hasRegistered.id !== user.id) {
throw new BadRequestException(`The email provided has been taken.`);
} else {
throw new BadRequestException(
`The email provided is the same as the current email.`
);
}
}
const withEmailToken = nanoid();
await this.session.set(withEmailToken, `${user.id},${email}`);
const verifyEmailToken = await this.token.createToken(
TokenType.VerifyEmail,
user.id
);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', withEmailToken);
url.searchParams.set('token', verifyEmailToken);
url.searchParams.set('email', email);
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
await this.session.delete(token);
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendVerifyEmail(
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string
) {
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async verifyEmail(
@CurrentUser() user: CurrentUser,
@Args('token') token: string
) {
if (!token) {
throw new BadRequestException('Invalid token');
}
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
credential: user.id,
});
if (!valid) {
throw new ForbiddenException('Invalid token');
}
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
return emailVerifiedAt !== null;
}
}

View File

@@ -1,299 +1,327 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
NotAcceptableException,
NotFoundException,
OnApplicationBootstrap,
} from '@nestjs/common';
import { hash, verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { PrismaClient, type User } from '@prisma/client';
import { nanoid } from 'nanoid';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import {
Config,
CryptoHelper,
MailService,
verifyChallengeResponse,
SessionCache,
} from '../../fundamentals';
import { Quota_FreePlanV1_1 } from '../quota';
import { FeatureManagementService } from '../features/management';
import { UserService } from '../user/service';
import type { CurrentUser } from './current-user';
export type UserClaim = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
> & {
hasPassword?: boolean;
};
export function parseAuthUserSeqNum(value: any) {
switch (typeof value) {
case 'number': {
return value;
}
case 'string': {
value = Number.parseInt(value);
return Number.isNaN(value) ? 0 : value;
}
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
default: {
return 0;
}
}
}
export function sessionUser(
user: Omit<User, 'password'> & { password?: string | null }
): CurrentUser {
return assign(omit(user, 'password', 'emailVerifiedAt', 'createdAt'), {
hasPassword: user.password !== null,
emailVerified: user.emailVerifiedAt !== null,
});
}
@Injectable()
export class AuthService {
export class AuthService implements OnApplicationBootstrap {
readonly cookieOptions: CookieOptions = {
sameSite: 'lax',
httpOnly: true,
path: '/',
domain: this.config.host,
secure: this.config.https,
};
static readonly sessionCookieName = 'sid';
static readonly authUserSeqHeaderName = 'x-auth-user';
constructor(
private readonly config: Config,
private readonly prisma: PrismaClient,
private readonly mailer: MailService
private readonly db: PrismaClient,
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly user: UserService,
private readonly crypto: CryptoHelper,
private readonly cache: SessionCache
) {}
sign(user: UserClaim) {
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + this.config.auth.accessTokenExpiresIn,
iss: this.config.serverId,
sub: user.id,
aud: 'https://affine.pro',
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
refresh(user: UserClaim) {
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
exp: now + this.config.auth.refreshTokenExpiresIn,
iat: now,
iss: this.config.serverId,
sub: user.id,
aud: 'https://affine.pro',
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
async verify(token: string) {
try {
const data = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
aud: ['https://affine.pro'],
})
).data as UserClaim;
return {
...data,
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
createdAt: new Date(data.createdAt),
};
} catch (e) {
throw new UnauthorizedException('Invalid token');
async onApplicationBootstrap() {
if (this.config.node.dev) {
await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => {
// ignore
});
}
}
async verifyCaptchaToken(token: any, ip: string) {
if (typeof token !== 'string' || !token) return false;
const formData = new FormData();
formData.append('secret', this.config.auth.captcha.turnstile.secret);
formData.append('response', token);
formData.append('remoteip', ip);
// prevent replay attack
formData.append('idempotency_key', nanoid());
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});
const outcome = await result.json();
return (
!!outcome.success &&
// skip hostname check in dev mode
(this.config.node.dev || outcome.hostname === this.config.host)
);
canSignIn(email: string) {
return this.feature.canEarlyAccess(email);
}
async verifyChallengeResponse(response: any, resource: string) {
return verifyChallengeResponse(
response,
this.config.auth.captcha.challenge.bits,
resource
);
async signUp(
name: string,
email: string,
password: string
): Promise<CurrentUser> {
const user = await this.getUserByEmail(email);
if (user) {
throw new BadRequestException('Email was taken');
}
const hashedPassword = await this.crypto.encryptPassword(password);
return this.user
.createUser({
name,
email,
password: hashedPassword,
})
.then(sessionUser);
}
async signIn(email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
async signIn(email: string, password: string) {
const user = await this.user.findUserWithHashedPasswordByEmail(email);
if (!user) {
throw new BadRequestException('Invalid email');
throw new NotFoundException('User Not Found');
}
if (!user.password) {
throw new BadRequestException('User has no password');
throw new NotAcceptableException(
'User Password is not set. Should login throw email link.'
);
}
let equal = false;
try {
equal = await verify(user.password, password);
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e, 'Verify password failed');
const passwordMatches = await this.crypto.verifyPassword(
password,
user.password
);
if (!passwordMatches) {
throw new NotAcceptableException('Incorrect Password');
}
if (!equal) {
throw new UnauthorizedException('Invalid password');
return sessionUser(user);
}
async getUserWithCache(token: string, seq = 0) {
const cacheKey = `session:${token}:${seq}`;
let user = await this.cache.get<CurrentUser | null>(cacheKey);
if (user) {
return user;
}
user = await this.getUser(token, seq);
if (user) {
await this.cache.set(cacheKey, user);
}
return user;
}
async signUp(name: string, email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
const session = await this.getSession(token);
if (user) {
throw new BadRequestException('Email already exists');
// no such session
if (!session) {
return null;
}
const hashedPassword = await hash(password);
const userSession = session.userSessions.at(seq);
return this.prisma.user.create({
data: {
name,
email,
password: hashedPassword,
// TODO(@forehalo): handle in event system
features: {
create: {
reason: 'created by api sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
},
});
}
async createAnonymousUser(email: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
if (user) {
throw new BadRequestException('Email already exists');
// no such user session
if (!userSession) {
return null;
}
return this.prisma.user.create({
data: {
name: 'Unnamed',
email,
features: {
create: {
reason: 'created by invite sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
},
});
}
// user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return null;
}
async getUserByEmail(email: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
const user = await this.db.user.findUnique({
where: { id: userSession.userId },
});
}
async isUserHasPassword(email: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
if (!user) {
throw new BadRequestException('Invalid email');
return null;
}
return Boolean(user.password);
return sessionUser(user);
}
async getUserList(token: string) {
const session = await this.getSession(token);
if (!session || !session.userSessions.length) {
return [];
}
const users = await this.db.user.findMany({
where: {
id: {
in: session.userSessions.map(({ userId }) => userId),
},
},
});
// TODO(@forehalo): need to separate expired session, same for [getUser]
// Session
// | { user: LimitedUser { email, avatarUrl }, expired: true }
// | { user: User, expired: false }
return users.map(sessionUser);
}
async signOut(token: string, seq = 0) {
const session = await this.getSession(token);
if (session) {
// overflow the logged in user
if (session.userSessions.length <= seq) {
return session;
}
await this.db.userSession.deleteMany({
where: { id: session.userSessions[seq].id },
});
// no more user session active, delete the whole session
if (session.userSessions.length === 1) {
await this.db.session.delete({ where: { id: session.id } });
return null;
}
return session;
}
return null;
}
async getSession(token: string) {
return this.db.$transaction(async tx => {
const session = await tx.session.findUnique({
where: {
id: token,
},
include: {
userSessions: {
orderBy: {
createdAt: 'asc',
},
},
},
});
if (!session) {
return null;
}
if (session.expiresAt && session.expiresAt <= new Date()) {
await tx.session.delete({
where: {
id: session.id,
},
});
return null;
}
return session;
});
}
async createUserSession(
user: { id: string },
existingSession?: string,
ttl = this.config.auth.session.ttl
) {
const session = existingSession
? await this.getSession(existingSession)
: null;
const expiresAt = new Date(Date.now() + ttl * 1000);
if (session) {
return this.db.userSession.upsert({
where: {
sessionId_userId: {
sessionId: session.id,
userId: user.id,
},
},
update: {
expiresAt,
},
create: {
sessionId: session.id,
userId: user.id,
expiresAt,
},
});
} else {
return this.db.userSession.create({
data: {
expiresAt,
session: {
create: {},
},
user: {
connect: {
id: user.id,
},
},
},
});
}
}
async setCookie(req: Request, res: Response, user: { id: string }) {
const session = await this.createUserSession(
user,
req.cookies[AuthService.sessionCookieName]
);
res.cookie(AuthService.sessionCookieName, session.sessionId, {
expires: session.expiresAt ?? void 0,
...this.cookieOptions,
});
}
async getUserByEmail(email: string) {
return this.user.findUserByEmail(email);
}
async changePassword(email: string, newPassword: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
emailVerified: {
not: null,
},
},
});
const user = await this.getUserByEmail(email);
if (!user) {
throw new BadRequestException('Invalid email');
}
const hashedPassword = await hash(newPassword);
const hashedPassword = await this.crypto.encryptPassword(newPassword);
return this.prisma.user.update({
return this.db.user.update({
where: {
id: user.id,
},
@@ -304,7 +332,7 @@ export class AuthService {
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.prisma.user.findUnique({
const user = await this.db.user.findUnique({
where: {
id,
},
@@ -314,12 +342,27 @@ export class AuthService {
throw new BadRequestException('Invalid email');
}
return this.prisma.user.update({
return this.db.user.update({
where: {
id,
},
data: {
email: newEmail,
emailVerifiedAt: new Date(),
},
});
}
async setEmailVerified(id: string) {
return await this.db.user.update({
where: {
id,
},
data: {
emailVerifiedAt: new Date(),
},
select: {
emailVerifiedAt: true,
},
});
}
@@ -336,7 +379,20 @@ export class AuthService {
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
}
async sendVerifyEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyEmail(email, callbackUrl);
}
async sendNotificationChangeEmail(email: string) {
return this.mailer.sendNotificationChangeEmail(email);
}
async sendSignInEmail(email: string, link: string, signUp: boolean) {
return signUp
? await this.mailer.sendSignUpMail(link.toString(), {
to: email,
})
: await this.mailer.sendSignInMail(link.toString(), {
to: email,
});
}
}

View File

@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { CryptoHelper } from '../../fundamentals/helpers';
export enum TokenType {
SignIn,
VerifyEmail,
ChangeEmail,
ChangePassword,
Challenge,
}
@Injectable()
export class TokenService {
constructor(
private readonly db: PrismaClient,
private readonly crypto: CryptoHelper
) {}
async createToken(
type: TokenType,
credential?: string,
ttlInSec: number = 30 * 60
) {
const plaintextToken = randomUUID();
const { token } = await this.db.verificationToken.create({
data: {
type,
token: plaintextToken,
credential,
expiresAt: new Date(Date.now() + ttlInSec * 1000),
},
});
return this.crypto.encrypt(token);
}
async verifyToken(
type: TokenType,
token: string,
{
credential,
keep,
}: {
credential?: string;
keep?: boolean;
} = {}
) {
token = this.crypto.decrypt(token);
const record = await this.db.verificationToken.findUnique({
where: {
type_token: {
token,
type,
},
},
});
if (!record) {
return null;
}
const expired = record.expiresAt <= new Date();
const valid =
!expired && (!record.credential || record.credential === credential);
if ((expired || valid) && !keep) {
await this.db.verificationToken.delete({
where: {
type_token: {
token,
type,
},
},
});
}
return valid ? record : null;
}
}

View File

@@ -1,3 +0,0 @@
export { jwtDecode as decode, jwtEncode as encode } from './jwt';
export { sendVerificationRequest } from './send-mail';
export type { SendVerificationRequestParams } from 'next-auth/providers/email';

View File

@@ -1,76 +0,0 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { JWT } from 'next-auth/jwt';
import { Config } from '../../../fundamentals';
import { getUtcTimestamp, UserClaim } from '../service';
export const jwtEncode = async (
config: Config,
prisma: PrismaClient,
token: JWT | undefined,
maxAge: number | undefined
) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
picture: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
hasPassword: Boolean(user.password),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: config.serverId,
sub: user.id,
aud: 'https://affine.pro',
jti: randomUUID({
disableEntropyCache: true,
}),
},
config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
};
export const jwtDecode = async (config: Config, token: string | undefined) => {
if (!token) {
return null;
}
const { name, email, emailVerified, id, picture, hasPassword } = (
await jwtVerify(token, config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [config.serverId],
leeway: config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as Omit<UserClaim, 'avatarUrl'> & {
picture: string | undefined;
};
return {
name,
email,
emailVerified,
picture,
sub: id,
id,
hasPassword,
};
};

View File

@@ -1,38 +0,0 @@
import { Logger } from '@nestjs/common';
import { nanoid } from 'nanoid';
import type { SendVerificationRequestParams } from 'next-auth/providers/email';
import { Config, MailService, SessionService } from '../../../fundamentals';
export async function sendVerificationRequest(
config: Config,
logger: Logger,
mailer: MailService,
session: SessionService,
params: SendVerificationRequestParams
) {
const { identifier, url } = params;
const urlWithToken = new URL(url);
const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || '';
if (!callbackUrl) {
throw new Error('callbackUrl is not set');
} else {
const newCallbackUrl = new URL(callbackUrl, config.origin);
const token = nanoid();
await session.set(token, identifier);
newCallbackUrl.searchParams.set('token', token);
urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString());
}
const result = await mailer.sendSignInEmail(urlWithToken.toString(), {
to: identifier,
});
logger.log(`send verification email success: ${result.accepted.join(', ')}`);
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
}
}

View File

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../fundamentals';
import { Public } from './auth';
export enum ServerFeature {
Payment = 'payment',
OAuth = 'oauth',
}
registerEnumType(ServerFeature, {
@@ -15,9 +17,9 @@ registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
const ENABLED_FEATURES: ServerFeature[] = [];
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.push(feature);
ENABLED_FEATURES.add(feature);
}
@ObjectType()
@@ -48,6 +50,7 @@ export class ServerConfigType {
}
export class ServerConfigResolver {
@Public()
@Query(() => ServerConfigType, {
description: 'server config',
})
@@ -61,7 +64,7 @@ export class ServerConfigResolver {
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
// this field should be removed after frontend feature flags implemented
flavor: AFFiNE.type,
features: ENABLED_FEATURES,
features: Array.from(ENABLED_FEATURES),
};
}
}

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { UserType } from '../users/types';
import { WorkspaceType } from '../workspaces/types';
import { FeatureConfigType, getFeature } from './feature';
import { FeatureKind, FeatureType } from './types';
@@ -158,7 +157,7 @@ export class FeatureService {
return configs.filter(feature => !!feature.feature);
}
async listFeatureUsers(feature: FeatureType): Promise<UserType[]> {
async listFeatureUsers(feature: FeatureType) {
return this.prisma.userFeatures
.findMany({
where: {
@@ -175,7 +174,7 @@ export class FeatureService {
name: true,
avatarUrl: true,
email: true,
emailVerified: true,
emailVerifiedAt: true,
createdAt: true,
},
},

View File

@@ -1,4 +1,4 @@
import { FeatureKind } from '../features';
import { FeatureKind } from '../features/types';
import { OneDay, OneGB, OneMB } from './constant';
import { Quota, QuotaType } from './types';

View File

@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { z } from 'zod';
import { commonFeatureSchema, FeatureKind } from '../features';
import { commonFeatureSchema, FeatureKind } from '../features/types';
import { ByteUnit, OneDay, OneKB } from './constant';
/// ======== quota define ========

View File

@@ -14,7 +14,6 @@ import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { CallTimer, metrics } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { DocManager } from '../../doc';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../../workspaces/permission';
import { Permission } from '../../workspaces/types';
@@ -53,6 +52,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
if (result instanceof Promise) {
return result.catch(e => {
metrics.socketio.counter('unhandled_errors').add(1);
new Logger('EventsGateway').error(e, e.stack);
return {
error: new InternalError(e),
};
@@ -139,7 +139,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@Auth()
@SubscribeMessage('client-handshake-sync')
async handleClientHandshakeSync(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@MessageBody('workspaceId') workspaceId: string,
@MessageBody('version') version: string | undefined,
@ConnectedSocket() client: Socket
@@ -172,7 +172,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@Auth()
@SubscribeMessage('client-handshake-awareness')
async handleClientHandshakeAwareness(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@MessageBody('workspaceId') workspaceId: string,
@MessageBody('version') version: string | undefined,
@ConnectedSocket() client: Socket
@@ -290,7 +290,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@SubscribeMessage('doc-load-v2')
async loadDocV2(
@ConnectedSocket() client: Socket,
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@MessageBody()
{
workspaceId,
@@ -339,6 +339,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
};
}
@Auth()
@SubscribeMessage('awareness-init')
async handleInitAwareness(
@MessageBody() workspaceId: string,

View File

@@ -6,15 +6,15 @@ import { StorageModule } from '../storage';
import { UserAvatarController } from './controller';
import { UserManagementResolver } from './management';
import { UserResolver } from './resolver';
import { UsersService } from './users';
import { UserService } from './service';
@Module({
imports: [StorageModule, FeatureModule, QuotaModule],
providers: [UserResolver, UserManagementResolver, UsersService],
providers: [UserResolver, UserManagementResolver, UserService],
controllers: [UserAvatarController],
exports: [UsersService],
exports: [UserService],
})
export class UsersModule {}
export class UserModule {}
export { UserService } from './service';
export { UserType } from './types';
export { UsersService } from './users';

View File

@@ -6,23 +6,21 @@ import {
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { Auth, CurrentUser } from '../auth/guard';
import { AuthService } from '../auth/service';
import { CurrentUser } from '../auth/current-user';
import { sessionUser } from '../auth/service';
import { FeatureManagementService } from '../features';
import { UserService } from './service';
import { UserType } from './types';
import { UsersService } from './users';
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => UserType)
export class UserManagementResolver {
constructor(
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly users: UserService,
private readonly feature: FeatureManagementService
) {}
@@ -34,7 +32,7 @@ export class UserManagementResolver {
})
@Mutation(() => Int)
async addToEarlyAccess(
@CurrentUser() currentUser: UserType,
@CurrentUser() currentUser: CurrentUser,
@Args('email') email: string
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
@@ -44,7 +42,7 @@ export class UserManagementResolver {
if (user) {
return this.feature.addEarlyAccess(user.id);
} else {
const user = await this.auth.createAnonymousUser(email);
const user = await this.users.createAnonymousUser(email);
return this.feature.addEarlyAccess(user.id);
}
}
@@ -57,7 +55,7 @@ export class UserManagementResolver {
})
@Mutation(() => Int)
async removeEarlyAccess(
@CurrentUser() currentUser: UserType,
@CurrentUser() currentUser: CurrentUser,
@Args('email') email: string
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
@@ -79,13 +77,15 @@ export class UserManagementResolver {
@Query(() => [UserType])
async earlyAccessUsers(
@Context() ctx: { isAdminQuery: boolean },
@CurrentUser() user: UserType
@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();
return this.feature.listEarlyAccess().then(users => {
return users.map(sessionUser);
});
}
}

View File

@@ -9,6 +9,7 @@ import {
} 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,
@@ -17,68 +18,38 @@ import {
PaymentRequiredException,
Throttle,
} from '../../fundamentals';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
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';
import { UsersService } from './users';
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => UserType)
export class UserResolver {
constructor(
private readonly prisma: PrismaClient,
private readonly storage: AvatarStorage,
private readonly users: UsersService,
private readonly users: UserService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaService,
private readonly event: EventEmitter
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Publicable()
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
nullable: true,
})
async currentUser(@CurrentUser() user?: UserType) {
if (!user) {
return null;
}
const storedUser = await this.users.findUserById(user.id);
if (!storedUser) {
throw new BadRequestException(`User ${user.id} not found in db`);
}
return {
id: storedUser.id,
name: storedUser.name,
email: storedUser.email,
emailVerified: storedUser.emailVerified,
avatarUrl: storedUser.avatarUrl,
createdAt: storedUser.createdAt,
hasPassword: !!storedUser.password,
};
}
@Throttle({
default: {
limit: 10,
@@ -92,9 +63,9 @@ export class UserResolver {
})
@Public()
async user(
@CurrentUser() currentUser?: UserType,
@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`
@@ -102,16 +73,19 @@ export class UserResolver {
}
// TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.users.findUserByEmail(email);
if (currentUser) return user;
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,
email: user.email,
hasPassword: !!user.password,
};
}
@@ -128,7 +102,7 @@ export class UserResolver {
name: 'invoiceCount',
description: 'Get user invoice count',
})
async invoiceCount(@CurrentUser() user: UserType) {
async invoiceCount(@CurrentUser() user: CurrentUser) {
return this.prisma.userInvoice.count({
where: { userId: user.id },
});
@@ -145,7 +119,7 @@ export class UserResolver {
description: 'Upload user avatar',
})
async uploadAvatar(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
@@ -169,6 +143,33 @@ export class UserResolver {
});
}
@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,
@@ -179,7 +180,7 @@ export class UserResolver {
name: 'removeAvatar',
description: 'Remove user avatar',
})
async removeAvatar(@CurrentUser() user: UserType) {
async removeAvatar(@CurrentUser() user: CurrentUser) {
if (!user) {
throw new BadRequestException(`User not found`);
}
@@ -197,7 +198,9 @@ export class UserResolver {
},
})
@Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<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 };

View 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 } });
}
}

View File

@@ -1,7 +1,15 @@
import { createUnionType, Field, ID, ObjectType } from '@nestjs/graphql';
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' })
@@ -42,7 +50,7 @@ export class UserQuotaType {
}
@ObjectType()
export class UserType implements Partial<User> {
export class UserType implements CurrentUser {
@Field(() => ID)
id!: string;
@@ -53,19 +61,25 @@ export class UserType implements Partial<User> {
email!: string;
@Field(() => String, { description: 'User avatar url', nullable: true })
avatarUrl: string | null = null;
avatarUrl!: string | null;
@Field(() => Date, { description: 'User email verified', nullable: true })
emailVerified: Date | null = null;
@Field({ description: 'User created date', nullable: true })
createdAt!: Date;
@Field(() => Boolean, {
description: 'User email verified',
})
emailVerified!: boolean;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
hasPassword!: boolean | null;
@Field(() => Date, {
deprecationReason: 'useless',
description: 'User email verified',
nullable: true,
})
createdAt?: Date | null;
}
@ObjectType()
@@ -77,7 +91,7 @@ export class LimitedUserType implements Partial<User> {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
hasPassword!: boolean | null;
}
export const UserOrLimitedUser = createUnionType({
@@ -101,3 +115,9 @@ export class RemoveAvatar {
@Field()
success!: boolean;
}
@InputType()
export class UpdateUserInput implements Partial<User> {
@Field({ description: 'User name', nullable: true })
name?: string;
}

View File

@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaClient) {}
async findUserByEmail(email: string) {
return this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
}
async findUserById(id: string) {
return this.prisma.user
.findUnique({
where: { id },
})
.catch(() => {
return null;
});
}
async deleteUser(id: string) {
return this.prisma.user.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import z from 'zod';
function getAuthCredentialValidator() {
const email = z.string().email({ message: 'Invalid email address' });
let password = z.string();
const minPasswordLength = AFFiNE.node.prod ? 8 : 1;
password = password
.min(minPasswordLength, {
message: `Password must be ${minPasswordLength} or more charactors long`,
})
.max(20, { message: 'Password must be 20 or fewer charactors long' });
return z
.object({
email,
password,
})
.required();
}
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
const result = z.safeParse(value);
if (!result.success) {
const firstIssue = result.error.issues.at(0);
if (firstIssue) {
throw new BadRequestException(firstIssue.message);
} else {
throw new BadRequestException('Invalid credential');
}
}
}
export function assertValidEmail(email: string) {
assertValid(getAuthCredentialValidator().shape.email, email);
}
export function assertValidPassword(password: string) {
assertValid(getAuthCredentialValidator().shape.password, password);
}
export function assertValidCredential(credential: {
email: string;
password: string;
}) {
assertValid(getAuthCredentialValidator(), credential);
}
export const validators = {
assertValidEmail,
assertValidPassword,
assertValidCredential,
};

View File

@@ -11,10 +11,9 @@ import { PrismaClient } from '@prisma/client';
import type { Response } from 'express';
import { CallTimer } from '../../fundamentals';
import { Auth, CurrentUser, Publicable } from '../auth';
import { CurrentUser, Public } from '../auth';
import { DocHistoryManager, DocManager } from '../doc';
import { WorkspaceBlobStorage } from '../storage';
import { UserType } from '../users';
import { DocID } from '../utils/doc';
import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types';
@@ -63,11 +62,10 @@ export class WorkspacesController {
// get doc binary
@Get('/:id/docs/:guid')
@Auth()
@Publicable()
@Public()
@CallTimer('controllers', 'workspace_get_doc')
async doc(
@CurrentUser() user: UserType | undefined,
@CurrentUser() user: CurrentUser | undefined,
@Param('id') ws: string,
@Param('guid') guid: string,
@Res() res: Response
@@ -112,10 +110,9 @@ export class WorkspacesController {
}
@Get('/:id/docs/:guid/histories/:timestamp')
@Auth()
@CallTimer('controllers', 'workspace_get_history')
async history(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Param('id') ws: string,
@Param('guid') guid: string,
@Param('timestamp') timestamp: string,

View File

@@ -4,7 +4,7 @@ import { DocModule } from '../doc';
import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
import { UsersService } from '../users';
import { UserModule } from '../user';
import { WorkspacesController } from './controller';
import { WorkspaceManagementResolver } from './management';
import { PermissionService } from './permission';
@@ -16,13 +16,12 @@ import {
} from './resolvers';
@Module({
imports: [DocModule, FeatureModule, QuotaModule, StorageModule],
imports: [DocModule, FeatureModule, QuotaModule, StorageModule, UserModule],
controllers: [WorkspacesController],
providers: [
WorkspaceResolver,
WorkspaceManagementResolver,
PermissionService,
UsersService,
PagePermissionResolver,
DocHistoryResolver,
WorkspaceBlobResolver,

View File

@@ -10,14 +10,12 @@ import {
} from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { Auth, CurrentUser } from '../auth';
import { CurrentUser } from '../auth';
import { FeatureManagementService, FeatureType } from '../features';
import { UserType } from '../users';
import { PermissionService } from './permission';
import { WorkspaceType } from './types';
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceManagementResolver {
constructor(
@@ -33,7 +31,7 @@ export class WorkspaceManagementResolver {
})
@Mutation(() => Int)
async addWorkspaceFeature(
@CurrentUser() currentUser: UserType,
@CurrentUser() currentUser: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<number> {
@@ -52,7 +50,7 @@ export class WorkspaceManagementResolver {
})
@Mutation(() => Int)
async removeWorkspaceFeature(
@CurrentUser() currentUser: UserType,
@CurrentUser() currentUser: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<boolean> {
@@ -71,7 +69,7 @@ export class WorkspaceManagementResolver {
})
@Query(() => [WorkspaceType])
async listWorkspaceFeatures(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<WorkspaceType[]> {
if (!this.feature.isStaff(user.email)) {
@@ -83,7 +81,7 @@ export class WorkspaceManagementResolver {
@Mutation(() => Boolean)
async setWorkspaceExperimentalFeature(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
@Args('enable') enable: boolean
@@ -117,7 +115,7 @@ export class WorkspaceManagementResolver {
complexity: 2,
})
async availableFeatures(
@CurrentUser() user: UserType
@CurrentUser() user: CurrentUser
): Promise<FeatureType[]> {
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
if (isEarlyAccessUser) {

View File

@@ -22,16 +22,14 @@ import {
MakeCache,
PreventCache,
} from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { CurrentUser } from '../../auth';
import { FeatureManagementService, FeatureType } from '../../features';
import { QuotaManagementService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UserType } from '../../users';
import { PermissionService } from '../permission';
import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types';
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name);
@@ -47,7 +45,7 @@ export class WorkspaceBlobResolver {
complexity: 2,
})
async blobs(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType
) {
await this.permissions.checkWorkspace(workspace.id, user.id);
@@ -74,7 +72,7 @@ export class WorkspaceBlobResolver {
})
@MakeCache(['blobs'], ['workspaceId'])
async listBlobs(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.permissions.checkWorkspace(workspaceId, user.id);
@@ -90,7 +88,7 @@ export class WorkspaceBlobResolver {
@Query(() => WorkspaceBlobSizes, {
deprecationReason: 'use `user.storageUsage` instead',
})
async collectAllBlobSizes(@CurrentUser() user: UserType) {
async collectAllBlobSizes(@CurrentUser() user: CurrentUser) {
const size = await this.quota.getUserUsage(user.id);
return { size };
}
@@ -102,7 +100,7 @@ export class WorkspaceBlobResolver {
deprecationReason: 'no more needed',
})
async checkBlobSize(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('size', { type: () => SafeIntResolver }) blobSize: number
) {
@@ -121,7 +119,7 @@ export class WorkspaceBlobResolver {
@Mutation(() => String)
@PreventCache(['blobs'], ['workspaceId'])
async setBlob(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
@@ -199,7 +197,7 @@ export class WorkspaceBlobResolver {
@Mutation(() => Boolean)
@PreventCache(['blobs'], ['workspaceId'])
async deleteBlob(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('hash') name: string
) {

View File

@@ -13,9 +13,8 @@ import {
import type { SnapshotHistory } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { CurrentUser } from '../../auth';
import { DocHistoryManager } from '../../doc';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../permission';
import { Permission, WorkspaceType } from '../types';
@@ -68,10 +67,9 @@ export class DocHistoryResolver {
);
}
@Auth()
@Mutation(() => Date)
async recoverDoc(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('guid') guid: string,
@Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date

View File

@@ -15,8 +15,7 @@ import {
} from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { UserType } from '../../users';
import { CurrentUser } from '../../auth';
import { DocID } from '../../utils/doc';
import { PermissionService, PublicPageMode } from '../permission';
import { Permission, WorkspaceType } from '../types';
@@ -42,7 +41,6 @@ class WorkspacePage implements Partial<PrismaWorkspacePage> {
}
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class PagePermissionResolver {
constructor(
@@ -90,7 +88,7 @@ export class PagePermissionResolver {
deprecationReason: 'renamed to publicPage',
})
async deprecatedSharePage(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
@@ -100,7 +98,7 @@ export class PagePermissionResolver {
@Mutation(() => WorkspacePage)
async publishPage(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string,
@Args({
@@ -134,7 +132,7 @@ export class PagePermissionResolver {
deprecationReason: 'use revokePublicPage',
})
async deprecatedRevokePage(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
@@ -144,7 +142,7 @@ export class PagePermissionResolver {
@Mutation(() => WorkspacePage)
async revokePublicPage(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {

View File

@@ -15,7 +15,7 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PrismaClient, type User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
@@ -27,11 +27,10 @@ import {
MailService,
Throttle,
} from '../../../fundamentals';
import { Auth, CurrentUser, Public } from '../../auth';
import { AuthService } from '../../auth/service';
import { CurrentUser, Public } from '../../auth';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UsersService, UserType } from '../../users';
import { UserService, UserType } from '../../user';
import { PermissionService } from '../permission';
import {
InvitationType,
@@ -48,18 +47,16 @@ import { defaultWorkspaceAvatar } from '../utils';
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
private readonly logger = new Logger(WorkspaceResolver.name);
constructor(
private readonly auth: AuthService,
private readonly mailer: MailService,
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService,
private readonly users: UsersService,
private readonly users: UserService,
private readonly event: EventEmitter,
private readonly blobStorage: WorkspaceBlobStorage
) {}
@@ -69,7 +66,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async permission(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
@@ -160,7 +157,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async isOwner(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
@@ -172,7 +169,7 @@ export class WorkspaceResolver {
description: 'Get all accessible workspaces for current user',
complexity: 2,
})
async workspaces(@CurrentUser() user: User) {
async workspaces(@CurrentUser() user: CurrentUser) {
const data = await this.prisma.workspaceUserPermission.findMany({
where: {
userId: user.id,
@@ -216,7 +213,7 @@ export class WorkspaceResolver {
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
await this.permissions.checkWorkspace(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
@@ -231,7 +228,7 @@ export class WorkspaceResolver {
description: 'Create a new workspace',
})
async createWorkspace(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
// we no longer support init workspace with a preload file
// use sync system to uploading them once created
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
@@ -289,7 +286,7 @@ export class WorkspaceResolver {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
@@ -304,7 +301,10 @@ export class WorkspaceResolver {
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
async deleteWorkspace(
@CurrentUser() user: CurrentUser,
@Args('id') id: string
) {
await this.permissions.checkWorkspace(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
@@ -320,7 +320,7 @@ export class WorkspaceResolver {
@Mutation(() => String)
async invite(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('permission', { type: () => Permission }) permission: Permission,
@@ -358,7 +358,7 @@ export class WorkspaceResolver {
// only invite if the user is not already in the workspace
if (originRecord) return originRecord.id;
} else {
target = await this.auth.createAnonymousUser(email);
target = await this.users.createAnonymousUser(email);
}
const inviteId = await this.permissions.grant(
@@ -470,7 +470,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async revoke(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
@@ -514,7 +514,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: UserType,
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('workspaceName') workspaceName: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean

View File

@@ -11,7 +11,7 @@ import {
import type { Workspace } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import { UserType } from '../users/types';
import { UserType } from '../user/types';
export enum Permission {
Read = 0,