diff --git a/.anima/.gitignore b/.anima/.gitignore new file mode 100644 index 000000000..5e4659675 --- /dev/null +++ b/.anima/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/README.md b/README.md index dfa05e177..f83173911 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Project Auth API -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +This project, completed as part of the Technigo bootcamp, involves developing a full-stack authentication system with a backend API and a React frontend. The project includes user registration and login functionalities, token-based authentication, and protected routes that require valid authentication tokens for access. ## The problem -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +The goal of this project was to create a secure user authentication system. The system allows users to register and log in, storing their credentials securely. Once logged in, users can access protected content that is only available to authenticated users. The primary challenge was ensuring the security of user data and tokens while providing a seamless user experience. + +To create a secure user authentication system with a backend API and a React frontend, we planned the project by outlining the essential components and their interactions. The approach involved building a Node.js and Express backend with MongoDB for data storage, and bcrypt for password hashing. The frontend was developed using React, incorporating forms for user registration and login, with authenticated routes to manage secure content access. If given more time, we would enhance the validation and security features, add more user functionalities, and improve the user experience with better error handling. ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. +[Frontend](https://authentication-service.netlify.app/) +[Backend](https://auth-s0og.onrender.com) diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..53bbf3933 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,8 +12,10 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^5.1.1", "cors": "^2.8.5", "express": "^4.17.3", + "express-list-endpoints": "^7.1.0", "mongoose": "^8.0.0", "nodemon": "^3.0.1" } diff --git a/backend/server.js b/backend/server.js index dfe86fb8e..ea9cf14e9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,15 +1,67 @@ +import bcrypt from "bcrypt"; import cors from "cors"; import express from "express"; +import expressListEndpoints from "express-list-endpoints"; import mongoose from "mongoose"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/auth"; mongoose.connect(mongoUrl); mongoose.Promise = Promise; -// Defines the port the app will run on. Defaults to 8080, but can be overridden +// Create user object that has access-token. Mongoose-model +// Destructure schema & model +const { Schema, model } = mongoose; + +const userSchema = new Schema({ + name: { + type: String, + unique: true, + required: [true, "Name is required"], + }, + email: { + type: String, + unique: true, + required: [true, "Email is required"], + match: [/.+\@.+\..+/, "Please enter a valid email address"], + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [8, "Password must be at least 8 characters long"], + }, + accessToken: { + type: String, + default: () => bcrypt.genSaltSync(), + }, +}); + +const User = model("User", userSchema); + +const authenticateUser = async (req, res, next) => { + try { + const user = await User.findOne({ + accessToken: req.header("Authorization"), + }); + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true, + }); + } + } catch (err) { + res + .status(500) + .json({ message: "Internal server error", error: err.message }); + } +}; + +// Defines the port the app will run on. Defaults to 8030, but can be overridden // when starting the server. Example command to overwrite PORT env variable value: // PORT=9000 npm start -const port = process.env.PORT || 8080; +const port = process.env.PORT || 8030; const app = express(); // Add middlewares to enable cors and json body parsing @@ -18,7 +70,70 @@ app.use(express.json()); // Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!"); + const endpoints = expressListEndpoints(app); + res.json(endpoints); +}); + +app.post("/register", async (req, res) => { + try { + const { name, email, password } = req.body; + + // check if password is empty + if (!password) { + return res.status(400).json({ message: "Password is required" }); + } + + // Check if password meets minimum length req + if (password.length < 8) { + return res.status(400).json({ + message: "Password has to be at least 8 characters long", + }); + } + // Encrypt the password + const user = await new User({ + name, + email, + password: bcrypt.hashSync(password, 10), + }).save(); + + res.status(201).json({ id: user._id, accessToken: user.accessToken }); + } catch (err) { + res + .status(400) + .json({ message: "Could not create user", errors: err.errors }); + } +}); + +// protect my-pages endpoint +app.get("/my-pages", authenticateUser, (req, res) => { + res.json({ message: "This is your personal page" }); +}); + +// Allow the user to log in, not only register + +app.post("/sign-in", async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) { + res.status(400).json({ message: "Email and password are required" }); + return; + } + + const user = await User.findOne({ email }); + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + userId: user._id, + name: user.name, + accessToken: user.accessToken, + }); + } else { + res.status(401).json({ message: "Invalid email or password" }); + } + } catch (err) { + res + .status(500) + .json({ message: "Internal server error", error: err.message }); + } }); // Start the server diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index f768e33fc..000000000 --- a/frontend/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# React + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..79796e2af 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,33 @@ +import { useEffect, useState } from "react"; + +import { MyPages } from "./components/MyPages"; +import { Registration } from "./components/Registration"; +import { SignIn } from "./components/SignIn"; + export const App = () => { - return
Find me in src/app.jsx!
; + const [isRegistering, setIsRegistering] = useState(false); + const [user, setUser] = useState(null); + + useEffect(() => { + // Check if user data is available in localStorage on component mount + const accessToken = localStorage.getItem("accessToken"); + const userName = localStorage.getItem("userName"); + const userId = localStorage.getItem("userId"); + + if (accessToken && userName && userId) { + setUser({ id: userId, name: userName }); + } + }, []); + + return ( +
+ {user ? ( + + ) : isRegistering ? ( + + ) : ( + + )} +
+ ); }; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/Loader.css b/frontend/src/components/Loader.css new file mode 100644 index 000000000..e729789fe --- /dev/null +++ b/frontend/src/components/Loader.css @@ -0,0 +1,17 @@ +.loader { + border: 2px solid #f3f3f3; + border-top: 2px solid #000000; + border-radius: 50%; + width: 16px; + height: 16px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/frontend/src/components/Loader.jsx b/frontend/src/components/Loader.jsx new file mode 100644 index 000000000..c2d7f5b66 --- /dev/null +++ b/frontend/src/components/Loader.jsx @@ -0,0 +1,5 @@ +import "./Loader.css"; + +export const Loader = () => { + return
; +}; diff --git a/frontend/src/components/MyPages.jsx b/frontend/src/components/MyPages.jsx new file mode 100644 index 000000000..a94687ace --- /dev/null +++ b/frontend/src/components/MyPages.jsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { SignOut } from "./SignOut"; + +export const MyPages = ({ user, setUser }) => { + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + const fetchMyPages = async () => { + const accessToken = localStorage.getItem("accessToken"); + if (!accessToken) { + setError("No access token found. Please log in again."); + return; + } + try { + const response = await fetch( + "https://auth-s0og.onrender.com/my-pages", + { + headers: { + Authorization: accessToken, + }, + } + ); + const data = await response.json(); + if (response.ok) { + setMessage(data.message); + } else { + setError("Failed to fetch data. Please log in again"); + } + } catch (error) { + setError("An error occurred. Please try again later"); + } + }; + fetchMyPages(); + }, []); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!message) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+

Welcome, {user.name}

+

{message}

+ +
+ ); +}; diff --git a/frontend/src/components/Registration.css b/frontend/src/components/Registration.css new file mode 100644 index 000000000..f6c554eb2 --- /dev/null +++ b/frontend/src/components/Registration.css @@ -0,0 +1,61 @@ +.container { + background-color: white; + padding: 20px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + max-width: 400px; + margin: 50px auto; + font-family: Arial, sans-serif; +} + +h1 { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input { + width: 100%; + padding: 8px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 3px; +} + +button { + width: 100%; + padding: 10px; + background-color: #28a745; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} + +button:hover { + background-color: #218838; +} + +p { + margin-top: 20px; + text-align: center; +} + +.login-link { + color: blue; + cursor: pointer; + text-decoration: underline; +} + +.register-link:hover { + text-decoration: none; +} + +.loading-container { + display: flex; + justify-content: center; + padding: 20px; +} diff --git a/frontend/src/components/Registration.jsx b/frontend/src/components/Registration.jsx new file mode 100644 index 000000000..89c7de6e3 --- /dev/null +++ b/frontend/src/components/Registration.jsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import "./Registration.css"; +import { Loader } from "./Loader"; + +export const Registration = ({ setIsRegistering }) => { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + setIsLoading(true); + + try { + const response = await fetch("https://auth-s0og.onrender.com/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, email, password }), + }); + + const result = await response.json(); + + if (response.ok) { + setMessage("Registration successful!"); + setName(""); + setEmail(""); + setPassword(""); + setIsLoading(false); + + // Redirect to the sign-in form after successful registration + setTimeout(() => { + setIsRegistering(false); + }, 2000); // Redirect after 2 seconds + } else { + setMessage(result.message || "Registration failed!"); + } + } catch (error) { + setMessage("An error occurred. Please try again later."); + } finally { + setIsLoading(false); // Reset isLoading after form submission is completed + } + }; + + return ( +
+ <> +

Register

+
+ + setName(e.target.value)} + required + /> + + setEmail(e.target.value)} + required + /> + + setPassword(e.target.value)} + required + /> + +
+ {isLoading && ( +
+ +
+ )} +

{message}

+

+ Already have an account?{" "} + setIsRegistering(false)} + > + Sign in + +

+ +
+ ); +}; diff --git a/frontend/src/components/SignIn.css b/frontend/src/components/SignIn.css new file mode 100644 index 000000000..27381bdea --- /dev/null +++ b/frontend/src/components/SignIn.css @@ -0,0 +1,41 @@ +.container { + background-color: white; + padding: 20px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + max-width: 400px; + margin: 50px auto; + font-family: Arial, sans-serif; +} + +h1 { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input { + width: 100%; + padding: 8px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 3px; +} + +p { + margin-top: 20px; + text-align: center; +} + +.register-link { + color: blue; + cursor: pointer; + text-decoration: underline; +} + +.register-link:hover { + text-decoration: none; +} diff --git a/frontend/src/components/SignIn.jsx b/frontend/src/components/SignIn.jsx new file mode 100644 index 000000000..64d48d60c --- /dev/null +++ b/frontend/src/components/SignIn.jsx @@ -0,0 +1,86 @@ +import { useState } from "react"; + +import "./SignIn.css"; +import { Loader } from "./Loader"; + +export const SignIn = ({ setIsRegistering, setUser }) => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + setIsLoading(true); + + try { + const response = await fetch("https://auth-s0og.onrender.com/sign-in", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const result = await response.json(); + + if (response.ok) { + setMessage("Sign in successful!"); + // You can also save the token to localStorage or context for further authenticated requests + localStorage.setItem("accessToken", result.accessToken); + localStorage.setItem("userId", result.userId); + localStorage.setItem("userName", result.name); + setUser({ id: result.userId, name: result.name }); + setIsLoading(false); + } else { + setMessage(result.message || "Sign in failed!"); + } + } catch (error) { + setMessage("An error occurred. Please try again later."); + } finally { + setIsLoading(false); // Reset isLoading after form submission is completed + } + }; + + return ( +
+

Sign In

+
+ + setEmail(e.target.value)} + required + /> + + setPassword(e.target.value)} + required + /> + +
+ {isLoading && ( +
+ +
+ )} +

{message}

+

+ Not a registered user?{" "} + setIsRegistering(true)} + > + Register + +

+
+ ); +}; diff --git a/frontend/src/components/SignOut.jsx b/frontend/src/components/SignOut.jsx new file mode 100644 index 000000000..f9e6f30b6 --- /dev/null +++ b/frontend/src/components/SignOut.jsx @@ -0,0 +1,10 @@ +export const SignOut = ({ setUser }) => { + const handleSignOut = () => { + localStorage.removeItem("accessToken"); + localStorage.removeItem("userId"); + localStorage.removeItem("userName"); + setUser(null); // Reset the user state to null + }; + + return ; +}; diff --git a/instructions.md b/instructions.md deleted file mode 100644 index eccc02575..000000000 --- a/instructions.md +++ /dev/null @@ -1,37 +0,0 @@ -# Instructions -Your project needs two parts; a backend API, and a React frontend. You'll need to create a `User` model using mongoose, with properties for your registered user, and to store a user's access token. - -Then, on the frontend side of things, you'll need to build up a registration form that POSTs to your API. You'll need to store the access token you get back in the browser using local storage, and then use that token when making other requests to your API. - -Once a user is logged in, you will need to have one last endpoint which returns some content which only logged-in users should be able to access. You can choose what you want this endpoint to return, and if you want to make multiple endpoints, that's fine too. It could return hard-coded data or something from the database - either is fine. Whatever you choose, it should be displayed in the frontend after you've logged in. - -To summarise, your API needs: -- Registration endpoint, to create a new user. -- Sign-in endpoint, to authenticate a returning user. -- An authenticated endpoint which only returns content if the `Authorization` header with the user's token was correct. - -Your frontend needs: -- A registration form. -- A sign-in form. -- A page to show the authenticated content from the API. -- A 'sign out' button that removes the saved access token and redirects the user to the login form. - -## Requirements -- Your API should have routes to register and login, and finally an authenticated endpoint. -- The authenticated endpoint should return a 401 or 403 (see [401 vs. 403 on SO](https://stackoverflow.com/questions/3297048/403-forbidden-vs-401-unauthorized-http-responses)) with an error message if you try to access it without an `Authentication` access token or with an invalid token. -- Your frontend should have a registration form which POSTs to the API to create a new user. -- Your passwords in the database should be encrypted with bcrypt. -- Your API should validate the user input when creating a new user, and return error messages which could be shown by the frontend (displaying the errors in a nice way in the frontend is a stretch goal - it’s fine to just show 'Something went wrong' on the frontend if you run out of time). - -## Stretch goals -So you’ve completed the requirements? Great job! Make sure you've committed and pushed a version of your project before starting on the stretch goals. Remember that the stretch goals are optional. - -### Intermediate Stretch Goals -- Store data in the database for your authenticated data routes. -- When registering, display error messages from the API next to the field which has the error. For example, if the email address is invalid, show an error message next to the email input. -- To challenge yourself, try to implement Google authentication with Firebase. [Here](https://www.freecodecamp.org/news/react-firebase-authentication-and-crud-operations/) you will find detailed tutorial which will guide you through implementation (some of the steps connected to [Material UI](https://mui.com/) components can be replaced with your custom components). - - -### Advanced Stretch Goals -- Add more routes, perhaps even a `POST` route to create new objects in your database as a logged-in user. -- Improve validations in the backend to ensure unique email addresses, or validate the email address format using a regular expression. diff --git a/netlify.toml b/netlify.toml index 95443a1f3..6abaa9a64 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,11 @@ # This file tells netlify where the code for this project is and # how it should build the JavaScript assets to deploy from. [build] - base = "frontend/" - publish = "build/" + base = "frontend" + publish = "dist" command = "npm run build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/pull_request_template.md b/pull_request_template.md deleted file mode 100644 index d92c89b51..000000000 --- a/pull_request_template.md +++ /dev/null @@ -1,7 +0,0 @@ -## Netlify link -Add your Netlify link here. -PS. Don't forget to add it in your readme as well. - -## Collaborators -Add your collaborators here. Write their GitHub usernames in square brackets. If there's more than one, separate them with a comma, like this: -[github-username-1, github-username-2]