diff --git a/bun.lockb b/bun.lockb index 8b8efbf..8ac3744 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/next.config.mjs b/next.config.mjs index 4678774..27ca96c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,9 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + webpack: (config) => { + config.externals.push("@node-rs/argon2", "@node-rs/bcrypt"); + return config; + } +}; export default nextConfig; diff --git a/package.json b/package.json index 0548c7a..589e43a 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,15 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name" }, "dependencies": { - "@clerk/nextjs": "^4.29.11", + "@lucia-auth/adapter-prisma": "^4.0.1", + "@node-rs/argon2": "^1.8.0", + "@node-rs/bcrypt": "^1.10.1", + "@prisma/adapter-pg": "^5.12.1", "@prisma/client": "^5.12.1", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -19,7 +24,10 @@ "@radix-ui/react-switch": "^1.0.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "lucia": "^3.1.1", "next": "14.1.4", + "oslo": "^1.2.0", + "pg": "^8.11.5", "react": "^18", "react-dom": "^18", "tailwind-merge": "^2.2.2", @@ -27,6 +35,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.11.4", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", diff --git a/prisma/migrations/20240406132011_add_lucia/migration.sql b/prisma/migrations/20240406132011_add_lucia/migration.sql new file mode 100644 index 0000000..2fb700e --- /dev/null +++ b/prisma/migrations/20240406132011_add_lucia/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240406140422_add_username_pw/migration.sql b/prisma/migrations/20240406140422_add_username_pw/migration.sql new file mode 100644 index 0000000..e7d9f9a --- /dev/null +++ b/prisma/migrations/20240406140422_add_username_pw/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `hashed_password` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "hashed_password" TEXT NOT NULL, +ADD COLUMN "username" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a19de92..915156c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,6 +6,7 @@ generator client { provider = "prisma-client-js" + previewFeatures = ["driverAdapters"] } datasource db { @@ -13,6 +14,20 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id + sessions Session[] + username String @unique + hashed_password String +} + +model Session { + id String @id + userId String + expiresAt DateTime + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} + model Point { id Int @id @default(autoincrement()) userId String diff --git a/src/app/api/verifyAuth/route.ts b/src/app/api/verifyAuth/route.ts new file mode 100644 index 0000000..bdd69f2 --- /dev/null +++ b/src/app/api/verifyAuth/route.ts @@ -0,0 +1,8 @@ +import { validateRequest } from "@/lib/auth"; + +export default async function GET(request: Request) { + const { user } = await validateRequest(); + if (!user) { + return Response.redirect("/auth/login"); + } +} \ No newline at end of file diff --git a/src/app/auth/signIn/page.tsx b/src/app/auth/signIn/page.tsx new file mode 100644 index 0000000..19da62a --- /dev/null +++ b/src/app/auth/signIn/page.tsx @@ -0,0 +1,101 @@ +/** + * @see https://v0.dev/t/ZOQ6u9Lf2bO +*/ +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import Link from "next/link" +import { Argon2id } from "oslo/password"; +import { cookies } from "next/headers"; +import { lucia } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import prisma from "@/lib/db"; + +export default function Component() { + return ( +
+
+
+

Inicia sesión

+

haha yes

+
+
+
+
+ + +
+
+ + +
+ +
+ ¿No tienes una cuenta? + + Crear una cuenta + +
+
+
+
+
+ ) +} + +async function login(formData: FormData): Promise { + "use server"; + const username = formData.get("username"); + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return { + error: "Invalid username" + }; + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return { + error: "Invalid password" + }; + } + + const existingUser = await prisma.user.findUnique({ + where: { + username: username + } + }) + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is non-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + return { + error: "Incorrect username or password" + }; + } + + const validPassword = await new Argon2id().verify(existingUser.hashed_password, password); + if (!validPassword) { + return { + error: "Incorrect username or password" + }; + } + + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/"); +} + +interface ActionResult { + error: string; +} \ No newline at end of file diff --git a/src/app/auth/signUp/page.tsx b/src/app/auth/signUp/page.tsx new file mode 100644 index 0000000..d32c242 --- /dev/null +++ b/src/app/auth/signUp/page.tsx @@ -0,0 +1,90 @@ +/** + * @see https://v0.dev/t/5ENQEFtiZm9 +*/ +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import Link from "next/link" +import prisma from "@/lib/db"; +import { Argon2id } from "oslo/password"; +import { cookies } from "next/headers"; +import { lucia } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { generateId } from "lucia"; + +export default function Component() { + return ( +
+
+
+

Crea una cuenta

+

venga ya tio

+
+
+
+
+ + +
+
+ + +
+ +
+ ¿Ya tienes una cuenta? + + Login + +
+
+
+
+
+ ) +} + +async function signup(formData: FormData): Promise { + "use server"; + const username = formData.get("username"); + console.log(username) + // username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _ + // keep in mind some database (e.g. mysql) are case insensitive + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return { + error: "Invalid username" + }; + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return { + error: "Invalid password" + }; + } + + const hashedPassword = await new Argon2id().hash(password); + const userId = generateId(15); + + // TODO: check if username is already used + await prisma.user.create({ + data: { + id: userId, + username: username, + hashed_password: hashedPassword + } + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/"); +} + +interface ActionResult { + error: string; +} diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 2ee6523..affe457 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -1,11 +1,12 @@ import History from '@/components/app/History/History' import prisma from '@/lib/db' -import { currentUser } from '@clerk/nextjs' +import { validateRequest } from '@/lib/auth'; export default async function Page() { + const { user } = await validateRequest(); const pointHistory = (await prisma.point.findMany({ where: { - userId: (await currentUser())!.id + userId: user!.id } })).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).map(p => { return { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e513cf7..d2cb4af 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import localFont from 'next/font/local' import "./globals.css"; import { ClerkProvider } from "@clerk/nextjs"; import Navbar from "@/components/app/Navbar/Navbar"; +import Protected from "@/components/app/Protected/Protected"; const satoshi = localFont({ src: './fonts/Satoshi-Medium.woff2' }) @@ -22,15 +23,15 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - -
- {children} -
+ +
+ {children} +
+
-
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index dfd6231..dc10954 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,18 +1,21 @@ -import DesktopPoints from "@/components/app/Points/Desktop/Desktop"; +import Points from "@/components/app/Points/Points"; import prisma from "@/lib/db"; -import { currentUser } from "@clerk/nextjs"; +import { validateRequest } from "@/lib/auth"; +import { redirect } from "next/navigation"; export default async function Home() { + const { user } = await validateRequest(); + const pointCount = (await prisma.pointCount.findFirst({ where: { - userId: (await currentUser())!.id, + userId: user!.id, } - }))!.balance + }) || { balance: 0 }).balance return ( <>

tienes {pointCount} puntos

- +
); diff --git a/src/app/remove/page.tsx b/src/app/remove/page.tsx index 3d5de9c..abe1d66 100644 --- a/src/app/remove/page.tsx +++ b/src/app/remove/page.tsx @@ -1,11 +1,12 @@ import RemovePoints from "@/components/app/RemovePoints/RemovePoints" +import { validateRequest } from "@/lib/auth"; import prisma from "@/lib/db" -import { currentUser } from "@clerk/nextjs" export default async function Page() { + const { user } = await validateRequest(); const pointCount = (await prisma.pointCount.findFirst({ where: { - userId: (await currentUser())!.id, + userId: user!.id, } }))!.balance return ( diff --git a/src/components/app/Navbar/Navbar.tsx b/src/components/app/Navbar/Navbar.tsx index 2443699..4b37df6 100644 --- a/src/components/app/Navbar/Navbar.tsx +++ b/src/components/app/Navbar/Navbar.tsx @@ -4,12 +4,14 @@ * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app */ import { Button } from "@/components/ui/button" -import { DropdownMenuShortcut } from "@/components/ui/dropdown-menu" -import { UserButton } from "@clerk/nextjs" import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { lucia, validateRequest } from "@/lib/auth" +import { cookies } from "next/headers" import Link from "next/link" +import { redirect } from "next/navigation" -export default function Navbar() { +export default async function Navbar() { + const { user } = await validateRequest(); return ( ) } +async function logout(): Promise { + "use server"; + const { session } = await validateRequest(); + await lucia.invalidateSession(session!.id); + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/auth/signUp"); +} + +interface ActionResult { + error: string; +} \ No newline at end of file diff --git a/src/components/app/Points/Desktop/Desktop.tsx b/src/components/app/Points/Points.tsx similarity index 88% rename from src/components/app/Points/Desktop/Desktop.tsx rename to src/components/app/Points/Points.tsx index e48f580..653a4ef 100644 --- a/src/components/app/Points/Desktop/Desktop.tsx +++ b/src/components/app/Points/Points.tsx @@ -11,9 +11,11 @@ import { import { Input } from "@/components/ui/input" import prisma from "@/lib/db" import { redirect } from "next/navigation" -import { currentUser } from "@clerk/nextjs" +import { validateRequest } from "@/lib/auth" -export default function DesktopPoints() { +export default async function Points() { + const { user } = await validateRequest(); + async function createPoints(formData: FormData) { 'use server' @@ -24,14 +26,14 @@ export default function DesktopPoints() { await prisma.point.create({ data: { - userId: (await currentUser())!.id, + userId: user!.id, number: Number(rawFormData.points), reason: rawFormData.reason as string, } }) await prisma.pointCount.upsert({ where: { - userId: (await currentUser())!.id, + userId: user!.id, }, update: { balance: { @@ -39,7 +41,7 @@ export default function DesktopPoints() { } }, create: { - userId: (await currentUser())!.id, + userId: user!.id, balance: Number(rawFormData.points), } }) diff --git a/src/components/app/Protected/Protected.tsx b/src/components/app/Protected/Protected.tsx new file mode 100644 index 0000000..ef794df --- /dev/null +++ b/src/components/app/Protected/Protected.tsx @@ -0,0 +1,14 @@ +import { validateRequest } from "@/lib/auth"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export default async function Protected({ children }: { children: React.ReactNode }) { + const publicRoutes = ['/auth/signIn', '/auth/signUp']; + + if (publicRoutes.includes(headers().get('x-url')!)) return <>{children}; + + const { user } = await validateRequest(); + if (!user) redirect('/auth/signIn'); + + return <>{children}; +} \ No newline at end of file diff --git a/src/components/app/RemovePoints/RemovePoints.tsx b/src/components/app/RemovePoints/RemovePoints.tsx index 2b89961..0f7430f 100644 --- a/src/components/app/RemovePoints/RemovePoints.tsx +++ b/src/components/app/RemovePoints/RemovePoints.tsx @@ -11,9 +11,11 @@ import { import { Input } from "@/components/ui/input" import prisma from "@/lib/db" import { redirect } from "next/navigation" -import { currentUser } from "@clerk/nextjs" +import { validateRequest } from "@/lib/auth" + +export default async function RemovePoints() { + const { user } = await validateRequest(); -export default function RemovePoints() { async function createPoints(formData: FormData) { 'use server' @@ -24,14 +26,14 @@ export default function RemovePoints() { await prisma.point.create({ data: { - userId: (await currentUser())!.id, + userId: user!.id, number: Number(rawFormData.points), reason: rawFormData.reason as string, } }) await prisma.pointCount.upsert({ where: { - userId: (await currentUser())!.id, + userId: user!.id, }, update: { balance: { @@ -39,7 +41,7 @@ export default function RemovePoints() { } }, create: { - userId: (await currentUser())!.id, + userId: user!.id, balance: Number(rawFormData.points), } }) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0270f64..74a7bb6 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -25,6 +25,7 @@ const buttonVariants = cva( sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", + text: "h-9" }, }, defaultVariants: { diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..b5c0153 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,62 @@ +// src/auth.ts +import { Lucia } from "lucia"; +import { adapter } from "../db"; +import { cache } from "react"; +import { cookies } from "next/headers"; +import type { Session, User } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + // this sets cookies with super long expiration + // since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages + expires: false, + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production" + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + username: attributes.username + }; + } +}); + +export const validateRequest = cache( + async (): Promise<{ user: User; session: Session } | { user: null; session: null }> => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) { + return { + user: null, + session: null + }; + } + + const result = await lucia.validateSession(sessionId); + // next.js throws when you attempt to set cookie when rendering page + try { + if (result.session && result.session.fresh) { + const sessionCookie = lucia.createSessionCookie(result.session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!result.session) { + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + } catch {} + return result; + } +); + +// IMPORTANT! +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index f4ba39b..7ae27f6 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,15 +1,22 @@ import { PrismaClient } from '@prisma/client' +import { PrismaAdapter } from '@lucia-auth/adapter-prisma' +import { Pool } from 'pg' +import { PrismaPg } from '@prisma/adapter-pg' +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) +const pgAdapter = new PrismaPg(pool) const prismaClientSingleton = () => { - return new PrismaClient() + return new PrismaClient({ adapter: pgAdapter }) } declare global { var prismaGlobal: undefined | ReturnType } + const prisma = globalThis.prismaGlobal ?? prismaClientSingleton() +export const adapter = new PrismaAdapter(prisma.session, prisma.user); export default prisma -if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma +if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 3e006d2..f3433b7 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,12 @@ -import { authMiddleware } from "@clerk/nextjs"; +import { NextResponse } from 'next/server'; -export default authMiddleware({}); +export function middleware(request: Request) { + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-url', new URL(request.url).pathname); -export const config = { - matcher: ["/((?!.+.[w]+$|_next).*)", "/", "/(api|trpc)(.*)"], -}; \ No newline at end of file + return NextResponse.next({ + request: { + headers: requestHeaders + } + }); +} \ No newline at end of file