Implemented a simple authentication scheme with Firebase and Iron Session

This commit is contained in:
Tiago Ribeiro
2023-04-12 16:53:36 +01:00
parent cb1a67de23
commit 58bdc745e4
16 changed files with 1371 additions and 33 deletions

View File

@@ -16,21 +16,28 @@
"@types/node": "18.13.0", "@types/node": "18.13.0",
"@types/react": "18.0.27", "@types/react": "18.0.27",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.10",
"axios": "^1.3.5",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"daisyui": "^2.50.0", "daisyui": "^2.50.0",
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
"firebase": "9.19.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
"iron-session": "^6.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"next": "13.1.6", "next": "13.1.6",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-lineto": "^3.3.0", "react-lineto": "^3.3.0",
"react-player": "^2.12.0", "react-player": "^2.12.0",
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2", "react-toastify": "^9.1.2",
"swr": "^2.1.3",
"typescript": "4.9.5", "typescript": "4.9.5",
"zustand": "^4.3.6" "zustand": "^4.3.6"
}, },

View File

@@ -1,4 +1,6 @@
import axios from "axios";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router";
interface Props { interface Props {
profilePicture: string; profilePicture: string;
@@ -6,8 +8,16 @@ interface Props {
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({profilePicture}: Props) { export default function Navbar({profilePicture}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
});
};
return ( return (
<div className="navbar bg-neutral-100 drop-shadow-md text-black"> <div className="navbar bg-neutral-100 drop-shadow-md text-black z-10">
<div className="flex-1"> <div className="flex-1">
<Link className="btn btn-ghost normal-case text-xl" href="/"> <Link className="btn btn-ghost normal-case text-xl" href="/">
IELTS GPT IELTS GPT
@@ -34,7 +44,7 @@ export default function Navbar({profilePicture}: Props) {
<a>Settings</a> <a>Settings</a>
</li> </li>
<li> <li>
<a>Logout</a> <a onClick={logout}>Logout</a>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -1,5 +1,5 @@
{ {
"username": "tiago.ribeiro", "email": "tiago.ribeiro@ecrop.dev",
"name": { "name": {
"first": "Tiago", "first": "Tiago",
"last": "Ribeiro" "last": "Ribeiro"

14
src/firebase/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import {initializeApp} from "firebase/app";
import {getFirestore} from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
authDomain: process.env.FIREBASE_AUTH_DOMAIN || "",
projectId: process.env.FIREBASE_PROJECT_ID || "",
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
appId: process.env.FIREBASE_APP_ID || "",
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
};
export const app = initializeApp(firebaseConfig);

29
src/hooks/useUser.tsx Normal file
View File

@@ -0,0 +1,29 @@
import {useEffect} from "react";
import Router from "next/router";
import useSWR from "swr";
import {User} from "@/interfaces/user";
import axios from "axios";
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export default function useUser({redirectTo = "", redirectIfFound = false} = {}) {
const {data: user, mutate: mutateUser, isLoading} = useSWR<User>("/api/user", fetcher);
useEffect(() => {
// if no redirect needed, just return (example: already on /dashboard)
// if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
console.log(redirectTo, user);
if (!redirectTo || !user) return;
if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && !user) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user)
) {
Router.push(redirectTo);
}
}, [user, redirectIfFound, redirectTo]);
return {user, mutateUser, isLoading};
}

View File

@@ -1,5 +1,5 @@
export interface User { export interface User {
username: string; email: string;
name: Name; name: Name;
profilePicture: string; profilePicture: string;
id: string; id: string;

18
src/lib/session.ts Normal file
View File

@@ -0,0 +1,18 @@
// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import type {IronSessionOptions} from "iron-session";
import {User} from "@/interfaces/user";
export const sessionOptions: IronSessionOptions = {
password: process.env.SECRET_COOKIE_PASSWORD as string,
cookieName: "eCrop/ielts",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
};
// This is where we specify the typings of req.session.*
declare module "iron-session" {
interface IronSessionData {
user?: User | null;
}
}

View File

@@ -2,6 +2,10 @@ import "@/styles/globals.css";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import type {AppProps} from "next/app"; import type {AppProps} from "next/app";
import "primereact/resources/themes/lara-light-indigo/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
export default function App({Component, pageProps}: AppProps) { export default function App({Component, pageProps}: AppProps) {
return <Component {...pageProps} />; return <Component {...pageProps} />;
} }

View File

@@ -0,0 +1,19 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs} from "firebase/firestore";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const {module} = req.query as {module: string};
const snapshot = await getDocs(collection(db, module));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}

38
src/pages/api/login.ts Normal file
View File

@@ -0,0 +1,38 @@
import {NextApiRequest, NextApiResponse} from "next";
import {getAuth, signInWithEmailAndPassword} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
import {User} from "@/interfaces/user";
import {getFirestore, getDoc, doc} from "firebase/firestore";
const auth = getAuth(app);
const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions);
async function login(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as {email: string; password: string};
signInWithEmailAndPassword(auth, email, password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
const docUser = await getDoc(doc(db, "users", userId));
if (!docUser.exists()) {
res.status(401).json({error: 401, message: "User does not exist!"});
return;
}
const user = docUser.data() as User;
req.session.user = user;
await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
}

21
src/pages/api/logout.ts Normal file
View File

@@ -0,0 +1,21 @@
import {NextApiRequest, NextApiResponse} from "next";
import {getAuth, signOut} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
const auth = getAuth(app);
export default withIronSessionApiRoute(logout, sessionOptions);
async function logout(req: NextApiRequest, res: NextApiResponse) {
signOut(auth)
.then(() => {
req.session.destroy();
res.status(200).json({ok: true});
})
.catch(() => {
req.session.destroy();
res.status(500).json({ok: false});
});
}

27
src/pages/api/user.ts Normal file
View File

@@ -0,0 +1,27 @@
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {getAuth} from "firebase/auth";
import {withIronSessionApiRoute} from "iron-session/next";
import {NextApiRequest, NextApiResponse} from "next";
const auth = getAuth(app);
export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) {
console.log(auth.currentUser);
if (!auth.currentUser) {
res.status(401).json({ok: false});
return;
}
if (req.session.user.id === auth.currentUser.uid) {
res.status(401).json({ok: false});
return;
}
res.json({user: req.session.user});
} else {
res.status(401).json({ok: false});
}
}

View File

@@ -4,22 +4,38 @@ import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
// TODO: Remove this import
import JSON_USER from "@/demo/user.json";
import JSON_READING from "@/demo/reading.json";
import JSON_LISTENING from "@/demo/listening.json";
import JSON_WRITING from "@/demo/writing.json";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, UserSolution, WritingExam} from "@/interfaces/exam"; import {Exam, ListeningExam, ReadingExam, UserSolution, WritingExam} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer} from "react-toastify"; import {ToastContainer, toast} from "react-toastify";
import Link from "next/link";
import Finish from "@/exams/Finish"; import Finish from "@/exams/Finish";
import axios from "axios";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
export default function Home() { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Page({user}: {user: User}) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [moduleIndex, setModuleIndex] = useState(0); const [moduleIndex, setModuleIndex] = useState(0);
const [exam, setExam] = useState<Exam>(); const [exam, setExam] = useState<Exam>();
@@ -27,21 +43,31 @@ export default function Home() {
const [showSolutions, setShowSolutions] = useState(false); const [showSolutions, setShowSolutions] = useState(false);
useEffect(() => { useEffect(() => {
(async () => {
if (selectedModules.length > 0 && moduleIndex < selectedModules.length) { if (selectedModules.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = getExam(selectedModules[moduleIndex]); const nextExam = await getExam(selectedModules[moduleIndex]);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
} }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex]); }, [selectedModules, moduleIndex]);
const getExam = (module: Module): Exam | undefined => { const getExam = async (module: Module): Promise<Exam | undefined> => {
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
if (examRequest.status !== 200) {
toast.error("Something went wrong!");
return undefined;
}
const newExam = examRequest.data;
switch (module) { switch (module) {
case "reading": case "reading":
return JSON_READING as ReadingExam; return newExam.shift() as ReadingExam;
case "listening": case "listening":
return JSON_LISTENING as ListeningExam; return newExam.shift() as ListeningExam;
case "writing": case "writing":
return JSON_WRITING as WritingExam; return newExam.shift() as WritingExam;
} }
return undefined; return undefined;
@@ -63,13 +89,13 @@ export default function Home() {
const renderScreen = () => { const renderScreen = () => {
if (selectedModules.length === 0) { if (selectedModules.length === 0) {
return <Selection user={JSON_USER} onStart={setSelectedModules} />; return <Selection user={user} onStart={setSelectedModules} />;
} }
if (moduleIndex >= selectedModules.length) { if (moduleIndex >= selectedModules.length) {
return ( return (
<Finish <Finish
user={JSON_USER} user={user}
modules={selectedModules} modules={selectedModules}
onViewResults={() => { onViewResults={() => {
setShowSolutions(true); setShowSolutions(true);
@@ -110,7 +136,7 @@ export default function Home() {
</Head> </Head>
<main className="w-full h-screen flex flex-col items-center bg-neutral-100 text-black"> <main className="w-full h-screen flex flex-col items-center bg-neutral-100 text-black">
<ToastContainer /> <ToastContainer />
<Navbar profilePicture={JSON_USER.profilePicture} /> <Navbar profilePicture={user.profilePicture} />
{renderScreen()} {renderScreen()}
</main> </main>
</> </>

View File

@@ -11,9 +11,31 @@ import ProfileCard from "@/components/ProfileCard";
// TODO: Remove this import // TODO: Remove this import
import JSON_RESULTS from "@/demo/user_results.json"; import JSON_RESULTS from "@/demo/user_results.json";
import JSON_USER from "@/demo/user.json";
export default function Home() { import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Home({user}: {user: User}) {
return ( return (
<> <>
<Head> <Head>
@@ -26,7 +48,7 @@ export default function Home() {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="w-full h-screen flex flex-col items-center bg-neutral-100"> <main className="w-full h-screen flex flex-col items-center bg-neutral-100">
<Navbar profilePicture={JSON_USER.profilePicture} /> <Navbar profilePicture={user.profilePicture} />
<div className="w-full h-full p-4 relative"> <div className="w-full h-full p-4 relative">
<Link href="/exam"> <Link href="/exam">
<button className={clsx("btn gap-2 top-12 right-12 absolute", infoButtonStyle)}> <button className={clsx("btn gap-2 top-12 right-12 absolute", infoButtonStyle)}>
@@ -36,7 +58,7 @@ export default function Home() {
</Link> </Link>
<section className="h-full w-full flex items-center p-8 gap-12 justify-center"> <section className="h-full w-full flex items-center p-8 gap-12 justify-center">
<section className="w-1/2 h-full flex items-center"> <section className="w-1/2 h-full flex items-center">
<ProfileCard user={JSON_USER} className="text-black self-start" /> <ProfileCard user={user} className="text-black self-start" />
</section> </section>
<section className="w-1/2 h-full flex items-center justify-center"> <section className="w-1/2 h-full flex items-center justify-center">
<UserResultChart results={JSON_RESULTS} resultKey="total" label="Total exams" className="w-2/3" /> <UserResultChart results={JSON_RESULTS} resultKey="total" label="Total exams" className="w-2/3" />

82
src/pages/login.tsx Normal file
View File

@@ -0,0 +1,82 @@
import {User} from "@/interfaces/user";
import {toast, ToastContainer} from "react-toastify";
import axios from "axios";
import {FormEvent, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {InputText} from "primereact/inputtext";
import {Button} from "primereact/button";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const {mutateUser} = useUser({
redirectTo: "/",
redirectIfFound: true,
});
const login = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
axios
.post<User>("/api/login", {email, password})
.then((response) => {
toast.success("You have been logged in!", {toastId: "login-successful"});
mutateUser(response.data);
})
.catch((e) => {
if (e.response.status === 401) {
toast.error("Wrong login credentials!", {toastId: "wrong-credentials"});
} else {
toast.error("Something went wrong!", {toastId: "server-error"});
}
setIsLoading(false);
});
};
return (
<>
<Head>
<title>Login | IELTS GPT</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-screen flex flex-col items-center justify-center bg-neutral-100">
<ToastContainer />
<form className="p-4 rounded-xl bg-white drop-shadow-xl flex flex-col gap-4" onSubmit={login}>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-inbox"></i>
</span>
<InputText
placeholder="E-mail..."
type="email"
required
disabled={isLoading}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>
</div>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-star"></i>
</span>
<InputText
placeholder="Password..."
type="password"
required
disabled={isLoading}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<Button loading={isLoading} iconPos="right" label="Login" icon="pi pi-check" />
</form>
</main>
</>
);
}

1031
yarn.lock

File diff suppressed because it is too large Load Diff