diff --git a/backend/package-lock.json b/backend/package-lock.json index 03af6eb..52eb18f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@apollo/server": "^5.5.1", + "bcryptjs": "^3.0.3", "graphql": "^16.14.2", "mongodb": "^7.7.0" }, @@ -1237,6 +1238,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index fd32e2c..409ef73 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "type": "module", "dependencies": { "@apollo/server": "^5.5.1", + "bcryptjs": "^3.0.3", "graphql": "^16.14.2", "mongodb": "^7.7.0" }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 781d8db..4252b8f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,10 +1,41 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; import { connectToDatabase } from "./db.js"; +import { + randomBytes, + scrypt as scryptCallback, + timingSafeEqual, +} from "node:crypto"; +import { promisify } from "node:util"; +import { GraphQLError } from "graphql/error/index.js"; + + +// Hash a password using scrypt +const scrypt = promisify(scryptCallback); + +async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const hash = (await scrypt(password, salt, 64)) as Buffer; + return `${salt.toString("hex")}:${hash.toString("hex")}`; +} + +async function verifyPassword(password: string, storedHash: string): Promise { + const [saltHex, hashHex] = storedHash.split(":"); + if (!saltHex || !hashHex) return false; + + const salt = Buffer.from(saltHex, "hex"); + const expectedHash = Buffer.from(hashHex, "hex"); + const actualHash = (await scrypt(password, salt, expectedHash.length)) as Buffer; + + return expectedHash.length === actualHash.length && timingSafeEqual(expectedHash, actualHash); +} // Connect to MongoDB const db = await connectToDatabase(); +// Prevent duplicate usernames at the database level +await db.collection("users").createIndex({ username: 1 }, { unique: true }); + // Define the GraphQL schema const typeDefs = `#graphql type Artwork { @@ -20,10 +51,20 @@ const typeDefs = `#graphql totalCount: Int! } + type User { + id: ID! + username: String! + } + type Query { hello: String! artworks(limit: Int!, offset: Int!): ArtworkPage! } + + type Mutation { + register(username: String!, password: String!): User! + login(username: String!, password: String!): User! + } `; // Define how queries are resolved @@ -42,7 +83,6 @@ const resolvers = { const [artworks, totalCount] = await Promise.all([ collection.find({}).sort({ id: 1 }).skip(offset).limit(limit).toArray(), - collection.countDocuments({}), ]); @@ -52,6 +92,62 @@ const resolvers = { }; }, }, + Mutation: { + login: async ( + _parent: unknown, + args: { username: string; password: string }, + ) => { + const username = args.username.trim().toLowerCase(); + const user = await db.collection("users").findOne({ username }); + + if (!user || typeof user.passwordHash !== "string") { + throw new GraphQLError("Incorrect username or password"); + } + + const passwordIsCorrect = await verifyPassword(args.password, user.passwordHash); + if (!passwordIsCorrect) { + throw new GraphQLError("Incorrect username or password"); + } + + return { id: user._id.toString(), username: user.username }; + }, + register: async ( + _parent: unknown, + args: { username: string; password: string }, + ) => { + const username = args.username.trim().toLowerCase(); + + if (username.length < 3 || username.length > 20) { + throw new GraphQLError("Username must be between 3 and 20 characters"); + } + if (args.password.length < 8 || args.password.length > 100) { + throw new GraphQLError("Password must be between 8 and 100 characters"); + } + + const users = db.collection("users"); + + const existing = await users.findOne({ username }); + if (existing) { + throw new GraphQLError("Username is already taken"); + } + + const passwordHash = await hashPassword(args.password); + + try { + const result = await users.insertOne({ + username, + passwordHash, + createdAt: new Date(), + }); + return { id: result.insertedId.toString(), username }; + } catch (err: any) { + if (err?.code === 11000) { + throw new GraphQLError("Username is already taken"); + } + throw err; + } + }, + }, }; // Create the Apollo Server diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 98edca7..1ecf606 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,11 +11,11 @@ "@apollo/client": "^4.3.3", "@tailwindcss/vite": "^4.3.3", "graphql": "^16.14.2", - "rxjs": "^7.8.2", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.4", + "rxjs": "^7.8.2", "tailwindcss": "^4.3.3" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f4191d..e1bcc5b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,12 +2,14 @@ import { Routes, Route } from "react-router-dom"; import "./index.css"; import HomePage from "./pages/HomePage"; import Login from "./pages/login"; +import CreateAccount from "./pages/CreateAccount"; function App() { return ( } /> } /> + } /> ); } diff --git a/frontend/src/pages/CreateAccount.tsx b/frontend/src/pages/CreateAccount.tsx new file mode 100644 index 0000000..ff457e0 --- /dev/null +++ b/frontend/src/pages/CreateAccount.tsx @@ -0,0 +1,148 @@ +import { useState, type SubmitEvent as ReactSubmitEvent } from "react"; +import { Link } from "react-router-dom"; + +type RegisterResponse = { + data?: { register?: { id: string; username: string } }; + errors?: Array<{ message: string }>; +}; + +export default function CreateAccount() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleRegister(event: ReactSubmitEvent) { + event.preventDefault(); + setMessage(""); + setIsSubmitting(true); + + const query = ` + mutation Register($username: String!, $password: String!) { + register(username: $username, password: $password) { + id + username + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables: { username, password } }), + }); + + if (!response.ok) { + throw new Error(`Server error (${response.status}).`); + } + + const result = (await response.json()) as RegisterResponse; + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + if (!result.data?.register) { + throw new Error("The server did not return the new account."); + } + + setMessage(`Account created for ${result.data.register.username}.`); + setPassword(""); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Something went wrong.", + ); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+ + ← Back + + +

+ Create account +

+ +
+
+ + setUsername(event.target.value)} + minLength={3} + maxLength={20} + required + className="w-full rounded-lg bg-gray-100 px-4 py-3 text-base text-black outline-none transition placeholder:text-gray-400 hover:bg-gray-200 focus:bg-white focus:ring-2 focus:ring-gray-400" + /> +
+ +
+ + setPassword(event.target.value)} + minLength={8} + maxLength={100} + required + className="w-full rounded-lg bg-gray-100 px-4 py-3 text-base text-black outline-none transition placeholder:text-gray-400 hover:bg-gray-200 focus:bg-white focus:ring-2 focus:ring-gray-400" + /> +
+ + + + {message && ( +

+ {message} +

+ )} +
+ +

+ Already have an account?{" "} + + Login + +

+
+
+ + +
+ ); +} diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 7e2eee6..4aeff26 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,26 +1,94 @@ import { Link } from "react-router-dom"; +import { useState, type SubmitEvent as ReactSubmitEvent } from "react"; + +type LoginResponse = { + data?: { login?: { id: string; username: string } }; + errors?: Array<{ message: string }>; +}; function Login() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleLogin(event: ReactSubmitEvent) { + event.preventDefault(); + setMessage(""); + setIsSubmitting(true); + + const query = ` + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + id + username + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables: { username, password } }), + }); + + if (!response.ok) { + throw new Error(`Server error (${response.status}).`); + } + + const result = (await response.json()) as LoginResponse; + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + if (!result.data?.login) { + throw new Error("The server did not return a user."); + } + + setMessage(`Welcome back, ${result.data.login.username}.`); + setPassword(""); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Something went wrong.", + ); + } finally { + setIsSubmitting(false); + } + } + return ( -
-
- - ← Back - - -
-

Login

- -
+
+
+
+ + ← Back + + +

+ Login +

+ +
setUsername(event.target.value)} + required className="w-full rounded-lg bg-gray-100 px-4 py-3 text-base text-black outline-none transition placeholder:text-gray-400 hover:bg-gray-200 focus:bg-white focus:ring-2 focus:ring-gray-400" />
@@ -29,35 +97,56 @@ function Login() { setPassword(event.target.value)} + required className="w-full rounded-lg bg-gray-100 px-4 py-3 text-base text-black outline-none transition placeholder:text-gray-400 hover:bg-gray-200 focus:bg-white focus:ring-2 focus:ring-gray-400" />
-
- + + + {message && ( +

+ {message} +

+ )} + -

+

Don't have an account?{" "} - - Sign up - + + Create account +

-
+ -
+
-
- ) + + + ); } -export default Login \ No newline at end of file +export default Login; \ No newline at end of file