feat: move away from clerk

This commit is contained in:
2024-04-06 17:27:17 +02:00
parent e45e8327a9
commit e283fe0654
21 changed files with 422 additions and 38 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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");

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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 (
<div className="flex items-center p-4 lg:p-8">
<div className="w-full max-w-md m-auto space-y-8">
<div className="text-center">
<h1 className="text-4xl font-bold pb-1">Inicia sesión</h1>
<p className="text-gray-500 dark:text-gray-400">haha yes</p>
</div>
<form action={login}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Nombre de usuario</Label>
<Input name="username" id="username" placeholder="srizan" required type="text" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input name="password" id="password" placeholder="osakafromazumangadaioh123" required type="password" />
</div>
<Button className="w-full" type="submit">Iniciar sesión</Button>
<div className="text-center text-sm">
¿No tienes una cuenta?
<Link className="underline pl-1" href="/auth/signUp">
Crear una cuenta
</Link>
</div>
</div>
</form>
</div>
</div>
)
}
async function login(formData: FormData): Promise<ActionResult> {
"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;
}

View File

@@ -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 (
<div className="flex items-center p-6 lg:p-8">
<div className="w-full max-w-md m-auto space-y-8">
<div className="text-center">
<h1 className="text-4xl font-bold pb-1">Crea una cuenta</h1>
<p className="text-gray-500 dark:text-gray-400">venga ya tio</p>
</div>
<form action={signup}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Nombre de usuario</Label>
<Input name="username" placeholder="srizan" required type="text" id="username" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Contraseña</Label>
<Input name="password" placeholder="osakafromazumangadaioh123" required type="password" id="password" />
</div>
<Button className="w-full" type="submit">Registrarse</Button>
<div className="text-center text-sm">
¿Ya tienes una cuenta?
<Link className="underline pl-1" href="/auth/signIn">
Login
</Link>
</div>
</div>
</form>
</div>
</div>
)
}
async function signup(formData: FormData): Promise<ActionResult> {
"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;
}

View File

@@ -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 {

View File

@@ -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 (
<ClerkProvider>
<html lang="es">
<body className={`${satoshi.className}`}>
<Navbar />
<div className="p-2">
{children}
</div>
<Protected>
<div className="p-2">
{children}
</div>
</Protected>
</body>
</html>
</ClerkProvider>
);
}

View File

@@ -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 (
<>
<h1 className="text-3xl text-center mb-6">tienes {pointCount} puntos</h1>
<div className="flex items-center justify-center">
<DesktopPoints />
<Points />
</div>
</>
);

View File

@@ -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 (

View File

@@ -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 (
<nav className="flex items-center h-16 px-4 border-b shrink-0">
<DropdownMenu>
@@ -41,11 +43,34 @@ export default function Navbar() {
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
{user && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<form action={logout}>
<DropdownMenuItem>
<Button variant={"ghost"} size={'text'}>Log out</Button>
</DropdownMenuItem>
</form>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
<UserButton />
</nav>
)
}
async function logout(): Promise<ActionResult> {
"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;
}

View File

@@ -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),
}
})

View File

@@ -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}</>;
}

View File

@@ -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),
}
})

View File

@@ -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: {

62
src/lib/auth/index.ts Normal file
View File

@@ -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;
}

View File

@@ -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<typeof prismaClientSingleton>
}
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

View File

@@ -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)(.*)"],
};
return NextResponse.next({
request: {
headers: requestHeaders
}
});
}