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 = () => {
{user.response.username} + +
+ +
+
+
+ Säkerhetskurs · Fas 2 + YH Message App Security Demo +
+
+ + 1 / 7 + + Till praktisk demo +
+
+ +
+
+ Slide 1 +

Säkerhetskurs Fas 2

+

Kodförståelse och säkerhetsgranskning av YH Message App.

+

Express + MongoDB + React · JWT Auth

+

Mario · Rasmus · Sally

+
+ +
+ Slide 2 +

Vad vi granskade

+
+
backend/server.jsAPI-routes för register, login och meddelanden.
+
backend/middleware/auth.jsJWT-verifiering som skyddar privata routes.
+
backend/models/User.jsAnvändarschema där lösenord lagras som bcrypt-hash.
+
+

Fokus: backend = Zon 2 i ESTRID, där logik och säkerhetsbeslut sker.

+
+ +
+ Slide 3 +

Krav 1 · Automatisk utloggning

+
+
FÖRE
+expiresIn: "2h"
+
EFTER
+expiresIn: "30m"
+
+

JWT har fast giltighetstid. Riktig inaktivitetsutloggning kräver även klient- eller sessionslogik.

+

STRIDE: Spoofing · Elevation of Privilege

+
+ +
+ Slide 4 +

Krav 2 · Lösenordsvalidering

+

Bcrypt fanns, men lösenordet validerades inte innan hashning.

+
passwordRegex =
+/^(?=.*[0-9])(?=.*[!@#$%^&*])
+[A-Za-z0-9!@#$%^&*]{8,}$/
+

Validering måste ske innan hashning. Annars kan svaga lösenord sparas säkert men vara enkla att gissa.

+
+ +
+ Slide 5 +

Krav 3 · Rate limiting

+

Utan rate limiting kan en angripare prova många lösenord snabbt.

+
const loginLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000,
+  max: 10
+})
+
+app.post("/login", loginLimiter, async (...))
+

I live-demon är gränsen 5 försök för att funktionen ska gå snabbt att visa.

+
+ +
+ Slide 6 +

Kritiskt säkerhetshål

+

DELETE saknade autentisering i originalet. Vem som helst kunde radera meddelanden.

+
app.delete("/messages/:id",
+  authenticateUser,
+  async (req, res) => {
+    // ägarskontroll
+  }
+)
+

STRIDE: Tampering · Elevation of Privilege

+
+ +
+ Slide 7 +

Sammanfattning

+
+
+ Krav 1 · Utloggning + Original: token 2h + Fix: token 30 min + demo för inaktivitet +
+
+ Krav 2 · Lösenord + Original: bcrypt men ingen styrka + Fix: bcrypt + regex-validering +
+
+ Krav 3 · Rate limiting + Original: saknades + Fix: express-rate-limit på /login +
+
+ Krav 4 · Auth + Original: DELETE saknade auth + Fix: auth + ägarskontroll +
+
+

Viktigaste fynd: DELETE utan auth = Broken Access Control.

+
+
+ +
+
+ +
+
+
+

Praktisk demo

+

Här kan ni manuellt visa varje säkerhetskrav utan att behöva starta backend eller frontend.

+
+
+ +
+ + +
+
+ Manuell funktion 1 +

Lösenordsvalidering

+ +
+ + +
+
+
Minst 8 tecken
+
Minst en siffra
+
Minst ett specialtecken
+
+
Testa lösenordet ovan.
+
+ +
+ Manuell funktion 2 +

Login med rate limiting

+

Ange lösenordet Solen123!. Fem felaktiga försök låser sidan.

+ +
+ + + +
+ +
5 försök kvar.
+
+ +
+ Manuell funktion 3 +

Auth och ägarskap

+
+ + + + +
+
+ Ägare: Mario +

Det här meddelandet får bara Mario ändra eller radera.

+
+ + + +
+
+
Du är inloggad som Mario.
+
+ +
+ Manuell funktion 4 +

Automatisk utloggning

+

Redovisningsläge: 30 minuter visas som 30 sekunder så funktionen kan visas live.

+
+ + + +
+ +
Starta timern för att visa auto logout.
+
+
+
+
+ + + +