feat: add authjs backend support

This commit is contained in:
moonrailgun 2024-07-30 21:10:28 +08:00
parent d5d04468cb
commit 06d6ecd2a3
9 changed files with 346 additions and 51 deletions

View File

@ -42,8 +42,8 @@ app.use(
}) })
); );
app.use('/auth/*', ExpressAuth(authConfig));
app.use('/health', healthRouter); app.use('/health', healthRouter);
app.use('/api/auth/*', ExpressAuth(authConfig));
app.use('/api/website', websiteRouter); app.use('/api/website', websiteRouter);
app.use('/api/workspace', workspaceRouter); app.use('/api/workspace', workspaceRouter);
app.use('/monitor', monitorRouter); app.use('/monitor', monitorRouter);

View File

@ -1,11 +1,246 @@
import { AuthConfig } from '@auth/core'; import { AuthConfig } from '@auth/core';
import Nodemailer from '@auth/core/providers/nodemailer'; import Nodemailer from '@auth/core/providers/nodemailer';
import Credentials from '@auth/core/providers/credentials';
import { env } from '../utils/env.js'; import { env } from '../utils/env.js';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from './_client.js'; 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'> = { export const authConfig: Omit<AuthConfig, 'raw'> = {
providers: [Nodemailer(env.auth.email)], debug: true,
adapter: PrismaAdapter(prisma), 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, 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, '&#8203;.');
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);
}

View File

@ -2,6 +2,7 @@ import { NotificationProvider } from './type.js';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport'; import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { htmlContentTokenizer } from '../token/index.js'; import { htmlContentTokenizer } from '../token/index.js';
import { generateSMTPHTML } from '../../../utils/smtp.js';
interface SMTPPayload { interface SMTPPayload {
hostname: string; 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>
`;
}

View File

@ -4,6 +4,8 @@ import { ROLES, SYSTEM_ROLES } from '@tianji/shared';
import { jwtVerify } from '../middleware/auth.js'; import { jwtVerify } from '../middleware/auth.js';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AdapterUser } from '@auth/core/adapters';
import { md5 } from '../utils/common.js';
async function hashPassword(password: string) { async function hashPassword(password: string) {
return await bcryptjs.hash(password, 10); return await bcryptjs.hash(password, 10);
@ -22,6 +24,10 @@ export async function getUserCount(): Promise<number> {
const createUserSelect = { const createUserSelect = {
id: true, id: true,
username: true, username: true,
nickname: true,
avatar: true,
email: true,
emailVerified: true,
role: true, role: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
@ -132,6 +138,52 @@ export async function createUser(username: string, password: string) {
return user; 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) { export async function authUser(username: string, password: string) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {

View File

@ -25,7 +25,6 @@
"dependencies": { "dependencies": {
"@auth/core": "^0.34.1", "@auth/core": "^0.34.1",
"@auth/express": "^0.5.5", "@auth/express": "^0.5.5",
"@auth/prisma-adapter": "^2.1.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@prisma/client": "5.14.0", "@prisma/client": "5.14.0",
"@tianji/shared": "workspace:^", "@tianji/shared": "workspace:^",

View File

@ -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 -- 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 "emailVerified" TIMESTAMP(3),
ADD COLUMN "image" TEXT, ADD COLUMN "nickname" VARCHAR(255);
ADD COLUMN "name" TEXT;
-- CreateTable -- CreateTable
CREATE TABLE "Account" ( CREATE TABLE "Account" (
@ -44,6 +50,9 @@ CREATE TABLE "VerificationToken" (
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -19,12 +19,12 @@ generator zod {
model User { model User {
id String @id @unique @default(cuid()) @db.VarChar(30) id String @id @unique @default(cuid()) @db.VarChar(30)
name String?
email String?
emailVerified DateTime?
image String?
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
email String? @unique
emailVerified DateTime?
nickname String? @db.VarChar(255)
avatar String?
role String @db.VarChar(50) role String @db.VarChar(50)
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)

View File

@ -4,12 +4,12 @@ import { CompleteWorkspace, RelatedWorkspaceModelSchema, CompleteAccount, Relate
export const UserModelSchema = z.object({ export const UserModelSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().nullish(),
email: z.string().nullish(),
emailVerified: z.date().nullish(),
image: z.string().nullish(),
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
email: z.string().nullish(),
emailVerified: z.date().nullish(),
nickname: z.string().nullish(),
avatar: z.string().nullish(),
role: z.string(), role: z.string(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),

34
src/server/utils/smtp.ts Normal file
View 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>
`;
}