diff --git a/README.md b/README.md index dfa05e177..88631f030 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,26 @@ # 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. +Project Auth is a web application designed to provide user authentication functionalities. The application features a frontend built using React.js and a backend developed with Node.js and Express.js. User data is stored securely in a MongoDB database, and password hashing is implemented using bcrypt for enhanced security. ## 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? +Challenges Encountered: + +Backend Setup: Setting up the backend with Node.js and Express.js required careful configuration of routes and middleware for authentication. + +Frontend Integration: Integrating the frontend with the backend API endpoints and managing authentication state in React was challenging. + +User Authentication and Security: Implementing secure authentication and password hashing with bcrypt and JWT authentication required careful implementation. + +Cross-Origin Resource Sharing (CORS): Handling CORS issues during development and configuring CORS middleware in the backend were challenges. + +Technologies Used: + +Frontend: React.js, Axios +Backend: Node.js, Express.js, MongoDB +Authentication: JSON Web Tokens (JWT), bcrypt ## 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. +BE: https://project-auth-2zcr.onrender.com +FE: https://authentication-bmm.netlify.app/ diff --git a/backend/.gitignore b/backend/.gitignore index 25c8fdbab..6fe20f0f3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ node_modules +.env package-lock.json \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..acd86c304 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,4 +1,5 @@ { + "type": "module", "name": "project-auth-backend", "version": "1.0.0", "description": "Starter project to get up and running with express quickly", @@ -10,11 +11,19 @@ "license": "ISC", "dependencies": { "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^5.1.1", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "dotenv": "^16.4.5", "express": "^4.17.3", + "express-async-handler": "^1.2.0", + "express-list-endpoints": "^7.1.0", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.0", "nodemon": "^3.0.1" + }, + "devDependencies": { + "@babel/node": "^7.24.6" } } diff --git a/backend/server.js b/backend/server.js index dfe86fb8e..c70d47af1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,15 +1,73 @@ +import bcrypt from "bcrypt"; +import crypto from "crypto"; import cors from "cors"; +import dotenv from "dotenv"; import express from "express"; +import jwt from "jsonwebtoken"; import mongoose from "mongoose"; +import expressListEndpoints from "express-list-endpoints"; +import asyncHandler from "express-async-handler"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; +dotenv.config(); + +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-auth"; mongoose.connect(mongoUrl); mongoose.Promise = Promise; +const { Schema, model } = mongoose; + +const userSchema = new Schema({ + username: { + type: String, + unique: true, + required: true, + minlength: 3, + }, + email: { + type: String, + unique: true, + required: true, + }, + password: { + type: String, + required: true, + required: true, + minlength: 6, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(16).toString("hex"), + }, +}); + +const User = model("User", userSchema); + +// Function to generate JWT +const generateAccessToken = (userId) => { + return jwt.sign({ userId }, process.env.ACCESS_TOKEN_SECRET, { + expiresIn: "24h", + }); +}; + +// Middleware to athenticate the token +const authenticateToken = async (req, res, next) => { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; + if (token == null) return res.sendStatus(401); + + try { + const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET); + req.user = await User.findById(decoded.userId); + next(); + } catch (err) { + return res.sendStatus(403); + } +}; + // Defines the port the app will run on. Defaults to 8080, 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 || 9000; const app = express(); // Add middlewares to enable cors and json body parsing @@ -18,10 +76,80 @@ app.use(express.json()); // Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!"); + const endpoints = expressListEndpoints(app); + res.json(endpoints); }); +//Registration Endpoint +//http://localhost:9000/register +app.post("/register", async (req, res) => { + try { + const { username, email, password } = req.body; + const hashedPassword = bcrypt.hashSync(password, 10); + const user = new User({ username, email, password: hashedPassword }); + const savedUser = await user.save(); + + // Generate access token for the registered user + const accessToken = generateAccessToken(savedUser._id); + res.status(201).json({ id: savedUser._id, accessToken }); + } catch (err) { + console.error("Error creating user:", err); // Log the actual error for debugging + let errorMessage = "Could not create user"; + if (err.code === 11000) { + // Duplicate key error + if (err.keyPattern && err.keyPattern.username) { + errorMessage = "Username already exists"; + } else if (err.keyPattern && err.keyPattern.email) { + errorMessage = "Email already exists"; + } + } else if (err.errors) { + // Validation errors + const errorFields = Object.keys(err.errors); + if (errorFields.length > 0) { + errorMessage = err.errors[errorFields[0]].message; + } + } + res.status(400).json({ message: errorMessage, errors: err.errors }); + } +}); + +// Sign-in Endpoint +app.post("/login", async (req, res) => { + const { email, password } = req.body; + const user = await User.findOne({ email }); + + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + const accessToken = generateAccessToken(user._id); + await User.findByIdAndUpdate(user._id, { accessToken }); + res.json({ accessToken, username: user.username }); +}); + +// Authenticated endpoint +app.get("/secrets", authenticateToken, (req, res) => { + res.json({ secret: "This is secret content" }); +}); + +app.get( + "/logged-in", + authenticateToken, + asyncHandler(async (req, res) => { + res.status(200).json({ + success: true, + response: { + message: "User is logged in", + }, + }); + }) +); + +/* const secret = crypto.randomBytes(64).toString("hex"); +console.log(secret); */ + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); +console.log("Contents of process.env:", process.env); diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 4dcb43901..bb4f312b1 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -2,19 +2,20 @@ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - settings: { react: { version: '18.2' } }, - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + settings: { react: { version: "18.2" } }, + plugins: ["react-refresh"], rules: { - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], + "react/prop-types": "off", }, -} +}; 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/index.html b/frontend/index.html index 0c589eccd..b9bb266ae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,8 +1,7 @@ - + - Vite + React diff --git a/frontend/netlify.toml b/frontend/netlify.toml new file mode 100644 index 000000000..bdad2d6c8 --- /dev/null +++ b/frontend/netlify.toml @@ -0,0 +1,9 @@ +[build] + base = "frontend/" + publish = "dist" + command = "npm run build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index e9c95b79f..18d92f4c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "react": "^18.2.0", + "axios": "^1.7.2", + "react": "^18.3.1", "react-dom": "^18.2.0" }, "devDependencies": { @@ -23,4 +24,4 @@ "eslint-plugin-react-refresh": "^0.4.3", "vite": "^4.4.5" } -} +} \ No newline at end of file diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..373b523a8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,36 @@ +import AuthenticatedContent from "./components/AuthenticatedContent"; +import RegistrationForm from "./components/RegistrationForm"; +import SignInForm from "./components/SignInForm"; +import { useState } from 'react' + + export const App = () => { - return
Find me in src/app.jsx!
; + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [username, setUsername] = useState('') + + const handleSigninSuccess = (username) => { + setIsAuthenticated(true) + setUsername(username) + } + + const handleSignOut = () => { + setIsAuthenticated(false) + setUsername('') + sessionStorage.removeItem('accessToken') + } + + return ( +
+ {!isAuthenticated ? ( +
+

Authentication System

+ +

Sign in

+ +
+ ) : ( + + )} +
+ ); }; diff --git a/frontend/src/components/AuthenticatedContent.jsx b/frontend/src/components/AuthenticatedContent.jsx new file mode 100644 index 000000000..28bdd1a4c --- /dev/null +++ b/frontend/src/components/AuthenticatedContent.jsx @@ -0,0 +1,13 @@ +const AuthenticatedContent = ({ username, onSignOut }) => { + return ( +
+

πŸŽ‰ Congratulations, {username}! πŸŽ‰

+

You've successfully signed in!

+

Your profile is currently under construction, but stay tuned for more exciting features coming your way!

+

Meanwhile, sit back and relaxπŸ˜‰.

+ +
+ ); +}; + +export default AuthenticatedContent; diff --git a/frontend/src/components/RegistrationForm.jsx b/frontend/src/components/RegistrationForm.jsx new file mode 100644 index 000000000..c69b8d842 --- /dev/null +++ b/frontend/src/components/RegistrationForm.jsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import axios from 'axios'; + +const RegistrationForm = () => { + // State to store form data + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '' + }); + + const [registrationSuccess, setRegistrationSuccess] = useState(false); + const [registeredUsername, setRegisteredUsername] = useState(''); + const [loading, setLoading] = useState(false); + + // State to store error messages + const [error, setError] = useState({ + username: '', + email: '', + password: '', + form: '' + }); + + // Handle form input changes + const handleInputChange = (event) => { + const { name, value } = event.target; + setFormData({ ...formData, [name]: value }); + + // Inline validation logic + if (name === 'username' && value.length < 3) { + setError((prev) => ({ ...prev, username: 'Username must be at least 3 characters long' })); + } else if (name === 'email' && !/\S+@\S+\.\S+/.test(value)) { + setError((prev) => ({ ...prev, email: 'Email must be valid' })); + } else if (name === 'password' && value.length < 6) { + setError((prev) => ({ ...prev, password: 'Password must be at least 6 characters long' })); + } else { + setError((prev) => ({ ...prev, [name]: '' })); + } + }; + + // Handle form submission + const handleSubmit = async (event) => { + event.preventDefault(); + setLoading(true); + + // Clear previous error messages + setError({ form: '', username: '', email: '', password: '' }); + + try { + const response = await axios.post('https://project-auth-2zcr.onrender.com/register', formData); + if (response.status === 201) { + setRegisteredUsername(formData.username); + setRegistrationSuccess(true); + setFormData({ username: '', email: '', password: '' }); + console.log('Registration successful'); + } + } catch (error) { + console.error('Error:', error); + if (error.response && error.response.data && error.response.data.message) { + const errorMsg = error.response.data.message; + if (errorMsg.includes('Username')) { + setError((prev) => ({ ...prev, username: errorMsg })); + } else if (errorMsg.includes('Email')) { + setError((prev) => ({ ...prev, email: errorMsg })); + } else if (errorMsg.includes('Password')) { + setError((prev) => ({ ...prev, password: errorMsg })); + } else { + setError((prev) => ({ ...prev, form: 'Something went wrong' })); + } + } else { + setError((prev) => ({ ...prev, form: 'Something went wrong' })); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ {!registrationSuccess && ( +
+

Register

+
+
+ + + {error.username &&
{error.username}
} +
+
+ + + {error.email &&
{error.email}
} +
+
+ + + {error.password &&
{error.password}
} +
+ +
+ {loading &&
Registering...
} + {error.form &&
{error.form}
} +
+ )} + {registrationSuccess && ( +
+

Hello, {registeredUsername}!

+

You have successfully registered!

+

Now you can sign in using your credentials.

+
+ )} +
+ ); +}; + +export default RegistrationForm; diff --git a/frontend/src/components/SignInForm.jsx b/frontend/src/components/SignInForm.jsx new file mode 100644 index 000000000..aabf6fdca --- /dev/null +++ b/frontend/src/components/SignInForm.jsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import axios from 'axios'; + +const SignInForm = ({ onSignInSuccess }) => { + // State to store form data + const [formData, setFormData] = useState({ + email: '', + password: '' + }); + + // State to store error message + const [error, setError] = useState(''); + + // Handle form input changes + const handleInputChange = (event) => { + const { name, value } = event.target; + setFormData({ ...formData, [name]: value }); + }; + + // Handle form submission + const handleSubmit = async (event) => { + event.preventDefault(); + setError(''); + + + try { + // Send form data to backend API for sign-in + const response = await axios.post('https://project-auth-2zcr.onrender.com/login', formData); + if (response.status === 200) { + const accessToken = response.data.accessToken; + const username = response.data.username; + onSignInSuccess(username); // Pass the username to the parent component + /* console.log('Sign-in successful. Access token:', accessToken); */ + // Reset form fields + setFormData({ email: '', password: '' }); + // Store access token in sessionStorage + sessionStorage.setItem('accessToken', accessToken); + } + } catch (error) { + console.error('Error:', error); + // Sign-in failed + setError(error.response?.data?.error || 'Something went wrong'); + } + }; + + + return ( +
+

Please sign in to continue.

+
+
+ + +
+
+ + +
+ +
+ {error &&
{error}
} +
+ ); +}; + +export default SignInForm; diff --git a/frontend/src/components/SignOutButton.jsx b/frontend/src/components/SignOutButton.jsx new file mode 100644 index 000000000..0a781b4d4 --- /dev/null +++ b/frontend/src/components/SignOutButton.jsx @@ -0,0 +1,6 @@ + +export const SignOutButton = () => { + return ( +
SignOutButton
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e560a674..d3abec6a2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,4 +10,91 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} + +.form-container { + max-width: 400px; + margin: auto; + padding: 20px; + border: 1px solid #ccc; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background-color: #fff; +} + +.heading { + margin-bottom: 20px; + text-align: center; +} + +.form { + display: flex; + flex-direction: column; +} + +.input-group { + margin-bottom: 15px; +} + +.label { + margin-bottom: 5px; + font-weight: bold; +} + +.input { + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 16px; + width: calc(100% - 22px); +} + +.button { + padding: 10px; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 5px; + font-size: 16px; + cursor: pointer; + width: 100%; +} + +.button:hover { + background-color: #0056b3; +} + +.error { + margin-top: 10px; + color: red; + text-align: center; +} + +.loading { + margin-top: 10px; + color: blue; + text-align: center; +} + +/* Styling after signing in */ +.authenticated-content { + max-width: 600px; + margin: 20px auto; + padding: 20px; + border: 2px solid #ccc; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + background-color: #f9f9f9; + text-align: center; + font-family: "Arial", sans-serif; +} + +.authenticated-content h2 { + color: #4caf50; + font-size: 24px; +} + +.authenticated-content p { + margin: 10px 0; + font-size: 18px; } \ No newline at end of file diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 95443a1f3..000000000 --- a/netlify.toml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/" - command = "npm run build" diff --git a/package.json b/package.json index d774b8cc3..728936250 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,13 @@ "name": "project-auth-parent", "version": "1.0.0", "scripts": { - "postinstall": "npm install --prefix backend" + "postinstall": "npm install --prefix backend", + "build": "npm run build --prefix frontend" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "dotenv": "^16.4.5", + "js": "^0.1.0", + "jsonwebtoken": "^9.0.2" } -} +} \ No newline at end of file