refactor(server): plugin modules (#5630)
- [x] separates modules into `fundamental`, `core`, `plugins`
- [x] optional modules with `@OptionalModule` decorator to install modules with requirements met(`requires`, `if`)
- [x] `module.contributesTo` defines optional features that will be enabled if module registered
- [x] `AFFiNE.plugins.use('payment', {})` to enable a optional/plugin module
- [x] `PaymentModule` is the first plugin module
- [x] GraphQLSchema will not be generated for non-included modules
- [x] Frontend can use `ServerConfigType` query to detect which features are enabled
- [x] override existing provider globally
This commit is contained in:
152
packages/backend/server/src/core/auth/guard.ts
Normal file
152
packages/backend/server/src/core/auth/guard.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
createParamDecorator,
|
||||
Inject,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
|
||||
import {
|
||||
getRequestResponseFromContext,
|
||||
PrismaService,
|
||||
} from '../../fundamentals';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
export 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 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 {
|
||||
constructor(
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private readonly auth: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.get<boolean>(
|
||||
'isPublic',
|
||||
context.getHandler()
|
||||
);
|
||||
// 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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
|
||||
*
|
||||
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
|
||||
* fast throw if user is not logged in.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* \@Auth()
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Auth = () => {
|
||||
return UseGuards(AuthGuard);
|
||||
};
|
||||
|
||||
// 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);
|
||||
18
packages/backend/server/src/core/auth/index.ts
Normal file
18
packages/backend/server/src/core/auth/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { NextAuthController } from './next-auth.controller';
|
||||
import { NextAuthOptionsProvider } from './next-auth-options';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthService, AuthResolver, NextAuthOptionsProvider],
|
||||
exports: [AuthService, NextAuthOptionsProvider],
|
||||
controllers: [NextAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
export * from './guard';
|
||||
export { TokenType } from './resolver';
|
||||
export { AuthService };
|
||||
270
packages/backend/server/src/core/auth/next-auth-options.ts
Normal file
270
packages/backend/server/src/core/auth/next-auth-options.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
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,
|
||||
PrismaService,
|
||||
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: PrismaService,
|
||||
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;
|
||||
}
|
||||
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;
|
||||
};
|
||||
const nextAuthOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
// @ts-expect-error esm interop issue
|
||||
Email.default({
|
||||
server: {
|
||||
host: config.auth.email.server,
|
||||
port: config.auth.email.port,
|
||||
auth: {
|
||||
user: config.auth.email.login,
|
||||
pass: config.auth.email.password,
|
||||
},
|
||||
},
|
||||
from: config.auth.email.sender,
|
||||
sendVerificationRequest: (params: SendVerificationRequestParams) =>
|
||||
sendVerificationRequest(config, logger, mailer, session, params),
|
||||
}),
|
||||
],
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.node.prod,
|
||||
session: {
|
||||
strategy: 'database',
|
||||
},
|
||||
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.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' },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
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, PrismaService, MailService, SessionService],
|
||||
};
|
||||
409
packages/backend/server/src/core/auth/next-auth.controller.ts
Normal file
409
packages/backend/server/src/core/auth/next-auth.controller.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
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 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,
|
||||
PrismaService,
|
||||
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: PrismaService,
|
||||
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,
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
};
|
||||
305
packages/backend/server/src/core/auth/resolver.ts
Normal file
305
packages/backend/server/src/core/auth/resolver.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Field,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { Request } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
Config,
|
||||
SessionService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { UserType } from '../users';
|
||||
import { Auth, CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@ObjectType()
|
||||
export class TokenType {
|
||||
@Field()
|
||||
token!: string;
|
||||
|
||||
@Field()
|
||||
refresh!: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth resolver
|
||||
* Token rate limit: 20 req/m
|
||||
* Sign up/in rate limit: 10 req/m
|
||||
* Other rate limit: 5 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Resolver(() => UserType)
|
||||
export class AuthResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly auth: AuthService,
|
||||
private readonly session: SessionService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@ResolveField(() => TokenType)
|
||||
async token(
|
||||
@Context() ctx: { req: Request },
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Parent() user: UserType
|
||||
) {
|
||||
if (user.id !== currentUser.id) {
|
||||
throw new BadRequestException('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];
|
||||
}
|
||||
|
||||
return {
|
||||
sessionToken,
|
||||
token: this.auth.sign(user),
|
||||
refresh: this.auth.refresh(user),
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
async signUp(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('name') name: string,
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
const user = await this.auth.signUp(name, email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
async signIn(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
const user = await this.auth.signIn(email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changePassword(
|
||||
@CurrentUser() user: UserType,
|
||||
@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)
|
||||
) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changePassword(user.email, newPassword);
|
||||
await this.session.delete(token);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string
|
||||
) {
|
||||
const key = await this.session.get(token);
|
||||
if (!key) {
|
||||
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);
|
||||
|
||||
await this.auth.sendNotificationChangeEmail(email);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangePasswordEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendSetPasswordEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
// The change email step is:
|
||||
// 1. send email to primitive email `sendChangeEmail`
|
||||
// 2. user open change email page from email
|
||||
// 3. send verify email to new email `sendVerifyChangeEmail`
|
||||
// 4. user open confirm email page from new email
|
||||
// 5. user click confirm button
|
||||
// 6. send notification email
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangeEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendVerifyChangeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const id = await this.session.get(token);
|
||||
if (!id || id !== user.id) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
const hasRegistered = await this.auth.getUserByEmail(email);
|
||||
|
||||
if (hasRegistered) {
|
||||
throw new BadRequestException(`Invalid user email`);
|
||||
}
|
||||
|
||||
const withEmailToken = nanoid();
|
||||
await this.session.set(withEmailToken, `${user.id},${email}`);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', withEmailToken);
|
||||
|
||||
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
|
||||
|
||||
await this.session.delete(token);
|
||||
|
||||
return !res.rejected.length;
|
||||
}
|
||||
}
|
||||
325
packages/backend/server/src/core/auth/service.ts
Normal file
325
packages/backend/server/src/core/auth/service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import type { User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
Config,
|
||||
MailService,
|
||||
PrismaService,
|
||||
verifyChallengeResponse,
|
||||
} from '../../fundamentals';
|
||||
import { Quota_FreePlanV1_1 } from '../quota';
|
||||
|
||||
export type UserClaim = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
|
||||
> & {
|
||||
hasPassword?: boolean;
|
||||
};
|
||||
|
||||
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailer: MailService
|
||||
) {}
|
||||
|
||||
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 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.affineEnv === 'dev' || outcome.hostname === this.config.host)
|
||||
);
|
||||
}
|
||||
|
||||
async verifyChallengeResponse(response: any, resource: string) {
|
||||
return verifyChallengeResponse(
|
||||
response,
|
||||
this.config.auth.captcha.challenge.bits,
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('User has no password');
|
||||
}
|
||||
let equal = false;
|
||||
try {
|
||||
equal = await verify(user.password, password);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e, 'Verify password failed');
|
||||
}
|
||||
if (!equal) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async signUp(name: string, email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async isUserHasPassword(email: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
return Boolean(user.password);
|
||||
}
|
||||
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(newPassword);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async changeEmail(id: string, newEmail: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
email: newEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendChangePasswordEmail(email, callbackUrl);
|
||||
}
|
||||
async sendSetPasswordEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendSetPasswordEmail(email, callbackUrl);
|
||||
}
|
||||
async sendChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendChangeEmail(email, callbackUrl);
|
||||
}
|
||||
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
|
||||
}
|
||||
async sendNotificationChangeEmail(email: string) {
|
||||
return this.mailer.sendNotificationChangeEmail(email);
|
||||
}
|
||||
}
|
||||
3
packages/backend/server/src/core/auth/utils/index.ts
Normal file
3
packages/backend/server/src/core/auth/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { jwtDecode as decode, jwtEncode as encode } from './jwt';
|
||||
export { sendVerificationRequest } from './send-mail';
|
||||
export type { SendVerificationRequestParams } from 'next-auth/providers/email';
|
||||
75
packages/backend/server/src/core/auth/utils/jwt.ts
Normal file
75
packages/backend/server/src/core/auth/utils/jwt.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
|
||||
import { Config, PrismaService } from '../../../fundamentals';
|
||||
import { getUtcTimestamp, UserClaim } from '../service';
|
||||
|
||||
export const jwtEncode = async (
|
||||
config: Config,
|
||||
prisma: PrismaService,
|
||||
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,
|
||||
};
|
||||
};
|
||||
39
packages/backend/server/src/core/auth/utils/send-mail.ts
Normal file
39
packages/backend/server/src/core/auth/utils/send-mail.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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, provider } = 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,
|
||||
from: provider.from,
|
||||
});
|
||||
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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user