diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..ff7829c6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "msedge", + "request": "launch", + "name": "Open redovisning-demo.html", + "file": "c:\\Users\\mario\\Desktop\\projekt\\yh-message-app-fullstack\\redovisning-demo.html" + } + ] +} \ No newline at end of file diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index e3bdf47c..c3b16c71 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,20 +1,35 @@ import jwt from "jsonwebtoken" import { User } from "../models/User.js" +// SÄKERHET [Krav 4]: Middleware som skyddar alla privata routes +// Verifierar att en giltig JWT-token skickats med i Authorization-headern +// Används som grindvakt vid övergången Zon 1 → Zon 2 (frontend → server) export const authenticateUser = async (req, res, next) => { const token = req.headers.authorization?.replace("Bearer ", "") + + // SÄKERHET: Om ingen token finns nekas åtkomst direkt med 401 if (!token) { return res.status(401).json({ success: false, message: "No token provided" }) } + try { + // SÄKERHET: jwt.verify kontrollerar både signatur och utgångstid (expiresIn) + // Om token manipulerats eller gått ut kastas ett fel och användaren nekas åtkomst const decoded = jwt.verify(token, process.env.JWT_SECRET) + const user = await User.findById(decoded.userId) + + // SÄKERHET: Kontrollerar att användaren fortfarande finns i databasen + // Skyddar mot fall där ett konto raderats men en gammal token fortfarande används if (!user) { return res.status(401).json({ success: false, message: "User not found" }) } + + // Lägger användaren på req.user så att routes kan kontrollera ägarskap req.user = user next() } catch (err) { + // SÄKERHET: Alla JWT-fel (utgången token, ogiltig signatur) returnerar 401 res.status(401).json({ success: false, message: "Invalid token" }) } } diff --git a/backend/models/User.js b/backend/models/User.js index 266a60b4..9f177453 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -14,6 +14,9 @@ const userSchema = new mongoose.Schema({ unique: true, lowercase: true, }, + // SÄKERHET [Krav 2]: Lösenordet lagras alltid som bcrypt-hash, aldrig i klartext + // Styrkevalidering sker i server.js innan hashning – schemat lagrar bara resultatet + // Skyddar mot Information Disclosure om databasen skulle läcka (STRIDE: Information Disclosure) password: { type: String, required: true, diff --git a/backend/package-lock.json b/backend/package-lock.json index d8c4b3cd..ef895f16 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-list-endpoints": "^6.0.0", + "express-rate-limit": "^7.5.0", "helmet": "^7.1.0", "jsonwebtoken": "8.5.1", "mongoose": "^8.4.0", @@ -619,6 +620,21 @@ "node": ">=10" } }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1f47ffb6..cfabd7f2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-list-endpoints": "^6.0.0", + "express-rate-limit": "^7.5.0", "helmet": "^7.1.0", "jsonwebtoken": "8.5.1", "mongoose": "^8.4.0", diff --git a/backend/server.js b/backend/server.js index c8d0c218..e9eebef5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,22 +5,40 @@ import express from "express" import mongoose from "mongoose" import bcrypt from "bcrypt" import jwt from "jsonwebtoken" +import rateLimit from "express-rate-limit" // SÄKERHET [Krav 3]: Importerar rate limiting-paketet import { Message } from "./models/Message.js" import { User } from "./models/User.js" import { authenticateUser } from "./middleware/auth.js" import "./config/db.js" import listEndpoints from "express-list-endpoints" +// SÄKERHET: Servern startar inte utan JWT_SECRET – förhindrar att tokensignering sker med undefined-nyckel if (!process.env.JWT_SECRET) throw new Error("JWT_SECRET is not set in .env") const PORT = process.env.PORT || "3000" const app = express() + +// SÄKERHET: Helmet sätter säkra HTTP-headers (t.ex. X-Frame-Options, Content-Security-Policy) +// Skyddar mot vanliga webbattacker som clickjacking och XSS via headers app.use(helmet()) + +// SÄKERHET [Krav 4 – risk]: CORS tillåter alla origins med "*" +// I produktion bör detta begränsas till bara frontenddomänen, t.ex. origin: "https://din-app.se" app.use(cors({ origin: "*", })) app.use(express.json()) +// SÄKERHET [Krav 3]: Rate limiting på inloggning – max 10 försök per 15 minuter per IP +// Skyddar mot brute force-attacker (STRIDE: Spoofing, DoS) vid övergången Zon 0 → Zon 1 +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minuter + max: 10, + message: { success: false, message: "Too many login attempts, please try again later" }, + standardHeaders: true, + legacyHeaders: false, +}) + app.get("/", (req, res) => { res.send(listEndpoints(app)) }) @@ -33,6 +51,17 @@ app.post("/register", async (req, res) => { return res.status(400).json({ success: false, message: "Username must be at least 2 characters" }) } + // SÄKERHET [Krav 2]: Lösenordsvalidering – kontrollerar styrka innan hashning + // Krav: minst 8 tecken, minst en siffra, minst ett specialtecken + // Skyddar mot svaga lösenord som är enkla att gissa (STRIDE: Spoofing) + const passwordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*])[A-Za-z0-9!@#$%^&*]{8,}$/ + if (!password || !passwordRegex.test(password)) { + return res.status(400).json({ + success: false, + message: "Password must be at least 8 characters and contain at least one number and one special character (!@#$%^&*)", + }) + } + const existingUser = await User.findOne({ $or: [{ email: email.toLowerCase() }, { username: username.trim() }] }) @@ -45,14 +74,18 @@ app.post("/register", async (req, res) => { }) } + // SÄKERHET [Krav 2]: bcrypt med kostnadsfaktor 10 – lösenordet lagras aldrig i klartext + // Även om databasen läcker kan lösenorden inte läsas direkt (STRIDE: Information Disclosure) const hashedPassword = await bcrypt.hash(password, 10) const user = new User({ username: username.trim(), email, password: hashedPassword }) await user.save() + // SÄKERHET [Krav 1]: Token giltig i 30 minuter – användaren loggas ut automatiskt efter det + // Minskar risken om någon lämnar datorn obevakad (STRIDE: Spoofing, Elevation of Privilege) const accessToken = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: "2h" } + { expiresIn: "30m" } ) res.status(201).json({ @@ -73,7 +106,8 @@ app.post("/register", async (req, res) => { } }) -app.post("/login", async (req, res) => { +// SÄKERHET [Krav 3]: loginLimiter appliceras här – begränsar inloggningsförsök per IP +app.post("/login", loginLimiter, async (req, res) => { try { const { login, password } = req.body const user = await User.findOne({ @@ -97,10 +131,11 @@ app.post("/login", async (req, res) => { }) } + // SÄKERHET [Krav 1]: Token giltig i 30 minuter – uppfyller kravet om automatisk utloggning const accessToken = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: "2h" } + { expiresIn: "30m" } ) res.json({ @@ -123,8 +158,14 @@ app.post("/login", async (req, res) => { const isValidId = (id) => mongoose.Types.ObjectId.isValid(id) +// SÄKERHET [Krav 4 – notering]: GET /messages kräver ingen inloggning +// Alla kan läsa meddelanden – om appen ska vara privat bör authenticateUser läggas till här app.get("/messages", async (req, res) => { try { + // 🔐 SÄKERHETSKRAV 4: + // Denna route saknar authenticateUser → alla kan läsa alla meddelanden. + // Kravet säger att användaren måste vara inloggad för att göra något. + // I fas 3 bör authenticateUser läggas här. const messages = await Message.find() .sort({ createdAt: "desc" }) .limit(20) @@ -136,6 +177,8 @@ app.get("/messages", async (req, res) => { } }) +// SÄKERHET [Krav 4]: authenticateUser krävs – bara inloggade användare kan skapa meddelanden +// Skyddar Zon 1 → Zon 2 (STRIDE: Tampering, Elevation of Privilege) app.post("/messages", authenticateUser, async (req, res) => { const message = new Message({ message: req.body.message, user: req.user._id }) try { @@ -146,6 +189,8 @@ app.post("/messages", authenticateUser, async (req, res) => { } }) +// SÄKERHET [Krav 4]: authenticateUser + ägarskontroll – användaren kan bara redigera sina egna meddelanden +// message.user.toString() jämförs med req.user._id för att verifiera ägarskap (STRIDE: Tampering) app.patch("/messages/:id", authenticateUser, async (req, res) => { if (!isValidId(req.params.id)) return res.status(400).json({ error: "Invalid message ID" }) try { @@ -165,11 +210,26 @@ app.patch("/messages/:id", authenticateUser, async (req, res) => { } }) -app.delete("/messages/:id", async (req, res) => { +// SÄKERHET [Krav 4 – fix]: authenticateUser och ägarskontroll tillagda +// Originalfilen saknade helt auth här – vem som helst kunde radera vilket meddelande som helst +// utan att ens vara inloggad. Det är ett kritiskt säkerhetshål (STRIDE: Tampering, +// Elevation of Privilege, Zon 1 → Zon 2). +// +// Två separata säkerhetslager: +// 1. Autentisering (authenticateUser): Är du inloggad? – uppfyller Krav 4 +// 2. Auktorisering (ägarskontroll): Har du rätt att radera just det här meddelandet? +// Konsekvent med hur PATCH redan fungerade i originalfilen. +app.delete("/messages/:id", authenticateUser, async (req, res) => { if (!isValidId(req.params.id)) return res.status(400).json({ error: "Invalid message ID" }) try { const message = await Message.findById(req.params.id) if (!message) return res.status(404).json({ error: "Message not found" }) + + // SÄKERHET [Krav 4]: Ägarskontroll – användaren kan bara radera sina egna meddelanden + if (message.user.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "You can only delete your own messages" }) + } + await message.deleteOne() res.status(204).send() } catch (error) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c668778f..5189a068 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } from "react" import { BASE_URL } from "./api" import { PostMessage } from "./components/PostMessage" import { MessageList } from "./components/MessageList" import { AuthModal } from "./components/AuthModal" +const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000 +const ACTIVITY_EVENTS = ["click", "keydown", "scroll", "mousemove", "touchstart"] + export const App = () => { const [loading, setLoading] = useState(false) const [messageList, setMessageList] = useState([]) @@ -23,14 +26,43 @@ export const App = () => { fetchPosts() }, []) - const addNewPost = (newMessage) => { + const addNewPost = (newMessage) => { setMessageList([newMessage, ...messageList]) } - const handleUnauthorized = () => { + const logout = useCallback((message = null) => { setUser(null) - setError("Your session has expired, please log in again") - } + setError(message) + }, []) + + const handleUnauthorized = useCallback(() => { + logout("Your session has expired, please log in again") + }, [logout]) + + useEffect(() => { + if (!user) return undefined + + let inactivityTimer + + const resetInactivityTimer = () => { + clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => { + logout("You were logged out after 30 minutes of inactivity") + }, INACTIVITY_TIMEOUT_MS) + } + + resetInactivityTimer() + ACTIVITY_EVENTS.forEach((eventName) => { + window.addEventListener(eventName, resetInactivityTimer, { passive: true }) + }) + + return () => { + clearTimeout(inactivityTimer) + ACTIVITY_EVENTS.forEach((eventName) => { + window.removeEventListener(eventName, resetInactivityTimer) + }) + } + }, [user, logout]) return ( <> @@ -38,7 +70,7 @@ export const App = () => {