diff --git a/apps/api/package.json b/apps/api/package.json index 71b6714..5b4e350 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,19 +6,17 @@ "dev:db": "bun -b kysely", "dev": "bun run --watch src/index.ts", "dev:email": "email dev", - "dev:auth:generate": "bunx -b @better-auth/cli generate", - "dev:auth:migrate": "bunx -b @better-auth/cli migrate", "lint": "eslint src/** && tsc", "lint:fix": "eslint src/** --fix && tsc" }, "dependencies": { - "@better-auth/cli": "^0.8.7-beta.3", "@faker-js/faker": "^9.0.3", "@grpc/grpc-js": "^1.12.2", "@grpc/proto-loader": "^0.7.13", "@hono/zod-validator": "^0.4.1", "@react-email/components": "0.0.25", - "better-auth": "^1.1.10", + "@types/bcrypt": "^5.0.2", + "bcrypt": "^5.1.1", "dotenv": "^16.4.5", "hono": "^4.6.3", "kysely": "^0.27.4", diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 3e8d5ed..7153f8a 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -9,6 +9,7 @@ const apiConfigSchema = z.object({ }), services: z.object({ auth: z.object({ + jwtSecret: z.string(), totp: z.object({ issuer: z.string(), }), @@ -39,6 +40,7 @@ const data = { }, services: { auth: { + jwtSecret: "its-a-secret", totp: { issuer: "a-little-byte", }, diff --git a/apps/api/src/database/migrations/1737488390410_auth.ts b/apps/api/src/database/migrations/1737488390410_auth.ts new file mode 100644 index 0000000..a4669a9 --- /dev/null +++ b/apps/api/src/database/migrations/1737488390410_auth.ts @@ -0,0 +1,21 @@ +import { Database } from "@alittlebyte/api/database/types" +import { baseTable } from "@alittlebyte/api/database/utils/baseTable" +import { notNullColumn } from "@alittlebyte/api/database/utils/notNullColumn" +import type { Kysely } from "kysely" + +export const up = async (db: Kysely): Promise => { + await db.schema + .createTable("users") + .$call(baseTable) + .$call(notNullColumn("firstName")) + .$call(notNullColumn("lastName")) + .addColumn("email", "text", (col) => col.notNull().unique()) + .$call(notNullColumn("passwordHash")) + .$call(notNullColumn("passwordSalt")) + .addColumn("emailVerifiedAt", "timestamptz") + .execute() +} + +export const down = async (db: Kysely): Promise => { + await db.schema.dropTable("users").execute() +} diff --git a/apps/api/src/database/repositories/user.ts b/apps/api/src/database/repositories/user.ts index d0cdb83..fceb353 100644 --- a/apps/api/src/database/repositories/user.ts +++ b/apps/api/src/database/repositories/user.ts @@ -2,7 +2,7 @@ import { db } from "@alittlebyte/api/database" import { NewUser, User, UserUpdate } from "@alittlebyte/api/database/types" import { UUID } from "node:crypto" -const findAll = async (criteria: Partial) => { +const findAll = (criteria: Partial) => { let query = db.selectFrom("users") if (criteria.id) { @@ -21,7 +21,28 @@ const findAll = async (criteria: Partial) => { query = query.where("createdAt", "=", criteria.createdAt) } - return await query.selectAll().execute() + return query.selectAll().execute() +} +const findOne = (criteria: Partial) => { + let query = db.selectFrom("users") + + if (criteria.id) { + query = query.where("id", "=", criteria.id) + } + + if (criteria.firstName) { + query = query.where("firstName", "=", criteria.firstName) + } + + if (criteria.lastName) { + query = query.where("lastName", "=", criteria.lastName) + } + + if (criteria.createdAt) { + query = query.where("createdAt", "=", criteria.createdAt) + } + + return query.selectAll().executeTakeFirst() } const findById = (id: UUID) => db.selectFrom("users").where("id", "=", id).selectAll().executeTakeFirst() @@ -47,4 +68,5 @@ export const usersRepository = { update, updateReturn, delete: deleteUser, + findOne, } as const diff --git a/apps/api/src/database/types.ts b/apps/api/src/database/types.ts index da3ce7d..33d50e0 100644 --- a/apps/api/src/database/types.ts +++ b/apps/api/src/database/types.ts @@ -9,20 +9,10 @@ import type { UUID } from "node:crypto" export type Database = { accounts: AccountTable - "accounts.user": UserTable creditCards: CreditCardTable - "creditCards.user": UserTable services: ServiceTable translations: TranslationTable - sessions: SessionTable - twoFactors: TwoFactorTable - "twoFactors.user": UserTable users: UserTable - "users.accounts": AccountTable - "users.creditCards": CreditCardTable - "users.sessions": SessionTable - "users.twoFactors": TwoFactorTable - verifications: VerificationTable } export type ServiceTable = { @@ -74,14 +64,11 @@ export type CreditCardUpdate = Updateable export type UserTable = { id: Generated - name: string firstName: string lastName: string email: string - emailVerified: boolean - image?: string - twoFactorEnabled?: boolean - creditCards: CreditCard[] + passwordHash: string + passwordSalt: string createdAt: ColumnType updatedAt: ColumnType } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 13fb11a..ab2a219 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,4 @@ import { apiConfig } from "@alittlebyte/api/config" -import { authMiddleware } from "@alittlebyte/api/middlewares/auth" import { corsMiddleware } from "@alittlebyte/api/middlewares/cors" import { dbMiddleware } from "@alittlebyte/api/middlewares/db" import { authRouter } from "@alittlebyte/api/routes/auth" @@ -10,6 +9,7 @@ import { usersRouter } from "@alittlebyte/api/routes/users" import type { PublicContextVariables } from "@alittlebyte/api/utils/types" import { HTTP_STATUS_CODES } from "@alittlebyte/common/constants" import { Hono } from "hono" +import { jwt } from "hono/jwt" const { port } = apiConfig.server const app = new Hono<{ Variables: PublicContextVariables }>() @@ -26,7 +26,12 @@ const router = app .route("/auth", authRouter) .route("/services", servicesRouter) .route("/example", backofficeExample) - .use(authMiddleware) + .use( + jwt({ + secret: apiConfig.services.auth.jwtSecret, + alg: "RS512", + }), + ) .route("/users", usersRouter) .route("/checkout", checkoutRouter) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts deleted file mode 100644 index 01bb5fa..0000000 --- a/apps/api/src/lib/auth.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { apiConfig } from "@alittlebyte/api/config" -import { dialect } from "@alittlebyte/api/database" -import { sendEmail } from "@alittlebyte/api/grpc/emails/emailClient" -import { - firstNameValidator, - lastNameValidator, -} from "@alittlebyte/common/validators" -import { forgotPasswordTemplate } from "@alittlebyte/components/templates/ForgotPasswordTemplate" -import { verifyEmailTemplate } from "@alittlebyte/components/templates/VerifyEmailTemplate" -import { betterAuth } from "better-auth" -import { twoFactor } from "better-auth/plugins" - -const { - trustedOrigins, - totp: { issuer }, -} = apiConfig.services.auth - -export const auth = betterAuth({ - onAPIError: { - throw: true, - }, - trustedOrigins, - emailVerification: { - sendVerificationEmail: async ({ user, url }) => { - await sendEmail( - user.email, - "Verify your email address", - await verifyEmailTemplate(url), - ) - }, - }, - emailAndPassword: { - enabled: true, - sendResetPassword: async ({ url, user }) => { - await sendEmail( - user.email, - "Reset Your Password", - await forgotPasswordTemplate(url), - ) - }, - requireEmailVerification: true, - }, - user: { - modelName: "users", - additionalFields: { - firstName: { - type: "string", - required: true, - validator: { - input: firstNameValidator, - output: firstNameValidator, - }, - }, - lastName: { - type: "string", - required: true, - validator: { - input: lastNameValidator, - output: lastNameValidator, - }, - }, - }, - }, - account: { - modelName: "accounts", - }, - session: { - modelName: "sessions", - }, - database: { - dialect, - type: "postgres", - generateId: () => crypto.randomUUID(), - }, - plugins: [ - twoFactor({ - issuer, - }), - ], -}) - -export type Session = typeof auth.$Infer.Session.session - -export type User = typeof auth.$Infer.Session.user diff --git a/apps/api/src/middlewares/auth.ts b/apps/api/src/middlewares/auth.ts deleted file mode 100644 index fdbcc79..0000000 --- a/apps/api/src/middlewares/auth.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { auth } from "@alittlebyte/api/lib/auth" -import type { PrivateContextVariables } from "@alittlebyte/api/utils/types" -import type { Context, Next } from "hono" - -export const authMiddleware = async ( - c: Context<{ Variables: PrivateContextVariables }>, - next: Next, -) => { - const session = await auth.api.getSession({ headers: c.req.raw.headers }) - - if (!session) { - return c.json({ error: "Unauthorized" }, 403) - } - - c.set("user", session.user) - c.set("session", session.session) - - return next() -} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 22219f5..b16c1f3 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,6 +1,91 @@ -import { auth } from "@alittlebyte/api/lib/auth" +import { apiConfig } from "@alittlebyte/api/config" +import { + hashPassword, + validatePassword, +} from "@alittlebyte/api/utils/hashPassword" +import { PrivateContextVariables } from "@alittlebyte/api/utils/types" +import { + emailValidator, + firstNameValidator, + lastNameValidator, + passwordValidator, +} from "@alittlebyte/common/validators" +import { zValidator } from "@hono/zod-validator" import { Hono } from "hono" +import { sign } from "hono/jwt" +import { UUID } from "node:crypto" +import { z } from "zod" -export const authRouter = new Hono().on(["POST", "GET"], "/**", (c) => - auth.handler(c.req.raw), -) +const createToken = (id: UUID, email: string) => + sign({ id, email }, apiConfig.services.auth.jwtSecret) + +export const authRouter = new Hono<{ Variables: PrivateContextVariables }>() + .post( + "/sign-in", + zValidator( + "json", + z.object({ + email: emailValidator, + password: passwordValidator, + }), + ), + async ({ req, var: { repositories }, json }) => { + const { email, password } = req.valid("json") + const user = await repositories.users.findOne({ email }) + + if (!user) { + return json({}, 404) + } + + const matches = await validatePassword(password, user.passwordHash) + + if (!matches) { + throw new Error("Invalid credentials") + } + + const token = await createToken(user.id, user.email) + + return json({ token }, 201) + }, + ) + .post( + "/sign-up", + zValidator( + "json", + z.object({ + email: emailValidator, + password: passwordValidator, + firstName: firstNameValidator, + lastName: lastNameValidator, + }), + ), + async ({ req, var: { repositories }, json }) => { + const { password, email, ...body } = req.valid("json") + + try { + const usersExists = await repositories.users.findOne({ email }) + + if (usersExists) { + throw new Error("An error occurred during signup") + } + + const hashedPassword = await hashPassword(password) + const user = await repositories.users.create({ + email, + ...body, + ...hashedPassword, + }) + + return json(user, 201) + } catch (error) { + return json( + { + message: error, + }, + 401, + ) + } + }, + ) + .post("/forgot-password") + .post("/reset-password") diff --git a/apps/api/src/utils/constants.ts b/apps/api/src/utils/constants.ts new file mode 100644 index 0000000..aaace22 --- /dev/null +++ b/apps/api/src/utils/constants.ts @@ -0,0 +1,7 @@ +export const AUTH_CONSTANTS = { + SALT_ROUNDS: 12, + TOKEN_EXPIRY: "30 days", + MIN_PASSWORD_LENGTH: 8, + MAX_USERNAME_LENGTH: 30, + DEFAULT_SKIN_NAME: "default", +} as const diff --git a/apps/api/src/utils/hashPassword.ts b/apps/api/src/utils/hashPassword.ts new file mode 100644 index 0000000..ecd241c --- /dev/null +++ b/apps/api/src/utils/hashPassword.ts @@ -0,0 +1,15 @@ +import bcrypt from "bcrypt" + +export const hashPassword = async (password: string) => { + const salt = await bcrypt.genSalt(12) + + return { + passwordSalt: salt, + passwordHash: await bcrypt.hash(password, salt), + } +} + +export const validatePassword = ( + plainPassword: string, + hashedPassword: string, +): Promise => bcrypt.compare(plainPassword, hashedPassword) diff --git a/apps/api/src/utils/types.ts b/apps/api/src/utils/types.ts index 78ce387..7cd50e6 100644 --- a/apps/api/src/utils/types.ts +++ b/apps/api/src/utils/types.ts @@ -1,6 +1,6 @@ import { repositories } from "@alittlebyte/api/database" import { Database } from "@alittlebyte/api/database/types" -import type { Session, User } from "@alittlebyte/api/lib/auth" +import { UUID } from "crypto" import { Kysely } from "kysely" export interface PublicContextVariables { @@ -9,6 +9,8 @@ export interface PublicContextVariables { } export type PrivateContextVariables = PublicContextVariables & { - user: User - session: Session + user: { + id: UUID + email: string + } } diff --git a/apps/landing/src/config.ts b/apps/landing/src/config.ts index 3f6a55b..bd6389f 100644 --- a/apps/landing/src/config.ts +++ b/apps/landing/src/config.ts @@ -6,6 +6,7 @@ const landingConfigSchema = z.object({ auth: z.object({ baseURL: urlValidator, twoFactorPage: z.string(), + sessionKey: z.string(), }), }), }) @@ -14,6 +15,7 @@ const data = { auth: { baseURL: import.meta.env.VITE_SERVICES_AUTH_BASEURL, twoFactorPage: "", + sessionKey: "SESSION_KEY", }, }, } satisfies z.input diff --git a/apps/landing/src/hooks/useAuthClient.ts b/apps/landing/src/hooks/useAuthClient.ts deleted file mode 100644 index c44ad25..0000000 --- a/apps/landing/src/hooks/useAuthClient.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { authClient } from "@alittlebyte/components/lib/auth" -import { landingConfig } from "@alittlebyte/landing/config" - -const { - useSession, - sendVerificationEmail, - signIn, - signOut, - signUp, - forgetPassword, - resetPassword, -} = authClient(landingConfig.services.auth) - -export const useAuthClient = () => ({ - signIn, - signOut, - signUp, - forgetPassword, - sendVerificationEmail, - resetPassword, -}) - -export { useSession } diff --git a/apps/landing/src/routes/forgot-password.lazy.tsx b/apps/landing/src/routes/forgot-password.lazy.tsx index ddfdb90..eb209ca 100644 --- a/apps/landing/src/routes/forgot-password.lazy.tsx +++ b/apps/landing/src/routes/forgot-password.lazy.tsx @@ -3,13 +3,11 @@ import { ForgotPasswordValidatorOutput, } from "@alittlebyte/components/forms/ForgotPasswordForm" import { useToast } from "@alittlebyte/components/hooks/use-toast" -import { useAuthClient } from "@alittlebyte/landing/hooks/useAuthClient" import { useMutation } from "@tanstack/react-query" import { createLazyFileRoute, useNavigate } from "@tanstack/react-router" import { SubmitHandler } from "react-hook-form" const ForgotPassword = () => { - const { forgetPassword } = useAuthClient() const { toast } = useToast() const navigate = useNavigate() const { mutateAsync } = useMutation< @@ -17,15 +15,8 @@ const ForgotPassword = () => { Error, ForgotPasswordValidatorOutput >({ - mutationFn: async ({ email }) => { - const res = await forgetPassword({ - email, - redirectTo: `${window.location.origin}/reset-password`, - }) - - if (res.error) { - throw new Error(res.error.message) - } + mutationFn: async () => { + // }, onSuccess: async () => { toast({ diff --git a/apps/landing/src/routes/reset-password.lazy.tsx b/apps/landing/src/routes/reset-password.lazy.tsx index a792316..509f9e9 100644 --- a/apps/landing/src/routes/reset-password.lazy.tsx +++ b/apps/landing/src/routes/reset-password.lazy.tsx @@ -6,10 +6,8 @@ import { useToast } from "@alittlebyte/components/hooks/use-toast" import { useMutation } from "@tanstack/react-query" import { createLazyFileRoute, useNavigate } from "@tanstack/react-router" import { SubmitHandler } from "react-hook-form" -import { useAuthClient } from "../hooks/useAuthClient" const ResetPassword = () => { - const { resetPassword } = useAuthClient() const navigate = useNavigate() const queryParams = new URLSearchParams(window.location.search) const token = queryParams.get("token") @@ -20,19 +18,8 @@ const ResetPassword = () => { } const { mutate } = useMutation({ - mutationFn: async ({ password }) => { - const res = await resetPassword({ - newPassword: password, - fetchOptions: { - query: { - token, - }, - }, - }) - - if (res.error) { - throw new Error(res.error.message) - } + mutationFn: async () => { + // }, onSuccess: async () => { toast({ diff --git a/apps/landing/src/routes/sign-in.lazy.tsx b/apps/landing/src/routes/sign-in.lazy.tsx index 17358bc..d810f5c 100644 --- a/apps/landing/src/routes/sign-in.lazy.tsx +++ b/apps/landing/src/routes/sign-in.lazy.tsx @@ -1,8 +1,9 @@ +import { apiClient } from "@alittlebyte/common/lib/apiClient" import { SignInForm, SignInValidatorOutput, } from "@alittlebyte/components/forms/SignInForm" -import { useAuthClient } from "@alittlebyte/landing/hooks/useAuthClient" +import { landingConfig } from "@alittlebyte/landing/config" import { useMutation } from "@tanstack/react-query" import { createLazyFileRoute, useNavigate } from "@tanstack/react-router" import { useState } from "react" @@ -11,15 +12,16 @@ import { SubmitHandler } from "react-hook-form" const SignIn = () => { const [invalidEmailOrPassword, setInvalidEmailOrPassword] = useState(false) const navigate = useNavigate() - const { signIn } = useAuthClient() const { mutate } = useMutation({ mutationFn: async (data) => { setInvalidEmailOrPassword(false) - const res = await signIn.email(data) - if (res.error) { - throw new Error(res.error.message) - } + const res = await apiClient.auth["sign-in"].$post({ + json: data, + }) + const { token } = await res.json() + + localStorage.setItem(landingConfig.services.auth.sessionKey, token) }, onSuccess: () => { void navigate({ diff --git a/apps/landing/src/routes/sign-up.lazy.tsx b/apps/landing/src/routes/sign-up.lazy.tsx index 6d5ded1..d079fa3 100644 --- a/apps/landing/src/routes/sign-up.lazy.tsx +++ b/apps/landing/src/routes/sign-up.lazy.tsx @@ -1,35 +1,31 @@ -import { FULL_NAME_SEPARATOR } from "@alittlebyte/common/constants" +import { apiClient } from "@alittlebyte/common/lib/apiClient" import { SignUpForm, SignUpValidatorOutput, } from "@alittlebyte/components/forms/SignUpForm" import { toast } from "@alittlebyte/components/hooks/use-toast" -import { useAuthClient } from "@alittlebyte/landing/hooks/useAuthClient" import { useMutation } from "@tanstack/react-query" import { createLazyFileRoute, useNavigate } from "@tanstack/react-router" import { SubmitHandler } from "react-hook-form" const SignUp = () => { const navigate = useNavigate() - const { signUp, sendVerificationEmail } = useAuthClient() const { mutateAsync } = useMutation({ mutationFn: (data) => - signUp.email({ - name: `${data.firstName}${FULL_NAME_SEPARATOR}${data.lastName}`, - firstName: data.firstName, - lastName: data.lastName, - email: data.email, - password: data.password, + apiClient.auth["sign-up"].$post({ + json: { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + password: data.password, + }, }), - onSuccess: async (_data, { email }) => { + onSuccess: async () => { toast({ title: "Please check your email", description: "An email has been sent in order to verify your email", }) - await sendVerificationEmail({ - email, - callbackURL: `${window.location.origin}/sign-in`, - }) + await navigate({ to: "/" }) }, }) diff --git a/bun.lockb b/bun.lockb index 6284eb6..bf45add 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/common/package.json b/packages/common/package.json index 8e3b9e2..6b0eb73 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,7 +19,6 @@ "typescript": "^5.5.3" }, "dependencies": { - "better-auth": "^1.1.10", "hono": "^4.6.3", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", diff --git a/packages/components/package.json b/packages/components/package.json index c7aaa93..afc95ca 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -24,7 +24,6 @@ "@radix-ui/react-tooltip": "^1.1.2", "@react-email/components": "0.0.28", "@react-email/render": "1.0.2", - "better-auth": "^1.1.10", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "embla-carousel-react": "^8.3.0", diff --git a/packages/components/src/lib/auth.ts b/packages/components/src/lib/auth.ts deleted file mode 100644 index 4f084f3..0000000 --- a/packages/components/src/lib/auth.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { auth } from "@alittlebyte/api/lib/auth" -import { - inferAdditionalFields, - twoFactorClient, -} from "better-auth/client/plugins" -import { createAuthClient } from "better-auth/react" - -export const authClient = ({ - baseURL, -}: { - baseURL: string - twoFactorPage: string -}) => - createAuthClient({ - baseURL, - plugins: [inferAdditionalFields(), twoFactorClient({})], - fetchOptions: { - onError(e) { - if (e.error.status === 429) { - throw new Error("") - } - }, - }, - })