diff --git a/src/server/app.ts b/src/server/app.ts index 5bd0469..8e84f88 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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); diff --git a/src/server/model/auth.ts b/src/server/model/auth.ts index b7b4a19..339665c 100644 --- a/src/server/model/auth.ts +++ b/src/server/model/auth.ts @@ -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 = { - 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 +): 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, + deleteUser: (id) => + p.user.delete({ where: { id } }) as Promise, + 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; + }, + // 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 = ` + + + + + + + + + + + +
+ Sign in Tianji to ${escapedHost} +
+ + + + +
Sign + in
+
+ If you did not request this email you can safely ignore it. +
+ +`; + + return generateSMTPHTML(body); +} diff --git a/src/server/model/notification/provider/smtp.ts b/src/server/model/notification/provider/smtp.ts index 4a699ec..da52099 100644 --- a/src/server/model/notification/provider/smtp.ts +++ b/src/server/model/notification/provider/smtp.ts @@ -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 ` - - - - - Tianji - - - -
-
- -
-
- ${message} -
-
-
- Sent with ❤ by Tianji. -
-
- Github -
-
-
- - - `; -} diff --git a/src/server/model/user.ts b/src/server/model/user.ts index 808fc40..7b66022 100644 --- a/src/server/model/user.ts +++ b/src/server/model/user.ts @@ -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 { 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) { + 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: { diff --git a/src/server/package.json b/src/server/package.json index 488f4a5..63cc8c2 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -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:^", diff --git a/src/server/prisma/migrations/20240525171825_add_authjs_models/migration.sql b/src/server/prisma/migrations/20240729165431_add_authjs_models/migration.sql similarity index 80% rename from src/server/prisma/migrations/20240525171825_add_authjs_models/migration.sql rename to src/server/prisma/migrations/20240729165431_add_authjs_models/migration.sql index b7d3afd..502fa38 100644 --- a/src/server/prisma/migrations/20240525171825_add_authjs_models/migration.sql +++ b/src/server/prisma/migrations/20240729165431_add_authjs_models/migration.sql @@ -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; diff --git a/src/server/prisma/schema.prisma b/src/server/prisma/schema.prisma index 4066224..0976e72 100644 --- a/src/server/prisma/schema.prisma +++ b/src/server/prisma/schema.prisma @@ -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) diff --git a/src/server/prisma/zod/user.ts b/src/server/prisma/zod/user.ts index f5e0293..ba970df 100644 --- a/src/server/prisma/zod/user.ts +++ b/src/server/prisma/zod/user.ts @@ -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(), diff --git a/src/server/utils/smtp.ts b/src/server/utils/smtp.ts new file mode 100644 index 0000000..4ce2e90 --- /dev/null +++ b/src/server/utils/smtp.ts @@ -0,0 +1,34 @@ +export function generateSMTPHTML(body: string) { + return ` + + + + + Tianji + + + +
+
+ +
+
+ ${body} +
+ +
+ + + `; +}