From 655ec610fcce8dd7748f10772d520bdff4f7c78e Mon Sep 17 00:00:00 2001 From: Arne Rief Date: Fri, 19 Dec 2025 20:03:03 +0100 Subject: Basic setup & login --- backend/src/controllers/generateAdmin.ts | 48 ++++++++++++++++++ backend/src/controllers/loginUser.ts | 65 ++++++++++++++++++++++++ backend/src/database/postgres.ts | 34 +++++++++++++ backend/src/database/redis.ts | 49 ++++++++++++++++++ backend/src/middleware/authCheck.ts | 36 +++++++++++++ backend/src/routes/router.ts | 12 +++++ backend/src/server.ts | 86 ++++++++++++++++++++++++++++++++ backend/src/types/database.ts | 7 +++ backend/src/types/express.d.ts | 11 ++++ backend/src/types/request.ts | 4 ++ backend/src/types/user.ts | 18 +++++++ backend/src/utils/dbErrorCheck.ts | 16 ++++++ 12 files changed, 386 insertions(+) create mode 100644 backend/src/controllers/generateAdmin.ts create mode 100644 backend/src/controllers/loginUser.ts create mode 100644 backend/src/database/postgres.ts create mode 100644 backend/src/database/redis.ts create mode 100644 backend/src/middleware/authCheck.ts create mode 100644 backend/src/routes/router.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/types/database.ts create mode 100644 backend/src/types/express.d.ts create mode 100644 backend/src/types/request.ts create mode 100644 backend/src/types/user.ts create mode 100644 backend/src/utils/dbErrorCheck.ts (limited to 'backend/src') diff --git a/backend/src/controllers/generateAdmin.ts b/backend/src/controllers/generateAdmin.ts new file mode 100644 index 0000000..045fd2d --- /dev/null +++ b/backend/src/controllers/generateAdmin.ts @@ -0,0 +1,48 @@ +import bcrypt from "bcrypt"; +import { Request, Response } from "express"; +import { QueryResult } from "pg"; +import db from "../database/postgres.js"; +import type { AdminCreationResult } from "../types/user.js"; +import { isPostgresError, PostgresErrorCodes } from "../utils/dbErrorCheck.js"; + +/* + One-time function to generate an admin user with specific email & password in the DB + Reason: hash the password with bcrypt for future authentication +*/ +async function generateAdmin(_req: Request, res: Response) { + const adminMail = "admin@test.com"; + const adminPass = "test123"; + + try { + const hashedPassword = await bcrypt.hash(adminPass, 10); + + const adminCreation: QueryResult = await db.query( + "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, created_at;", + [adminMail, hashedPassword] + ); + + return res.status(201).json({ + message: "The admin was created successfully.", + admin: adminCreation.rows[0], + }); + } catch (error) { + if ( + isPostgresError(error) && + error.code === PostgresErrorCodes.UNIQUE_VIOLATION + ) { + console.error("Error creating the admin: ", error); + return res.status(409).json({ + message: "Admin already exists.", + error: error.message, + }); + } + + console.error("Error creating admin: ", error); + return res.status(500).json({ + message: "Internal server error for admin creation.", + error, + }); + } +} + +export default generateAdmin; diff --git a/backend/src/controllers/loginUser.ts b/backend/src/controllers/loginUser.ts new file mode 100644 index 0000000..860bce2 --- /dev/null +++ b/backend/src/controllers/loginUser.ts @@ -0,0 +1,65 @@ +import bcrypt from "bcrypt"; +import { Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { QueryResult } from "pg"; +import db from "../database/postgres.js"; +import type { LoginRequest } from "../types/request.js"; +import type { AuthorizedUser, DatabaseUser } from "../types/user.js"; + +async function loginUser(req: Request, res: Response) { + const { email, password } = req.body as LoginRequest; + + if (!email || !password) { + return res.status(400).json({ + message: "E-Mail und Passwort sind erforderlich.", + }); + } + + try { + // Get data for user with login email address + const queryResult: QueryResult = await db.query( + "SELECT id, email, password_hash, created_at FROM users WHERE email = $1;", + [email] + ); + + const user = queryResult.rows[0]; + + if (!user) { + return res.status(401).json({ message: "Login Daten ungültig." }); + } + + // Check if password is correct + const isValidPassword = await bcrypt.compare( + password, + user.password_hash + ); + + if (!isValidPassword) { + return res.status(401).json({ + message: "Das Passwort ist nicht korrekt.", + }); + } + + const userData: AuthorizedUser = { + id: user.id, + email: user.email, + createdAt: user.created_at, + }; + + // Create token for authentication + const token = jwt.sign(userData, process.env.JWT_SECRET!); + + return res.status(200).json({ + message: "Erfolgreiche Anmeldung.", + user: userData, + token, + }); + } catch (error) { + console.error("Fehler beim Login: ", error); + return res + .status(500) + .json({ message: "Interner Serverfehler beim Login.", error }); + } +} + +export default loginUser; diff --git a/backend/src/database/postgres.ts b/backend/src/database/postgres.ts new file mode 100644 index 0000000..6f6b682 --- /dev/null +++ b/backend/src/database/postgres.ts @@ -0,0 +1,34 @@ +import { Pool } from "pg"; + +const pool = new Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + host: process.env.DB_HOST, + port: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 5432, +}); + +/* Too strict for Docker: +pool.connect((error, _client, release) => { + if (error) { + console.error("Verbindung zur Datenbank fehlgeschlagen: ", error); + process.exit(1); + } else { + console.log("Erfolgreich mit der Datenbank verbunden."); + release(); + } +}); + */ + +console.log("Erfolgreich mit der Datenbank verbunden."); + +export async function closeDBConnection() { + try { + await pool.end(); + console.log("PostgreSQL-Verbindung erfolgreich beendet."); + } catch (error) { + console.error("Fehler beim Beenden der PostgreSQL-Verbindung: ", error); + } +} + +export default pool; diff --git a/backend/src/database/redis.ts b/backend/src/database/redis.ts new file mode 100644 index 0000000..06773b9 --- /dev/null +++ b/backend/src/database/redis.ts @@ -0,0 +1,49 @@ +import { createClient } from "redis"; + +const redisClient = createClient({ + username: process.env.REDIS_USER, + password: process.env.REDIS_PASSWORD, + socket: { + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 13080, + reconnectStrategy: (retries) => { + if (retries > 10) { + console.error("Redis Verbindunsversuche erschöpft."); + return new Error( + "Redis Verbindung nach 10 Versuchen Fehlgeschlagen." + ); + } + console.log(`Redis Verbindungsversuch ${retries} von 10`); + return 3000; + }, + }, +}); + +redisClient.on("error", (error) => { + console.error("Redis Fehler: ", error); +}); + +async function connectRedis() { + try { + await redisClient.connect(); + console.log("Erfolgreich mit Redis verbunden."); + } catch (error) { + // Server läuft ohne Cache weiter + console.error("Verbindung zu Redis fehlgeschlagen: ", error); + } +} + +await connectRedis(); + +export async function closeRedisConnection() { + try { + if (redisClient.isOpen) { + await redisClient.quit(); + console.log("Redis-Verbindung erfolgreich beendet."); + } + } catch (error) { + console.error("Fehler beim Beenden der Redis-Verbindung: ", error); + } +} + +export default redisClient; diff --git a/backend/src/middleware/authCheck.ts b/backend/src/middleware/authCheck.ts new file mode 100644 index 0000000..4ee0806 --- /dev/null +++ b/backend/src/middleware/authCheck.ts @@ -0,0 +1,36 @@ +import { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import type { AuthorizedUser } from "../types/user.js"; + +async function authenticateUser( + req: Request, + res: Response, + next: NextFunction +) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ + message: "User authentication failed.", + }); + } + + const token = authHeader.split(" ")[1]; + + try { + const authorizedUser = jwt.verify( + token, + process.env.JWT_SECRET! + ) as AuthorizedUser; + + req.user = authorizedUser; + next(); + } catch (error) { + console.error("User authentication failed: ", error); + return res.status(403).json({ + message: "User authentication failed.", + }); + } +} + +export default authenticateUser; diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts new file mode 100644 index 0000000..325b08a --- /dev/null +++ b/backend/src/routes/router.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import generateAdmin from "../controllers/generateAdmin.js"; +import loginUser from "../controllers/loginUser.js"; + +const router = Router(); + +// One-time generation of admin user with pre-defined email & password +router.get("/admin-generation", generateAdmin); +// Login for registered users +router.post("/auth/login", loginUser); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..d2df403 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,86 @@ +import cors from "cors"; +import express from "express"; +import http from "http"; +import { Server } from "socket.io"; +import { closeDBConnection } from "./database/postgres.js"; +import { closeRedisConnection } from "./database/redis.js"; +import router from "./routes/router.js"; + +const port = process.env.PORT ? parseInt(process.env.PORT) : 3000; + +const app = express(); +const httpServer = http.createServer(app); // for Websocket connection + +const io = new Server(httpServer, { + cors: { + origin: "*", + methods: ["GET", "POST"], + }, +}); + +// Global access to Socket.IO server for all controllers +app.set("io", io); + +// Websocket connection handler +io.on("connection", (socket) => { + console.log(`New Client connected: ${socket.id}`); + socket.on("disconnect", () => { + console.log(`Client disconnected: ${socket.id}`); + }); +}); + +app.use(cors()); +app.use(express.json()); + +app.use(router); + +let isShuttingDown = false; +async function shutdown(exitCode: number) { + // Prevent function from being called repeatedly e.g. from nodemon or node --watch + if (isShuttingDown) return; + + isShuttingDown = true; + console.log("[SHUTDOWN] Server shutdown initiated..."); + + // 1. Closing HTTP/WebSocket-Server + httpServer.close(async (error) => { + if (error) { + console.error("Error closing the HTTP server:", error); + } else { + console.log("Closed HTTP- and WebSocket server."); + } + + // Closing connections to database & Redis + await closeDBConnection(); + await closeRedisConnection(); + + // Closing the process + console.log("[SHUTDOWN] Process closed."); + process.exit(exitCode); + }); + + // Timeout as final fallback if server is unresponsive for 10 seconds + setTimeout(() => { + console.error("[SHUTDOWN] Timeout! Forcing shutdown."); + process.exit(1); + }, 10000); +} + +// Listeners for shutdown signals +process.on("SIGINT", () => shutdown(0)); // Ctrl+C +process.on("SIGTERM", () => shutdown(0)); // Kubernetes, Docker, etc. + +httpServer.on("error", (error: NodeJS.ErrnoException) => { + console.error("Failed to connect to the server...", error); + if (error.code === "EADDRINUSE") { + console.error(`Port ${port} is already in use.`); + } + shutdown(1); +}); + +httpServer.listen(port, () => { + console.log( + `The Robot-Tracker API is activ and listening on port ${port}.` + ); + console.log("The WebSocket server is active."); +}); diff --git a/backend/src/types/database.ts b/backend/src/types/database.ts new file mode 100644 index 0000000..fff88a1 --- /dev/null +++ b/backend/src/types/database.ts @@ -0,0 +1,7 @@ +export interface PostgresError extends Error { + code: string; + detail?: string; + schema?: string; + table?: string; + constraint?: string; +} diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..7ac6ca0 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,11 @@ +import type { AuthorizedUser } from "./user.js"; + +declare global { + namespace Express { + interface Request { + user?: AuthorizedUser; + } + } +} + +export {}; diff --git a/backend/src/types/request.ts b/backend/src/types/request.ts new file mode 100644 index 0000000..ef80738 --- /dev/null +++ b/backend/src/types/request.ts @@ -0,0 +1,4 @@ +export type LoginRequest = { + email: string; + password: string; +}; diff --git a/backend/src/types/user.ts b/backend/src/types/user.ts new file mode 100644 index 0000000..875072c --- /dev/null +++ b/backend/src/types/user.ts @@ -0,0 +1,18 @@ +export type AdminCreationResult = { + id: string; + email: string; + created_at: Date; +}; + +export type AuthorizedUser = { + id: string; + email: string; + createdAt: Date; +}; + +export type DatabaseUser = { + id: string; + email: string; + password_hash: string; + created_at: Date; +}; diff --git a/backend/src/utils/dbErrorCheck.ts b/backend/src/utils/dbErrorCheck.ts new file mode 100644 index 0000000..c5c6415 --- /dev/null +++ b/backend/src/utils/dbErrorCheck.ts @@ -0,0 +1,16 @@ +import type { PostgresError } from "../types/database.js"; + +export function isPostgresError(error: unknown): error is PostgresError { + return ( + error instanceof Error && + "code" in error && + typeof (error as PostgresError).code === "string" + ); +} + +export const PostgresErrorCodes = { + UNIQUE_VIOLATION: "23505", + FOREIGN_KEY_VIOLATION: "23503", + NOT_NULL_VIOLATION: "23502", + CHECK_VIOLATION: "23514", +}; -- cgit v1.2.3