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
+
+
+
+
+ )
+}
+
+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
+
+
+
+
+ )
+}
+
+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