diff --git a/.env.template b/.env.template index 3c05bdc..6f23fc5 100644 --- a/.env.template +++ b/.env.template @@ -14,4 +14,7 @@ SENDGRID_SENDER_ID="" NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="change-me" -NEXTAUTH_ROOT_DOMAIN="http://localhost" \ No newline at end of file +NEXTAUTH_ROOT_DOMAIN="http://localhost" + +NEXT_PUBLIC_URL="http://localhost:3000" +JWT_SECRET="" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b75a06c..0aa0a46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@sendgrid/mail": "^8.1.0", "bcrypt": "^5.1.1", "eclipse-components": "^0.0.141", + "jsonwebtoken": "^9.0.2", "next": "14.1.0", "next-auth": "^4.24.5", "prisma": "^5.9.1", @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -586,6 +588,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.11.16", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", @@ -1223,6 +1234,11 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1588,6 +1604,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/eclipse-components": { "version": "0.0.141", "resolved": "https://registry.npmjs.org/eclipse-components/-/eclipse-components-0.0.141.tgz", @@ -3291,6 +3315,27 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -3306,6 +3351,25 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3376,12 +3440,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/package.json b/package.json index 3adace8..81ae15b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@sendgrid/mail": "^8.1.0", "bcrypt": "^5.1.1", "eclipse-components": "^0.0.141", + "jsonwebtoken": "^9.0.2", "next": "14.1.0", "next-auth": "^4.24.5", "prisma": "^5.9.1", @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -31,4 +33,4 @@ "tailwindcss": "^3.3.0", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx new file mode 100644 index 0000000..be778d3 --- /dev/null +++ b/src/app/Providers.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +type ProvidersProps = { + children: React.ReactNode; +}; + +export default function Providers({ children }: ProvidersProps) { + return {children}; +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index edb7b3b..f49a925 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,6 +1,8 @@ import { prisma } from "@/lib/prisma"; import bcrypt from "bcrypt"; import { NextResponse } from "next/server"; +import { sendEmail } from "@/lib/email"; +import { jwtGenerateEmailVerificationToken } from "@/lib/jwt"; /** * @@ -64,7 +66,6 @@ export async function POST(request: Request) { } // If the user doesn't exist, then we need to create the user and the Auth - const newUser = await prisma.user.create({ data: { firstName: firstName, @@ -80,5 +81,23 @@ export async function POST(request: Request) { console.log(email, " Account Created"); - return NextResponse.json({ user: newUser }, { status: 201 }); + // send the verification email + const token = await jwtGenerateEmailVerificationToken(email); + try { + await sendEmail( + email, + "Eclipse Expos -- Email Verification", + `Click the following link to verify your email: ${process.env.NEXT_PUBLIC_URL}/verify?token=${token}\n\nThis link will expire in 10 minutes.` + ); + } catch { + return NextResponse.json( + { user: newUser, isEmailSent: false }, + { status: 201 } + ); + } + + return NextResponse.json( + { user: newUser, isEmailSent: true }, + { status: 201 } + ); } diff --git a/src/app/api/auth/validate-credentials/route.ts b/src/app/api/auth/validate-credentials/route.ts index 790f882..ee12f9a 100644 --- a/src/app/api/auth/validate-credentials/route.ts +++ b/src/app/api/auth/validate-credentials/route.ts @@ -8,63 +8,63 @@ import { NextRequest, NextResponse } from "next/server"; * @returns NextResponse.json */ export async function POST(request: NextRequest) { - const { username, password } = await request.json(); + const { username, password } = await request.json(); - console.log(username, " Attempted login"); + console.log(username, " Attempted login"); - const requestHeaders = headers(); - const ip = requestHeaders.get("x-forwarded-for") ?? ""; + const requestHeaders = headers(); + const ip = requestHeaders.get("x-forwarded-for") ?? ""; - const user = await prisma.user.findUnique({ - where: { - email: username, - }, - include: { - auth: true, - }, - }); - - // Return error if user is not found or doesn't have auth - if (!user || !user.auth) { - await prisma.loginAttempt.create({ - data: { - email: username, - ip, - result: "USER_NOT_FOUND", - }, - }); - return NextResponse.json({ error: "User not registered" }); - } - - const isValid = await bcrypt.compare(password, user.auth.passwordHash); - - if (!isValid) { - await prisma.loginAttempt.create({ - data: { - email: username, - ip, - result: "INVALID_PASSWORD", - associatedUser: { - connect: user, - }, - }, - }); - return NextResponse.json({ error: "Invalid login" }); - } + const user = await prisma.user.findUnique({ + where: { + email: username, + }, + include: { + auth: true, + }, + }); - console.log(username, " Logged in"); + // Return error if user is not found or doesn't have auth + if (!user || !user.auth) { + await prisma.loginAttempt.create({ + data: { + email: username, + ip, + result: "USER_NOT_FOUND", + }, + }); + return NextResponse.json({ error: "User not registered" }); + } - const { auth, ...rest } = user; + const isValid = await bcrypt.compare(password, user.auth.passwordHash); + if (!isValid) { await prisma.loginAttempt.create({ - data: { - email: username, - ip, - result: "SUCCESS", - associatedUser: { - connect: user, - }, + data: { + email: username, + ip, + result: "INVALID_PASSWORD", + associatedUser: { + connect: user, }, + }, }); - return NextResponse.json({ user: rest }); + return NextResponse.json({ error: "Invalid login" }); + } + + console.log(username, " Logged in"); + + const { auth, ...rest } = user; + + await prisma.loginAttempt.create({ + data: { + email: username, + ip, + result: "SUCCESS", + associatedUser: { + connect: user, + }, + }, + }); + return NextResponse.json({ user: rest }); } diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..9f72fd6 --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,77 @@ +import { sendEmail } from "@/lib/email"; +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; +import { jwtVerifyEmailVerificationToken } from "@/lib/jwt"; +import { JwtPayload } from "jsonwebtoken"; +/** + * Verify email API endpoint + * + * @param request + * @returns NextResponse.json + */ +export async function POST(request: Request) { + const { token } = await request.json(); + + // Return bad request if any of the fields are empty + if (!token) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + + // Decode the token + let email; + + try { + const decodedToken = (await jwtVerifyEmailVerificationToken( + token + )) as JwtPayload; + + email = decodedToken.email; + } catch { + return NextResponse.json({ error: "Invalid token" }, { status: 400 }); + } + + if (!email) { + return NextResponse.json({ error: "Invalid token" }, { status: 400 }); + } + + // Check if user already exists + const user = await prisma.user.findUnique({ + where: { + email: email, + }, + include: { + auth: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 400 }); + } + + // If the user doesn't exist, then we need to create the user and the Auth + const newUser = await prisma.user.update({ + where: { + email: email, + }, + data: { + auth: { + update: { + emailVerified: true, + }, + }, + }, + select: { + id: true, + email: true, + }, + }); + + if (!newUser) { + return NextResponse.json({ error: "Error updating user" }, { status: 500 }); + } + + // send an email denoting success, welcome them to eclipse + // await sendEmail(email, "Welcome to Eclipse", "Welcome to Eclipse!"); + + return NextResponse.json({ user: newUser }, { status: 201 }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b9c1f29..409952d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; -import Providers from "./Providers"; -import "./globals.css"; +import Providers from "@/app/Providers"; +import "@/styles/globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Auth | Eclipse Expos", + title: "Eclipse Expos | Authentication", description: "Authentication portal for Eclipse Expos", }; diff --git a/src/app/signup/SignupForm.tsx b/src/app/signup/SignupForm.tsx index 3188730..abfb61e 100644 --- a/src/app/signup/SignupForm.tsx +++ b/src/app/signup/SignupForm.tsx @@ -16,10 +16,13 @@ import { useSearchParams } from "next/navigation"; */ export default function SignupForm() { const searchParams = useSearchParams(); + return ( <> +
{ e.preventDefault(); @@ -62,7 +65,6 @@ export default function SignupForm() { window.location.href = searchParams.get("callbackUrl") || "/"; } }} - className="flex flex-col items-center gap-4 w-[18rem] md:w-[30rem]" >
{" "} diff --git a/src/app/verify/layout.tsx b/src/app/verify/layout.tsx new file mode 100644 index 0000000..6ef63c5 --- /dev/null +++ b/src/app/verify/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import Providers from "@/app/Providers"; +import "@/styles/globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Eclipse Expos | Verification", + description: "Authentication portal for Eclipse Expos", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx new file mode 100644 index 0000000..2d0ec45 --- /dev/null +++ b/src/app/verify/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { LoadingSpinner } from "eclipse-components"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +/** + * Verification page + * + * ?token is the verification token url param + */ +export default function VerifyPage({ + params, +}: Readonly<{ params: { token: string } }>) { + const router = useRouter(); + + const [error, setError] = useState(null); + + useEffect(() => { + // verify the token + fetch("/api/auth/verify-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: params.token }), + }).then((res) => { + if (res.ok) { + // redirect to the login page + router.push("/login"); + } else { + setError("Invalid token"); + } + }); + }, [params.token, router]); + + return ( +
+ {error ?

{error}

: } +
+ ); +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts deleted file mode 100644 index b3a6814..0000000 --- a/src/lib/crypto.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * SHA256 Encryption - */ -export async function sha256(text: string): Promise { - const msgBuffer = new TextEncoder().encode(text); - const hash = await crypto.subtle.digest("SHA-256", msgBuffer); - const hashArray = Array.from(new Uint8Array(hash)); - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -/** - * Base64 Encoding - */ -export function base64encode(text: string): string { - const buff: number[] = []; - for (let i = 0; i < text.length; i++) { - buff.push(text.charCodeAt(i)); - } - - return btoa(String.fromCharCode.apply(null, buff)); -} - -/** - * Base64 Decoding - */ -export function base64decode(text: string): string { - const buff = atob(text); - const arr = []; - for (let i = 0; i < buff.length; i++) { - arr.push(buff.charCodeAt(i)); - } - - return String.fromCharCode.apply(null, arr); -} - -/** - * Generate a random id using nanoseconds - * @returns The random id - */ -export async function genId(): Promise { - return sha256(Math.random().toString() + ":" + Date.now()); -} diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..f9cd9d7 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,27 @@ +import jwt from "jsonwebtoken"; + +export async function jwtGenerateEmailVerificationToken(email: string) { + const JWT_SECRET = process.env.JWT_SECRET!; + + return jwt.sign({ email }, JWT_SECRET, { + expiresIn: "10m", // 10 minutes + }); +} + +export async function jwtVerifyEmailVerificationToken(token: string) { + if (!token) { + throw new Error("Token is required."); + } + + const JWT_SECRET = process.env.JWT_SECRET!; + + return jwt.verify(token, JWT_SECRET); +} + +export async function jwtDecodeEmailVerificationToken(token: string) { + if (!token) { + throw new Error("Token is required."); + } + + return jwt.decode(token); +} diff --git a/src/lib/responses.ts b/src/lib/responses.ts deleted file mode 100644 index 061ac62..0000000 --- a/src/lib/responses.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { base64encode } from "./crypto"; - -export class Response { - static get Success() { - const id = base64encode(Math.random().toString()); - const timestamp = Date.now(); - - return { - success: true, - message: "Success", - timestamp, - id, - }; - } - - static get InvalidQueryParams() { - const id = base64encode(Math.random().toString()); - const timestamp = Date.now(); - - return { - success: false, - message: "Invalid query parameters", - timestamp, - id, - }; - } - - static get InternalError() { - const id = base64encode(Math.random().toString()); - const timestamp = Date.now(); - - return { - success: false, - message: "Internal server error", - timestamp, - id, - }; - } - - static get InvalidBody() { - const id = base64encode(Math.random().toString()); - const timestamp = Date.now(); - - return { - success: false, - message: "Invalid request body", - timestamp, - id, - }; - } - - static get MethodNotAllowed() { - const id = base64encode(Math.random().toString()); - const timestamp = Date.now(); - - return { - success: false, - message: "Method not allowed", - timestamp, - id, - }; - } - - static get NotFound() { - const id = base64encode(Math.random().toString()); - const timestamp = Date.now(); - - return { - success: false, - message: "Not found", - timestamp, - id, - }; - } -} diff --git a/src/app/globals.css b/src/styles/globals.css similarity index 100% rename from src/app/globals.css rename to src/styles/globals.css