mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-02 14:49:44 +08:00
feat!: affine cloud support (#3813)
Co-authored-by: Hongtao Lye <codert.sn@gmail.com> Co-authored-by: liuyi <forehalo@gmail.com> Co-authored-by: LongYinan <lynweklm@gmail.com> Co-authored-by: X1a0t <405028157@qq.com> Co-authored-by: JimmFly <yangjinfei001@gmail.com> Co-authored-by: Peng Xiao <pengxiao@outlook.com> Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com> Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: Qi <474021214@qq.com> Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator, Injectable, UseGuards } 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 { PrismaService } from '../../prisma';
|
||||
import { getRequestResponseFromContext } from '../../utils/nestjs';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
export function getUserFromContext(context: ExecutionContext) {
|
||||
@@ -42,26 +52,71 @@ export const CurrentUser = createParamDecorator(
|
||||
@Injectable()
|
||||
class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private auth: AuthService,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
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 },
|
||||
// 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) {
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
return !!req.user;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -85,3 +140,8 @@ class AuthGuard implements CanActivate {
|
||||
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);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { MAILER, MailService } from './mailer';
|
||||
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],
|
||||
exports: [AuthService],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthResolver,
|
||||
NextAuthOptionsProvider,
|
||||
MAILER,
|
||||
MailService,
|
||||
],
|
||||
exports: [AuthService, NextAuthOptionsProvider, MailService],
|
||||
controllers: [NextAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MailService } from './mail.service';
|
||||
export { MAILER } from './mailer';
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../../config';
|
||||
import {
|
||||
MAILER_SERVICE,
|
||||
type MailerService,
|
||||
type Options,
|
||||
type Response,
|
||||
} from './mailer';
|
||||
import { emailTemplate } from './template';
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
@Inject(MAILER_SERVICE) private readonly mailer: MailerService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
async sendMail(options: Options): Promise<Response> {
|
||||
return this.mailer.sendMail(options);
|
||||
}
|
||||
|
||||
hasConfigured() {
|
||||
return (
|
||||
!!this.config.auth.email.login &&
|
||||
!!this.config.auth.email.password &&
|
||||
!!this.config.auth.email.sender
|
||||
);
|
||||
}
|
||||
|
||||
async sendInviteEmail(
|
||||
to: string,
|
||||
inviteId: string,
|
||||
invitationInfo: {
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
user: {
|
||||
avatar: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
) {
|
||||
console.log('invitationInfo', invitationInfo);
|
||||
|
||||
const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`;
|
||||
const workspaceAvatar = invitationInfo.workspace.avatar;
|
||||
|
||||
const content = ` <img
|
||||
src="${invitationInfo.user.avatar}"
|
||||
alt=""
|
||||
width="24px"
|
||||
height="24px"
|
||||
style="border-radius: 12px;object-fit: cover;vertical-align: middle"
|
||||
/>
|
||||
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.user.name}</span>
|
||||
<span>invited you to join</span>
|
||||
<img
|
||||
src="cid:workspaceAvatar"
|
||||
alt=""
|
||||
width="24px"
|
||||
height="24px"
|
||||
style="margin-left:10px;border-radius: 12px;object-fit: cover;vertical-align: middle"
|
||||
/>
|
||||
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.workspace.name}</span>`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'You are invited!',
|
||||
content,
|
||||
buttonContent: 'Accept & Join',
|
||||
buttonUrl,
|
||||
});
|
||||
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Invitation to workspace`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
cid: 'workspaceAvatar',
|
||||
filename: 'image.png',
|
||||
content: workspaceAvatar,
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
async sendChangePasswordEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Change password</h1>
|
||||
<p>Click button to open change password page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSetPasswordEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Set password</h1>
|
||||
<p>Click button to open set password page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
async sendChangeEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Change Email</h1>
|
||||
<p>Click button to open change email page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
import { createTransport, Transporter } from 'nodemailer';
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { Config } from '../../../config';
|
||||
|
||||
export const MAILER_SERVICE = Symbol('MAILER_SERVICE');
|
||||
|
||||
export type MailerService = Transporter<SMTPTransport.SentMessageInfo>;
|
||||
export type Response = SMTPTransport.SentMessageInfo;
|
||||
export type Options = SMTPTransport.Options;
|
||||
|
||||
export const MAILER: FactoryProvider<
|
||||
Transporter<SMTPTransport.SentMessageInfo>
|
||||
> = {
|
||||
provide: MAILER_SERVICE,
|
||||
useFactory: (config: Config) => {
|
||||
return createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: config.auth.email.login,
|
||||
pass: config.auth.email.password,
|
||||
},
|
||||
});
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
export const emailTemplate = ({
|
||||
title,
|
||||
content,
|
||||
buttonContent,
|
||||
buttonUrl,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
buttonContent: string;
|
||||
buttonUrl: string;
|
||||
}) => {
|
||||
return `<body style="background: #f6f7fb; overflow: hidden">
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="24px"
|
||||
style="
|
||||
background: #fff;
|
||||
max-width: 450px;
|
||||
margin: 32px auto 0 auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://affine.pro" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
|
||||
alt="AFFiNE log"
|
||||
height="32px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>${title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>${content}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px" bgcolor="#1E96EB">
|
||||
<a
|
||||
href="${buttonUrl}"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-family: Inter;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid #1e96eb;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
"
|
||||
>${buttonContent}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
style="
|
||||
background: #fafafa;
|
||||
max-width: 450px;
|
||||
margin: 0 auto 32px auto;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px">
|
||||
<a
|
||||
href="https://github.com/toeverything/AFFiNE"
|
||||
target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
|
||||
alt="AFFiNE github link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://twitter.com/AffineOfficial" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
|
||||
alt="AFFiNE twitter link"
|
||||
height="16px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.youtube.com/@affinepro" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
|
||||
alt="AFFiNE youtube link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://t.me/affineworkos" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
|
||||
alt="AFFiNE telegram link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.reddit.com/r/Affine/" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
|
||||
alt="AFFiNE reddit link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
One hyper-fused platform for wildly creative minds
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
Copyright<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
|
||||
alt="copyright"
|
||||
height="14px"
|
||||
style="vertical-align: middle; margin: 0 4px"
|
||||
/>2023 Toeverything
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>`;
|
||||
};
|
||||
@@ -0,0 +1,501 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import Email, {
|
||||
type SendVerificationRequestParams,
|
||||
} from 'next-auth/providers/email';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { NewFeaturesKind } from '../users/types';
|
||||
import { MailService } from './mailer';
|
||||
import { getUtcTimestamp, UserClaim } from './service';
|
||||
|
||||
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
||||
|
||||
function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) {
|
||||
const { searchParams } = new URL(callbackUrl, origin);
|
||||
return searchParams.has('schema') ? searchParams.get('schema') : null;
|
||||
}
|
||||
|
||||
function wrapUrlWithSchema(url: string, schema: string | null) {
|
||||
if (schema) {
|
||||
return `${schema}://open-url?${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
provide: NextAuthOptionsProvide,
|
||||
useFactory(config: Config, prisma: PrismaService, mailer: MailService) {
|
||||
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,
|
||||
};
|
||||
if (data.email && !data.name) {
|
||||
userData.name = data.email.split('@')[0];
|
||||
}
|
||||
if (data.image) {
|
||||
userData.avatarUrl = data.image;
|
||||
}
|
||||
return createUser(userData);
|
||||
};
|
||||
// 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,
|
||||
async sendVerificationRequest(params: SendVerificationRequestParams) {
|
||||
const { identifier, url, provider } = params;
|
||||
const { host, searchParams, origin } = new URL(url);
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '';
|
||||
if (!callbackUrl) {
|
||||
throw new Error('callbackUrl is not set');
|
||||
}
|
||||
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
|
||||
const wrappedUrl = wrapUrlWithSchema(url, schema);
|
||||
// hack: check if link is opened via desktop
|
||||
const result = await mailer.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
text: text({ url: wrappedUrl, host }),
|
||||
html: html({ url: wrappedUrl, host }),
|
||||
});
|
||||
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`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.node.prod,
|
||||
session: {
|
||||
strategy: config.node.prod ? 'database' : 'jwt',
|
||||
},
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
logger: console,
|
||||
};
|
||||
|
||||
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) {
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) => {
|
||||
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: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
},
|
||||
decode: async ({ token }) => {
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
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 }) => {
|
||||
if (!config.affine.beta || !config.node.prod) {
|
||||
return true;
|
||||
}
|
||||
if (profile?.email) {
|
||||
return await prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: {
|
||||
email: profile.email,
|
||||
type: NewFeaturesKind.EarlyAccess,
|
||||
},
|
||||
})
|
||||
.then(user => !!user)
|
||||
.catch(() => false);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
redirect({ url }) {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaService, MailService],
|
||||
};
|
||||
|
||||
/**
|
||||
* Email HTML body
|
||||
* Insert invisible space into domains from being turned into a hyperlink by email
|
||||
* clients like Outlook and Apple mail, as this is confusing because it seems
|
||||
* like they are supposed to click on it to sign in.
|
||||
*
|
||||
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
|
||||
*/
|
||||
function html(params: { url: string; host: string }) {
|
||||
const { url } = params;
|
||||
|
||||
return `
|
||||
<body style="background: #f6f7fb;overflow:hidden">
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="24px"
|
||||
style="
|
||||
background: #fff;
|
||||
max-width: 450px;
|
||||
margin: 32px auto 0 auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://affine.pro" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
|
||||
alt="AFFiNE log"
|
||||
height="32px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>
|
||||
Verify your new email for AFFiNE
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>
|
||||
You recently requested to change the email address associated with your
|
||||
AFFiNe account. To complete this process, please click on the
|
||||
verification link below.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px" bgcolor="#1E96EB">
|
||||
<a
|
||||
href="${url}"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-family: Inter;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid #1e96eb;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
"
|
||||
>Verify your new email address</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
style="
|
||||
background: #fafafa;
|
||||
max-width: 450px;
|
||||
margin: 0 auto 32px auto;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://github.com/toeverything/AFFiNE" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
|
||||
alt="AFFiNE github link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://twitter.com/AffineOfficial" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
|
||||
alt="AFFiNE twitter link"
|
||||
height="16px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.youtube.com/@affinepro" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
|
||||
alt="AFFiNE youtube link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://t.me/affineworkos" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
|
||||
alt="AFFiNE telegram link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.reddit.com/r/Affine/" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
|
||||
alt="AFFiNE reddit link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
One hyper-fused platform for wildly creative minds
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
Copyright<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
|
||||
alt="copyright"
|
||||
height="14px"
|
||||
style="vertical-align: middle; margin: 0 4px"
|
||||
/>2023 Toeverything
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
||||
function text({ url, host }: { url: string; host: string }) {
|
||||
return `Sign in to ${host}\n${url}\n\n`;
|
||||
}
|
||||
@@ -1,141 +1,41 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import {
|
||||
All,
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Inject,
|
||||
Next,
|
||||
NotFoundException,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import type { User } from '@prisma/client';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { AuthAction, AuthOptions } from 'next-auth';
|
||||
import { pick } from 'lodash-es';
|
||||
import type { AuthAction, NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
import Email from 'next-auth/providers/email';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { getUtcTimestamp, type UserClaim } from './service';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
const BASE_URL = '/api/auth/';
|
||||
|
||||
@Controller(BASE_URL)
|
||||
export class NextAuthController {
|
||||
private readonly nextAuthOptions: AuthOptions;
|
||||
private readonly callbackSession;
|
||||
|
||||
constructor(
|
||||
readonly config: Config,
|
||||
readonly prisma: PrismaService
|
||||
readonly prisma: PrismaService,
|
||||
private readonly authService: AuthService,
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions
|
||||
) {
|
||||
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 => {
|
||||
if (data.email && !data.name) {
|
||||
data.name = data.email.split('@')[0];
|
||||
}
|
||||
return createUser(data);
|
||||
};
|
||||
this.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.sender,
|
||||
pass: config.auth.email.password,
|
||||
},
|
||||
},
|
||||
from: `AFFiNE <no-reply@toeverything.info>`,
|
||||
}),
|
||||
],
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.prod,
|
||||
};
|
||||
|
||||
if (config.auth.oauthProviders.github) {
|
||||
this.nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Github.default({
|
||||
clientId: config.auth.oauthProviders.github.clientId,
|
||||
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.google) {
|
||||
this.nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Google.default({
|
||||
clientId: config.auth.oauthProviders.google.clientId,
|
||||
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) => {
|
||||
if (!token?.email) {
|
||||
throw new BadRequestException('Missing email in jwt token');
|
||||
}
|
||||
const user = await this.prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
},
|
||||
decode: async ({ token }) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const { name, email, id } = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
sub: id,
|
||||
};
|
||||
},
|
||||
};
|
||||
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||
this.callbackSession = nextAuthOptions.callbacks!.session;
|
||||
}
|
||||
|
||||
@All('*')
|
||||
@@ -145,25 +45,69 @@ export class NextAuthController {
|
||||
@Query() query: Record<string, any>,
|
||||
@Next() next: NextFunction
|
||||
) {
|
||||
const nextauth = req.url // start with request url
|
||||
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[]; // as array of strings;
|
||||
.split('/') as [AuthAction, string]; // as array of strings;
|
||||
if (providerId === 'credentials') {
|
||||
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 {
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||
req: {
|
||||
body: req.body,
|
||||
query: query,
|
||||
method: req.method,
|
||||
action: nextauth[0],
|
||||
providerId: nextauth[1],
|
||||
error: query.error ?? nextauth[1],
|
||||
action,
|
||||
providerId,
|
||||
error: query.error ?? providerId,
|
||||
cookies: req.cookies,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
options,
|
||||
});
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
for (const { key, value } of headers) {
|
||||
res.setHeader(key, value);
|
||||
@@ -174,8 +118,32 @@ export class NextAuthController {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
|
||||
res.redirect('https://community.affine.pro/c/insider-general/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
res.redirect(redirect);
|
||||
console.log(providerId, action, req.headers);
|
||||
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') {
|
||||
@@ -184,4 +152,91 @@ export class NextAuthController {
|
||||
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.getUserFromRequest(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.getUserFromRequest(req);
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: pick(newSession, 'name', 'email'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getUserFromRequest(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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
@@ -26,7 +27,10 @@ export class TokenType {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class AuthResolver {
|
||||
constructor(private auth: AuthService) {}
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private auth: AuthService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => TokenType)
|
||||
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
|
||||
@@ -41,13 +45,13 @@ export class AuthResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async register(
|
||||
async signUp(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('name') name: string,
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
const user = await this.auth.register(name, email, password);
|
||||
const user = await this.auth.signUp(name, email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
@@ -62,4 +66,56 @@ export class AuthResolver {
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async changePassword(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('id') id: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
const user = await this.auth.changePassword(id, newPassword);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async changeEmail(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('id') id: string,
|
||||
@Args('email') email: string
|
||||
) {
|
||||
const user = await this.auth.changeEmail(id, email);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sendChangePasswordEmail(
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sendSetPasswordEmail(
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sendChangeEmail(
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendChangeEmail(email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,14 @@ import type { User } from '@prisma/client';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { MailService } from './mailer';
|
||||
|
||||
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
|
||||
export type UserClaim = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
|
||||
> & {
|
||||
hasPassword?: boolean;
|
||||
};
|
||||
|
||||
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||
|
||||
@@ -21,7 +27,8 @@ export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private config: Config,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
private mailer: MailService
|
||||
) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
@@ -32,6 +39,9 @@ export class AuthService {
|
||||
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,
|
||||
@@ -58,6 +68,9 @@ export class AuthService {
|
||||
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,
|
||||
@@ -78,7 +91,7 @@ export class AuthService {
|
||||
|
||||
async verify(token: string) {
|
||||
try {
|
||||
return (
|
||||
const data = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
@@ -86,6 +99,12 @@ export class AuthService {
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).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');
|
||||
}
|
||||
@@ -119,7 +138,7 @@ export class AuthService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async register(name: string, email: string, password: string): Promise<User> {
|
||||
async signUp(name: string, email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
@@ -140,4 +159,96 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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(id: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(newPassword);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user