diff --git a/.gitignore b/.gitignore index 0fc8187..bade184 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ .DS_Store node_modules /dist - package-lock.json +server/.nyc_output/*.json +server/.nyc_output/processinfo/*.json # local env files .env.local diff --git a/server/.nyc_output/processinfo/index.json b/server/.nyc_output/processinfo/index.json new file mode 100644 index 0000000..83b4e73 --- /dev/null +++ b/server/.nyc_output/processinfo/index.json @@ -0,0 +1 @@ +{"processes":{"ecd4285b-d9dc-4104-b850-fcf81a763c89":{"parent":null,"children":[]}},"files":{"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\app.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\database\\index.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\config\\db.config.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\models\\user.model.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\routes\\auth.routes.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\controllers\\auth.controller.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\business\\user.business.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"],"C:\\Users\\Jarrod\\Documents\\AAF\\CSSD\\server\\datalayer\\mongo.js":["ecd4285b-d9dc-4104-b850-fcf81a763c89"]},"externalIds":{}} \ No newline at end of file diff --git a/server/app.js b/server/app.js index c74e716..a8ef318 100644 --- a/server/app.js +++ b/server/app.js @@ -2,17 +2,29 @@ const express = require("express"); const path = require("path"); const bodyParser = require("body-parser"); const cors = require("cors"); +const cookieSession = require("cookie-session"); +const cookieParser = require("cookie-parser"); require("./database"); require("dotenv").config(); var app = express(); +app.use( + cookieSession({ + name: "highwayTracker-token", + secret: process.env.TOKEN_SECRET, + httpOnly: true, + keys: [process.env.TOKEN_SECRET], + }) +); + app.use(cors({ origin: "http://localhost:8080", credentials: true })); /** * Router setup */ +var authRouter = require("./routes/auth.routes"); /** * View Engine setup @@ -24,7 +36,9 @@ app.use(express.urlencoded({ extended: false })); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, "public"))); +app.use(cookieParser()); // Configuring the main routes +app.use("/auth", authRouter); module.exports = app; diff --git a/server/business/user.business.js b/server/business/user.business.js new file mode 100644 index 0000000..7111eaa --- /dev/null +++ b/server/business/user.business.js @@ -0,0 +1,118 @@ +const DataLayer = require("../datalayer/mongo"); +const model = require("../database").getModel("user"); +const httpError = require("http-errors"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcryptjs"); + +class UserBusiness { + constructor() { + // Create an instance of the data layer. + this.dataLayer = new DataLayer(model); + } + + /** + * Login a user. + */ + async login(email, password) { + return this.findUserByEmail(email) + .then((user) => { + const passwordIsValid = bcrypt.compareSync( + password, + user.password + ); + // Invalid password, return 401 + if (!passwordIsValid) { + throw httpError( + 401, + "Your email or password is incorrect." + ); + } + // Create token and store in the session cookie + const token = jwt.sign( + { + id: user._id, + type: user.type, + email: user.email, + username: user.username, + }, + process.env.TOKEN_SECRET, + { + expiresIn: 3600, // 1 hour + } + ); + return { + token: token, + username: user.username, + type: user.type, + id: user._id, + }; + }) + .catch(() => { + throw httpError(400, "Your email or password is incorrect."); + }); + } + + /** + * Register a user. + */ + async register(user) { + return this.createUser({ + username: user.username, + email: user.email, + password: user.password, + type: "Driver", + }).catch((error) => { + throw httpError(400, error.message); + }); + } + + /** + * Create a user and save it to the User collection. + */ + async createUser(userToCreate) { + if (!isUserDataValid(userToCreate)) { + throw httpError(400, "User data is invalid."); + } + const user = { + username: userToCreate.username, + email: userToCreate.email, + type: userToCreate.type, + password: bcrypt.hashSync(userToCreate.password, 8), + }; + + return this.dataLayer.create(user).catch((error) => { + if (error.message.includes("username")) + throw httpError(400, "Username is already in use."); + if (error.message.includes("email")) + throw httpError(400, "Email is already in use."); + throw httpError(404, error.message); + }); + } + + /** + * Find a user by email + */ + async findUserByEmail(email) { + return this.dataLayer + .findByProperty({ email: email }) + .then((users) => { + // Email is unique so only 1 can be returned. + return users[0]; + }) + .catch((error) => { + throw httpError(404, error.message); + }); + } +} +module.exports = UserBusiness; + +/** + * Validates the data in a User. + */ +function isUserDataValid(user) { + if (!user || !user.username || !user.email || !user.password) { + return false; + } else { + return true; + } +} diff --git a/server/config/db.config.js b/server/config/db.config.js index 2520892..bddb744 100644 --- a/server/config/db.config.js +++ b/server/config/db.config.js @@ -1,3 +1,8 @@ module.exports = { - url: "mongodb://localhost:27017/highwaytrackerdb", + dev: { + url: "mongodb://localhost:27017/highwaytrackerdb", + }, + test: { + url: "mongodb://localhost:27017/highwaytrackerdb_testing", + }, }; diff --git a/server/controllers/auth.controller.js b/server/controllers/auth.controller.js new file mode 100644 index 0000000..2a5b487 --- /dev/null +++ b/server/controllers/auth.controller.js @@ -0,0 +1,52 @@ +const UserBusiness = require("../business/user.business"); +const userBusiness = new UserBusiness(); + +/** + * Login the user + */ +exports.login = async (req, res) => { + userBusiness + .login(req.body.email, req.body.password) + .then((data) => { + req.session.token = data.token; + req.session.username = data.username; + req.session.role = data.role; + req.session.id = data.id; + + res.status(200).send({ + message: "Successfully logged in.", + username: data.username, + role: data.role, + id: data.id, + }); + }) + .catch((error) => { + res.status(error.status).send({ message: error.message }); + }); +}; + +/** + * Register the user + */ +exports.register = (req, res) => { + userBusiness + .register(req.body) + .then(() => { + res.status(201).send({ + message: "User was successfully created.", + }); + }) + .catch((error) => { + res.status(error.status).send({ message: error.message }); + }); +}; + +/** + * Logs the user out + */ +exports.logout = (req, res) => { + req.session = null; + res.status(200).send({ + message: "User was successfully logged out.", + }); +}; diff --git a/server/database/index.js b/server/database/index.js index 9867b72..6f5e705 100644 --- a/server/database/index.js +++ b/server/database/index.js @@ -1,14 +1,16 @@ // Get database config -const environment = process.env.NODE_ENV; const mongoose = require("mongoose"); mongoose.Promise = global.Promise; -const dbConfig = require("../config/db.config.js"); -const journey = require("../models/journey")(mongoose) -const bill = require("../models/bill")(mongoose) -const location = require("../models/location")(mongoose) +const environment = process.env.NODE_ENV; +const dbConfig = require("../config/db.config.js")[environment]; + +const journey = require("../models/journey")(mongoose); +const bill = require("../models/bill")(mongoose); +const location = require("../models/location")(mongoose); +const user = require("../models/user.model.js")(mongoose); // Create mongoose and read in config -const db = {journey: journey, bill: bill, location: location}; +const db = { journey: journey, bill: bill, location: location, user: user }; db.mongoose = mongoose; db.url = dbConfig.url; diff --git a/server/database/seed.js b/server/database/seed.js new file mode 100644 index 0000000..200149a --- /dev/null +++ b/server/database/seed.js @@ -0,0 +1,39 @@ +const mongoose = require("mongoose"); +mongoose.users = require("../models/user.model")(mongoose); +const bcrypt = require("bcryptjs"); + +mongoose + .connect("mongodb://localhost:27017/highwaytrackerdb_testing", { + useNewUrlParser: true, + useUnifiedTopology: true, + }) + .then(() => { + console.log("Connection to database successful."); + }) + .catch(() => { + console.log("Connection to database unsuccessful."); + }); + +const users = [ + { + username: "test_username", + email: "test@email.com", + password: bcrypt.hashSync("admin", 8), + type: "Driver", + }, +]; +const seedDB = async () => { + await mongoose.users.deleteMany(); + await mongoose.users.insertMany(users); +}; + +seedDB() + .then(() => { + console.log("Successfully seeded database."); + }) + .catch((error) => { + console.log("An error occurred while seeding databases: ", error); + }) + .finally(() => { + mongoose.connection.close(); + }); diff --git a/server/datalayer/mongo.js b/server/datalayer/mongo.js new file mode 100644 index 0000000..693ca84 --- /dev/null +++ b/server/datalayer/mongo.js @@ -0,0 +1,22 @@ +class DataLayer { + constructor(model) { + // Set the collections model to use. + this.model = model; + } + + /** + * Create and save the record to the database. + */ + async create(recordToCreate) { + return this.model.create(recordToCreate); + } + + /** + * Find a record by property in the database. + */ + async findByProperty(propertyToFind) { + return this.model.find(propertyToFind); + } +} + +module.exports = DataLayer; diff --git a/server/middleware/auth/authJwt.js b/server/middleware/auth/authJwt.js new file mode 100644 index 0000000..45f9f37 --- /dev/null +++ b/server/middleware/auth/authJwt.js @@ -0,0 +1,42 @@ +const jwt = require("jsonwebtoken"); + +// Check if token is valid +checkJwtToken = (req, res, next) => { + if (!req.session || !req.session.token) { + return res.status(401).send({ + message: "Unauthorized: No token provided.", + }); + } + const token = req.session.token; + + jwt.verify(token, process.env.TOKEN_SECRET, (err, decoded) => { + if (err) { + return res.status(401).send({ + message: "Unauthorized: Invalid token.", + }); + } + + req.userId = decoded.id; + req.username = decoded.username; + req.email = decoded.email; + req.type = decoded.type; + return next(); + }); +}; + +isOperator = (req, res, next) => { + const type = req.type; + + if (type != "Toll Operator") { + return res.status(403).send({ + message: + "Unauthorized: You not do have permission to view this page.", + }); + } + return next(); +}; + +module.exports = { + checkJwtToken, + isOperator, +}; diff --git a/server/models/user.model.js b/server/models/user.model.js new file mode 100644 index 0000000..4f0b158 --- /dev/null +++ b/server/models/user.model.js @@ -0,0 +1,31 @@ +// Model for the User +module.exports = (mongoose) => { + var UserSchema = mongoose.Schema({ + username: { + type: String, + required: [true, "You must supply the user's username."], + minlength: [5, "Your username must be at least 5 letters."], + unique: true, + }, + email: { + type: String, + required: [true, "You must supply the user's email."], + unique: true, + }, + password: { + type: String, + required: [true, "You must supply the user's password"], + minlength: [5, "Your password must be at least 8 letters."], + }, + type: { + type: String, + required: [true, "You must supply the user's role."], + enum: { + values: ["Driver", "Toll Operator"], + message: "Type is not valid. Must be 'Driver'.", + }, + }, + }); + + return mongoose.model("user", UserSchema); +}; diff --git a/server/package.json b/server/package.json index f73f5af..8a85949 100644 --- a/server/package.json +++ b/server/package.json @@ -1,18 +1,27 @@ { "scripts": { - "start": "set NODE_ENV=dev&& node ./bin/www" + "start": "set NODE_ENV=dev&& node ./bin/www", + "test": "set NODE_ENV=test&& node ./database/seed.js && nyc mocha --recursive --timeout 5000 --exit" }, "dependencies": { + "bcryptjs": "^2.4.3", "body-parser": "^1.19.1", + "chai": "^4.3.6", + "chai-http": "^4.3.0", + "cookie-parser": "^1.4.6", + "cookie-session": "^2.0.0", "cors": "^2.8.5", "dotenv": "^15.0.0", "eslint": "^8.8.0", "express": "^4.17.2", + "http-errors": "^2.0.0", "haversine-distance": "^1.2.1", "jshint": "^2.13.4", + "jsonwebtoken": "^8.5.1", "mocha": "^9.2.0", "mongodb": "^4.3.1", "mongoose": "^6.1.9", - "nodemon": "^2.0.15" + "nodemon": "^2.0.15", + "nyc": "^15.1.0" } } diff --git a/server/routes/auth.routes.js b/server/routes/auth.routes.js new file mode 100644 index 0000000..a664b36 --- /dev/null +++ b/server/routes/auth.routes.js @@ -0,0 +1,16 @@ +var express = require("express"); +var router = express.Router(); + +// Get the Auth controller +var authController = require("../controllers/auth.controller"); + +// Log the user in +router.post("/login/", authController.login); + +// Register the user +router.post("/register/", authController.register); + +// Log the user out +router.post("/logout/", authController.logout); + +module.exports = router; diff --git a/server/test/integration/auth.controller.test.js b/server/test/integration/auth.controller.test.js new file mode 100644 index 0000000..1369ef4 --- /dev/null +++ b/server/test/integration/auth.controller.test.js @@ -0,0 +1,125 @@ +let chai = require("chai"); +let chaiHttp = require("chai-http"); +let server = require("../../app"); +let should = chai.should(); +chai.use(chaiHttp); + +describe("Testing /auth paths", () => { + it("user should be able to register", (done) => { + // Arrange + const request = { + email: "test@test.com", + password: "test1", + username: "arealusername", + }; + + // Act + chai.request(server) + .post("/auth/register") + .send(request) + .end((err, res) => { + // Assert + res.should.have.status(201); + res.should.be.a("object"); + res.body.should.have.property("message"); + res.body.message.should.be.eql( + "User was successfully created." + ); + + done(); + }); + }); + + it("user should be able to login", (done) => { + // Arrange + const request = { + email: "test@test.com", + password: "test1", + }; + + // Act + chai.request(server) + .post("/auth/login") + .send(request) + .end((err, res) => { + // Assert + res.should.have.status(200); + res.should.be.a("object"); + res.body.should.have.property("message"); + res.body.message.should.be.eql("Successfully logged in."); + res.should.have.cookie("highwayTracker-token"); + + done(); + }); + }); + + it("user shouldn't be able to login with invalid credentials", (done) => { + // Act + const request = { + email: "test@test.com", + password: "test2", + }; + + // Arrange + chai.request(server) + .post("/auth/login") + .send(request) + .end((err, res) => { + // Assert + res.should.have.status(400); + res.should.be.a("object"); + res.body.should.have.property("message"); + res.body.message.should.be.eql( + "Your email or password is incorrect." + ); + + done(); + }); + }); + + it("user shouldn't be able to register with duplicate username", (done) => { + // Act + const request = { + email: "test@realemail.com", + password: "test1", + username: "test_username", + }; + + // Arrange + chai.request(server) + .post("/auth/register") + .send(request) + .end((err, res) => { + // Assert + res.should.have.status(400); + res.should.be.a("object"); + res.body.should.have.property("message"); + res.body.message.should.be.eql("Username is already in use."); + + done(); + }); + }); + + it("user shouldn't be able to register with duplicate email", (done) => { + // Act + const request = { + email: "test@email.com", + password: "test1", + username: "a username", + }; + + // Arrange + chai.request(server) + .post("/auth/register") + .send(request) + .end((err, res) => { + // Assert + res.should.have.status(400); + res.should.be.a("object"); + res.body.should.have.property("message"); + res.body.message.should.be.eql("Email is already in use."); + + done(); + }); + }); +});