feat: add authjs backend support
This commit is contained in:
parent
d5d04468cb
commit
06d6ecd2a3
@ -42,8 +42,8 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
app.use('/auth/*', ExpressAuth(authConfig));
|
||||
app.use('/health', healthRouter);
|
||||
app.use('/api/auth/*', ExpressAuth(authConfig));
|
||||
app.use('/api/website', websiteRouter);
|
||||
app.use('/api/workspace', workspaceRouter);
|
||||
app.use('/monitor', monitorRouter);
|
||||
|
@ -1,11 +1,246 @@
|
||||
import { AuthConfig } from '@auth/core';
|
||||
import Nodemailer from '@auth/core/providers/nodemailer';
|
||||
import Credentials from '@auth/core/providers/credentials';
|
||||
import { env } from '../utils/env.js';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { prisma } from './_client.js';
|
||||
import type { PrismaClient, Prisma, User } from '@prisma/client';
|
||||
import type {
|
||||
Adapter,
|
||||
AdapterAccount,
|
||||
AdapterSession,
|
||||
AdapterUser,
|
||||
} from '@auth/core/adapters';
|
||||
import { authUser, createUserWithAuthjs } from './user.js';
|
||||
import { createTransport } from 'nodemailer';
|
||||
import { Theme } from '@auth/core/types';
|
||||
import { generateSMTPHTML } from '../utils/smtp.js';
|
||||
|
||||
export const authConfig: Omit<AuthConfig, 'raw'> = {
|
||||
providers: [Nodemailer(env.auth.email)],
|
||||
adapter: PrismaAdapter(prisma),
|
||||
debug: true,
|
||||
providers: [
|
||||
Credentials({
|
||||
name: 'Account',
|
||||
credentials: {
|
||||
username: { label: 'Username' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
authorize: async (credentials) => {
|
||||
const user = await authUser(
|
||||
String(credentials.username),
|
||||
String(credentials.password)
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// No user found, so this is their first attempt to login
|
||||
// meaning this is also the place you could do registration
|
||||
throw new Error('User not found.');
|
||||
}
|
||||
|
||||
// return user object with the their profile data
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
}),
|
||||
Nodemailer({
|
||||
name: 'Email',
|
||||
...env.auth.email,
|
||||
async sendVerificationRequest(params) {
|
||||
const { identifier, url, provider, theme } = params;
|
||||
const { host } = new URL(url);
|
||||
const transport = createTransport(provider.server);
|
||||
const result = await transport.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in Tianji to ${host}`,
|
||||
text: `Sign in Tianji to ${host}\n${url}\n\n`,
|
||||
html: nodemailHtmlBody({ url, host, theme }),
|
||||
});
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
if (failed.length) {
|
||||
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
adapter: TianjiPrismaAdapter(prisma),
|
||||
secret: env.auth.secret,
|
||||
};
|
||||
|
||||
function toAdapterUser(
|
||||
user: Pick<
|
||||
User,
|
||||
'id' | 'nickname' | 'username' | 'avatar' | 'email' | 'emailVerified'
|
||||
>
|
||||
): AdapterUser {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.nickname ?? user.username,
|
||||
image: user.avatar,
|
||||
email: user.email!,
|
||||
emailVerified: user.emailVerified,
|
||||
};
|
||||
}
|
||||
|
||||
function TianjiPrismaAdapter(
|
||||
prisma: PrismaClient | ReturnType<PrismaClient['$extends']>
|
||||
): Adapter {
|
||||
const p = prisma as PrismaClient;
|
||||
|
||||
return {
|
||||
// We need to let Prisma generate the ID because our default UUID is incompatible with MongoDB
|
||||
createUser: async ({ id: _id, ...data }) => {
|
||||
const user = await createUserWithAuthjs(data);
|
||||
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
getUser: async (id) => {
|
||||
const user = await p.user.findUnique({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
getUserByEmail: async (email) => {
|
||||
const user = await p.user.findUnique({ where: { email } });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
async getUserByAccount(provider_providerAccountId) {
|
||||
const account = await p.account.findUnique({
|
||||
where: { provider_providerAccountId },
|
||||
select: { user: true },
|
||||
});
|
||||
return (account?.user as AdapterUser) ?? null;
|
||||
},
|
||||
updateUser: ({ id, ...data }) =>
|
||||
p.user.update({ where: { id }, data }) as Promise<AdapterUser>,
|
||||
deleteUser: (id) =>
|
||||
p.user.delete({ where: { id } }) as Promise<AdapterUser>,
|
||||
linkAccount: (data) =>
|
||||
p.account.create({ data }) as unknown as AdapterAccount,
|
||||
unlinkAccount: (provider_providerAccountId) =>
|
||||
p.account.delete({
|
||||
where: { provider_providerAccountId },
|
||||
}) as unknown as AdapterAccount,
|
||||
async getSessionAndUser(sessionToken) {
|
||||
const userAndSession = await p.session.findUnique({
|
||||
where: { sessionToken },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!userAndSession) return null;
|
||||
const { user, ...session } = userAndSession;
|
||||
return { user, session } as {
|
||||
user: AdapterUser;
|
||||
session: AdapterSession;
|
||||
};
|
||||
},
|
||||
createSession: (data) => p.session.create({ data }),
|
||||
updateSession: (data) =>
|
||||
p.session.update({ where: { sessionToken: data.sessionToken }, data }),
|
||||
deleteSession: (sessionToken) =>
|
||||
p.session.delete({ where: { sessionToken } }),
|
||||
async createVerificationToken(data) {
|
||||
const verificationToken = await p.verificationToken.create({ data });
|
||||
// @ts-expect-errors // MongoDB needs an ID, but we don't
|
||||
if (verificationToken.id) delete verificationToken.id;
|
||||
return verificationToken;
|
||||
},
|
||||
async useVerificationToken(identifier_token) {
|
||||
try {
|
||||
const verificationToken = await p.verificationToken.delete({
|
||||
where: { identifier_token },
|
||||
});
|
||||
// @ts-expect-errors // MongoDB needs an ID, but we don't
|
||||
if (verificationToken.id) delete verificationToken.id;
|
||||
return verificationToken;
|
||||
} catch (error) {
|
||||
// If token already used/deleted, just return null
|
||||
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
|
||||
if ((error as Prisma.PrismaClientKnownRequestError).code === 'P2025')
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async getAccount(providerAccountId, provider) {
|
||||
return p.account.findFirst({
|
||||
where: { providerAccountId, provider },
|
||||
}) as Promise<AdapterAccount | null>;
|
||||
},
|
||||
// async createAuthenticator(authenticator) {
|
||||
// return p.authenticator.create({
|
||||
// data: authenticator,
|
||||
// });
|
||||
// },
|
||||
// async getAuthenticator(credentialID) {
|
||||
// return p.authenticator.findUnique({
|
||||
// where: { credentialID },
|
||||
// });
|
||||
// },
|
||||
// async listAuthenticatorsByUserId(userId) {
|
||||
// return p.authenticator.findMany({
|
||||
// where: { userId },
|
||||
// });
|
||||
// },
|
||||
// async updateAuthenticatorCounter(credentialID, counter) {
|
||||
// return p.authenticator.update({
|
||||
// where: { credentialID },
|
||||
// data: { counter },
|
||||
// });
|
||||
// },
|
||||
};
|
||||
}
|
||||
|
||||
function nodemailHtmlBody(params: { url: string; host: string; theme: Theme }) {
|
||||
const { url, host, theme } = params;
|
||||
|
||||
const escapedHost = host.replace(/\./g, '​.');
|
||||
|
||||
const brandColor = theme.brandColor || '#346df1';
|
||||
const color = {
|
||||
background: '#f9f9f9',
|
||||
text: '#444',
|
||||
mainBackground: '#fff',
|
||||
buttonBackground: brandColor,
|
||||
buttonBorder: brandColor,
|
||||
buttonText: theme.buttonText || '#fff',
|
||||
};
|
||||
|
||||
const body = `
|
||||
<body style="background: ${color.background};">
|
||||
<table width="100%" border="0" cellspacing="20" cellpadding="0"
|
||||
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||
Sign in Tianji to <strong>${escapedHost}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
|
||||
target="_blank"
|
||||
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
|
||||
in</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||
If you did not request this email you can safely ignore it.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
`;
|
||||
|
||||
return generateSMTPHTML(body);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { NotificationProvider } from './type.js';
|
||||
import nodemailer from 'nodemailer';
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { htmlContentTokenizer } from '../token/index.js';
|
||||
import { generateSMTPHTML } from '../../../utils/smtp.js';
|
||||
|
||||
interface SMTPPayload {
|
||||
hostname: string;
|
||||
@ -49,38 +50,3 @@ export const smtp: NotificationProvider = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function generateSMTPHTML(message: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tianji</title>
|
||||
<style>
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #fafafa;">
|
||||
<div style="width: 640px; margin: auto;">
|
||||
<header style="margin-bottom: 10px; padding-top: 10px; text-align: center;">
|
||||
<img src="https://tianji.msgbyte.com/img/logo@128.png" width="50" height="50" />
|
||||
</header>
|
||||
<div style="background-color: #fff; border: 1px solid #dddddd; padding: 36px; margin-bottom: 10px;">
|
||||
${message}
|
||||
</div>
|
||||
<footer style="text-align: center;">
|
||||
<div>
|
||||
Sent with ❤ by Tianji.
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/msgbyte/tianji" target="_blank">Github</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ import { ROLES, SYSTEM_ROLES } from '@tianji/shared';
|
||||
import { jwtVerify } from '../middleware/auth.js';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AdapterUser } from '@auth/core/adapters';
|
||||
import { md5 } from '../utils/common.js';
|
||||
|
||||
async function hashPassword(password: string) {
|
||||
return await bcryptjs.hash(password, 10);
|
||||
@ -22,6 +24,10 @@ export async function getUserCount(): Promise<number> {
|
||||
const createUserSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@ -132,6 +138,52 @@ export async function createUser(username: string, password: string) {
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createUserWithAuthjs(data: Omit<AdapterUser, 'id'>) {
|
||||
const existCount = await prisma.user.count({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existCount > 0) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const user = await prisma.$transaction(async (p) => {
|
||||
const newWorkspace = await p.workspace.create({
|
||||
data: {
|
||||
name: data.name ?? data.email,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await p.user.create({
|
||||
data: {
|
||||
username: data.email,
|
||||
nickname: data.name,
|
||||
password: await hashPassword(md5(String(Date.now()))),
|
||||
email: data.email,
|
||||
emailVerified: data.emailVerified,
|
||||
role: SYSTEM_ROLES.user,
|
||||
avatar: data.image,
|
||||
workspaces: {
|
||||
create: [
|
||||
{
|
||||
role: ROLES.owner,
|
||||
workspaceId: newWorkspace.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
currentWorkspaceId: newWorkspace.id,
|
||||
},
|
||||
select: createUserSelect,
|
||||
});
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function authUser(username: string, password: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
|
@ -25,7 +25,6 @@
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.1",
|
||||
"@auth/express": "^0.5.5",
|
||||
"@auth/prisma-adapter": "^2.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@prisma/client": "5.14.0",
|
||||
"@tianji/shared": "workspace:^",
|
||||
|
@ -1,8 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "email" TEXT,
|
||||
ALTER TABLE "User" ADD COLUMN "avatar" TEXT,
|
||||
ADD COLUMN "email" TEXT,
|
||||
ADD COLUMN "emailVerified" TIMESTAMP(3),
|
||||
ADD COLUMN "image" TEXT,
|
||||
ADD COLUMN "name" TEXT;
|
||||
ADD COLUMN "nickname" VARCHAR(255);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
@ -44,6 +50,9 @@ CREATE TABLE "VerificationToken" (
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
@ -19,12 +19,12 @@ generator zod {
|
||||
|
||||
model User {
|
||||
id String @id @unique @default(cuid()) @db.VarChar(30)
|
||||
name String?
|
||||
email String?
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
username String @unique @db.VarChar(255)
|
||||
password String @db.VarChar(60)
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
nickname String? @db.VarChar(255)
|
||||
avatar String?
|
||||
role String @db.VarChar(50)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||
|
@ -4,12 +4,12 @@ import { CompleteWorkspace, RelatedWorkspaceModelSchema, CompleteAccount, Relate
|
||||
|
||||
export const UserModelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
emailVerified: z.date().nullish(),
|
||||
image: z.string().nullish(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
email: z.string().nullish(),
|
||||
emailVerified: z.date().nullish(),
|
||||
nickname: z.string().nullish(),
|
||||
avatar: z.string().nullish(),
|
||||
role: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
34
src/server/utils/smtp.ts
Normal file
34
src/server/utils/smtp.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export function generateSMTPHTML(body: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tianji</title>
|
||||
<style>
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #fafafa;">
|
||||
<div style="width: 640px; margin: auto;">
|
||||
<header style="margin-bottom: 10px; padding-top: 10px; text-align: center;">
|
||||
<img src="https://tianji.msgbyte.com/img/logo@128.png" width="50" height="50" />
|
||||
</header>
|
||||
<div style="background-color: #fff; border: 1px solid #dddddd; padding: 36px; margin-bottom: 10px;">
|
||||
${body}
|
||||
</div>
|
||||
<footer style="text-align: center;">
|
||||
<div>
|
||||
Sent with ❤ by Tianji.
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/msgbyte/tianji" target="_blank">Github</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
Loading…
Reference in New Issue
Block a user