revised to use httpOnly cookie to store jwt

comments can no longer be updated or deleted - out of scope
users can now be created, updated and deleted
This commit is contained in:
ak 2023-09-27 16:50:58 -07:00
parent 7ea24df56b
commit e69fe69d90
12 changed files with 333 additions and 308 deletions

View file

@ -1,5 +1,5 @@
# express-blog-api
RESTful blog API implemented in Express. Has POST/GET/PUT/DELETE methods for all posts and comments.
Post Creation, Updates and Deletion require jwt token returned when post completed at /api/login.
Hosted on Deta Space at https://expressblogapi-1-v2871156.deta.app/api/
RESTful blog API implemented in Express. Has POST/GET/PUT/DELETE methods for posts and users. Comments cannot be updated or deleted.
Post Creation, Updates and Deletion require jwt token returned when login completed at /login. User Updates and Deletion require a matching jwt token.
Hosted on Deta Space at https://expressblogapi-1-v2871156.deta.app/

6
app.js
View file

@ -2,9 +2,10 @@ const express = require("express");
const path = require("path");
const cors = require("cors");
const mongoose = require("mongoose");
const cookieParser = require("cookie-parser");
require("dotenv").config();
const apiRouter = require("./routes/api.js");
const router = require("./routes.js");
const app = express();
@ -28,6 +29,7 @@ app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
// parsing
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, "public")));
@ -36,6 +38,6 @@ app.use(express.static(path.join(__dirname, "public")));
app.use(cors());
// routing
app.use("/api", apiRouter);
app.use("/", router);
module.exports = app;

View file

@ -15,12 +15,6 @@ exports.post = [
.trim()
.escape(),
// Validate and sanitize password
body("password", "Please enter password for future comment modification!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after authentication, validation and sanitization
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
@ -40,7 +34,6 @@ exports.post = [
author: req.body.author,
post: req.params.postID,
_id: new mongoose.Types.ObjectId(),
password: req.body.password,
});
// save to DB
@ -60,96 +53,9 @@ exports.get = asyncHandler(async (req, res, next) => {
return res.status(200).json({ comment });
});
// updates comment - U
exports.put = [
// Validate and sanitize text
body("text", "Please enter comment!").isLength({ min: 1 }).trim().escape(),
// no U(pdate)
// comments cannot be edited as they can be made by anyone
// best to prevent impersonation
// Validate and sanitize author name
body("author", "Please enter comment author!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize password
body("password", "Please enter password to modify comment!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after sanitization and validation
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}
const dbComment = await Comment.findOne({ _id: req.params.commentID })
.lean()
.exec();
if (req.body.author === dbComment.author) {
if (req.body.password === dbComment.password) {
const comment = {
date: new Date(),
text: req.body.text,
author: req.body.author,
post: dbComment.post,
_id: dbComment._id,
password: dbComment.password,
};
await comment.save();
return res.status(200).json({
message: "Post updated!",
});
}
return res.status(401).json({
message: "Comments can only be updated with their original password!",
});
}
return res.status(401).json({
message: "Comments can only be updated by their original author!",
});
}),
];
// deletes a comment - D
exports.delete = [
// Validate and sanitize password
body("password", "Please enter comment deletion password!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after authentication, validation and sanitization
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment password produced validation errors!",
errors: errors.array(),
});
}
const dbComment = await Comment.findOne({ _id: req.params.commentID })
.lean()
.exec();
if (req.body.password === dbComment.password) {
await Comment.findByIdAndDelete({ _id: req.params.commentID }).exec();
return res.status(200).json({ message: "Comment deleted!" });
}
return res.status(401).json({
message: "Incorrect password!",
});
}),
];
// no D(elete)
// again, would require authorization that is out of scope for this project

View file

@ -6,19 +6,22 @@ const User = require("../models/user.js");
let opts = {};
exports.post = asyncHandler(async (req, res, next) => {
const admin = await User.findOne({}).lean().exec(); // only one user in DB - admin. pull it up, jamie!
const { username, password } = req.body; // get fields from body
if (username === admin.username) {
console.log();
const match = await bcrypt.compare(password, admin.password); // compare bcrypt hashed passwords
const user = await User.findOne({ username: username }).lean().exec(); // gets user based on username
if (user) {
const match = await bcrypt.compare(password, user.password); // compare bcrypt hashed passwords
if (match) {
opts.expiresIn = 120;
opts.expiresIn = "1d";
const token = jwt.sign({ username }, process.env.SECRET_KEY, opts); // create token and return below
return res.status(200).json({
message: "Authentication complete",
token,
});
return res
.cookie("JWT_TOKEN", token, {
httpOnly: true,
})
.status(200)
.json({
message: "Authentication complete", // a winrar is you
});
}
}
return res.status(401).json({ message: "Authentication failed" }); // get rekt nerd
return res.status(401).json({ message: "Authentication failed" }); // epic fail
});

View file

@ -5,10 +5,6 @@ const { body, validationResult } = require("express-validator");
const Post = require("../models/post.js");
const Comment = require("../models/comment.js");
const passport = require("passport");
const jwtStrategy = require("../strategy/jwt.js");
passport.use(jwtStrategy);
// returns json object with ALL posts and comments
exports.index = asyncHandler(async (req, res, next) => {
const dbPosts = await Post.find().lean().exec();
@ -30,50 +26,54 @@ exports.index = asyncHandler(async (req, res, next) => {
});
// makes new post - C
(exports.post = passport.authenticate("jwt", { session: false })),
[
// Validate and sanitize title
body("title", "Please enter blog post title!")
.isLength({ min: 1 })
.trim()
.escape(),
exports.post = [
// Validate and sanitize title
body("title", "Please enter blog post title!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize text
body("text", "Please enter blog post text!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize text
body("text", "Please enter blog post text!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after authentication, validation and sanitization
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, render with errors
if (!errors.isEmpty()) {
res.render("/admin/create"),
{
errors: errors.array(),
};
return;
}
// else data is valid, make post object
const post = new Post({
title: req.body.title,
date: new Date(),
text: req.body.text,
author: req.user.username,
_id: new mongoose.Types.ObjectId(),
asyncHandler(async (req, res, next) => {
// begin by authorizing token
const token = req.cookies.JWT_TOKEN;
if (!token) {
return res.status(403).json({
message: "Not authorized!",
});
}
// save to DB
await post.save();
return res.status(200).json({
message: "Post created!",
// then return any validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}),
];
}
// else data is valid, make post object
const post = new Post({
title: req.body.title,
date: new Date(),
text: req.body.text,
author: req.user.username,
_id: new mongoose.Types.ObjectId(),
});
// save to DB
await post.save();
return res.status(200).json({
message: "Post created!",
});
}),
];
// returns post in json format - R
exports.get = asyncHandler(async (req, res, next) => {
@ -82,43 +82,66 @@ exports.get = asyncHandler(async (req, res, next) => {
});
// updates post - U
(exports.put = passport.authenticate("jwt", { session: false })),
[
// Validate and sanitize title
body("title", "Please enter blog post title!")
.isLength({ min: 1 })
.trim()
.escape(),
exports.put = [
// Validate and sanitize title
body("title", "Please enter blog post title!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize text
body("text", "Please enter blog post text!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize text
body("text", "Please enter blog post text!")
.isLength({ min: 1 })
.trim()
.escape(),
// Process request after sanitization and validation
asyncHandler(async (req, res, next) => {
const dbPost = await Post.findOne({ _id: req.params.postID })
.lean()
.exec();
const post = {
title: req.body.title,
date: new Date(),
text: req.body.text,
author: dbPost.author,
published: dbPost.published,
_id: dbPost._id,
};
await post.save();
return res.status(200).json({
message: "Post updated!",
// Process request after sanitization and validation
asyncHandler(async (req, res, next) => {
// begin by authorizing token
const token = req.cookies.JWT_TOKEN;
if (!token) {
return res.status(403).json({
message: "Not authorized!",
});
}),
];
}
// then return any validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}
// else data is valid, update post object
const dbPost = await Post.findOne({ _id: req.params.postID }).lean().exec();
const post = new Post({
title: req.body.title,
date: new Date(),
text: req.body.text,
author: dbPost.author,
published: dbPost.published,
_id: dbPost._id,
});
// save to DB
await Post.findByIdAndUpdate(dbPost._id, post, {});
return res.status(200).json({
message: "Post updated!",
});
}),
];
// deletes a post - D
(exports.delete = passport.authenticate("jwt", { session: false })),
asyncHandler(async (req, res, next) => {
await Post.findByIdAndDelete({ _id: req.params.postID }).exec();
return res.status(200).json({ message: "Post deleted!" });
});
exports.delete = asyncHandler(async (req, res, next) => {
const token = req.cookies.JWT_TOKEN;
if (!token) {
return res.status(403).json({
message: "Not authorized!",
});
}
await Post.findByIdAndDelete({ _id: req.params.postID }).exec();
return res.status(200).json({ message: "Post deleted!" });
});

164
controllers/user.js Normal file
View file

@ -0,0 +1,164 @@
const asyncHandler = require("express-async-handler");
const bcrypt = require("bcryptjs");
const User = require("../models/user.js");
const jwt = require("jsonwebtoken");
// C
exports.put = [
// Validate and sanitize username
body("username", "Please enter username!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize password
body("password", "Please enter password!")
.isLength({ min: 1 })
.trim()
.escape(),
asyncHandler(async (req, res, next) => {
const errors = validationResult(req);
// if there are validation errors, return them
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}
const { username, password } = req.body; // get fields from body
const dbUser = await User.findOne({ username: req.params.username })
.lean()
.exec();
const exists = await User.findOne({ username: username }).lean().exec();
if (exists) {
return res.status(409).json({
message: "Username is taken!",
});
}
// else
const user = new User({
username: username,
password: await bcrypt.hash(password, 10),
_id: dbUser._id,
});
await User.findByIdAndUpdate(dbUser._id, user, {});
return res.status(200).json({
message: "User updated!",
});
}),
];
// R
exports.get = asyncHandler(async (req, res, next) => {
const user = await User.findOne({ username: req.params.username })
.lean()
.exec(); // gets user based on username
return res.status(200).json({
user: user,
});
});
// U
exports.post = [
// Validate and sanitize username
body("username", "Please enter username!")
.isLength({ min: 1 })
.trim()
.escape(),
// Validate and sanitize password
body("password", "Please enter password!")
.isLength({ min: 1 })
.trim()
.escape(),
asyncHandler(async (req, res, next) => {
// begin by authorizing token
const token = req.cookies.JWT_TOKEN;
if (!token) {
return res.status(403).json({
message: "Not authorized!",
});
}
// if token is not for this user - compares by creating another token
let opts = {
expiresIn: "1d",
};
const originalUsername = req.params.username;
const userToken = jwt.sign(
{ originalUsername },
process.env.SECRET_KEY,
opts
);
if (token != userToken) {
return res.status(403).json({
message: "Not authorized!",
});
}
// then return any validation errors
const errors = validationResult(req);
// if there are validation errors, return them
if (!errors.isEmpty()) {
return res.status(400).json({
message: "Comment produced validation errors!",
errors: errors.array(),
});
}
// check for duplicates
const { username, password } = req.body; // get fields from body
const exists = await User.findOne({ username: originalUsername })
.lean()
.exec();
if (exists) {
return res.status(409).json({
message: "Username is taken!",
});
}
// otherwise update user
const user = new User({
username: username,
password: await bcrypt.hash(password, 10),
});
await user.save(); // make and save user
return res.status(200).json({
message: "User created!",
});
}),
];
// D
exports.delete = asyncHandler(async (req, res, next) => {
// begin by authorizing token
const token = req.cookies.JWT_TOKEN;
if (!token) {
return res.status(403).json({
message: "Not authorized!",
});
}
// if token is not for this user - compares by creating another token
let opts = {
expiresIn: "1d",
};
const originalUsername = req.params.username;
const userToken = jwt.sign(
{ originalUsername },
process.env.SECRET_KEY,
opts
);
if (token != userToken) {
return res.status(403).json({
message: "Not authorized!",
});
}
// if everything is correct, delete user
await User.findOneAndDelete({ username: originalUsername }).exec();
return res.status(200).json({ message: "Post deleted!" });
});

View file

@ -7,13 +7,12 @@ const CommentSchema = new Schema({
author: { type: String, required: true },
post: { type: mongoose.ObjectId, required: true },
_id: { type: mongoose.ObjectId, required: true },
password: { type: String, required: true },
});
// Virtual for comment RESTful functions
CommentSchema.virtual("url").get(function () {
// We don't use an arrow function as we'll need the this object
return `/api/${post}/${this._id}`;
return `/${post}/${this._id}`;
});
// Export model

View file

@ -13,7 +13,7 @@ const PostSchema = new Schema({
// Virtual for message URL
PostSchema.virtual("url").get(function () {
// We don't use an arrow function as we'll need the this object
return `/api/${this._id}`;
return `/${this._id}`;
});
// Export model

100
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"bootstrap": "^5.3.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"debug": "~2.6.9",
"dotenv": "^16.3.1",
@ -20,11 +21,7 @@
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^8.0.0"
"mongoose": "^7.5.1"
}
},
"node_modules/@mongodb-js/saslprep": {
@ -394,6 +391,26 @@
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -829,11 +846,6 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/generaterr": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/generaterr/-/generaterr-1.5.0.tgz",
"integrity": "sha512-JgcGRv2yUKeboLvvNrq9Bm90P4iJBu7/vd5wSLYqMG5GJ6SxZT46LAAkMfNhQ+EK3jzC+cRBm7P8aUWYyphgcQ=="
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -1373,64 +1385,6 @@
"node": ">= 0.8"
}
},
"node_modules/passport": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz",
"integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
"dependencies": {
"jsonwebtoken": "^9.0.0",
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-local": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
"integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==",
"dependencies": {
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-local-mongoose": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/passport-local-mongoose/-/passport-local-mongoose-8.0.0.tgz",
"integrity": "sha512-jgfN/B0j11WT5f96QlL5EBvxbIwmzd+tbwPzG1Vk8hzDOF68jrch5M+NFvrHjWjb3lfAU0DkxKmNRT9BjFZysQ==",
"dependencies": {
"generaterr": "^1.5.0",
"passport-local": "^1.0.0",
"scmp": "^2.1.0"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@ -1444,11 +1398,6 @@
"node": ">=8"
}
},
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@ -1652,11 +1601,6 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/scmp": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",

View file

@ -8,6 +8,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"bootstrap": "^5.3.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"debug": "~2.6.9",
"dotenv": "^16.3.1",
@ -18,10 +19,6 @@
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^8.0.0"
"mongoose": "^7.5.1"
}
}

View file

@ -1,15 +1,28 @@
const express = require("express");
const router = express.Router();
const login_controller = require("../controllers/login.js");
const post_controller = require("../controllers/post.js");
const comment_controller = require("../controllers/comment.js");
const login_controller = require("./controllers/login.js");
const post_controller = require("./controllers/post.js");
const comment_controller = require("./controllers/comment.js");
const user_controller = require("./controllers/user.js");
// list all posts and append comments to each post based on id, return as json
router.get("/", post_controller.index);
// login page - should work with json
// login page
router.post("/login", login_controller.post);
// user get
router.get("/:username", user_controller.get);
// user post
router.post("/new_user", user_controller.post);
// user put
router.put("/:username", user_controller.put);
// user delete
router.delete("/:username", user_controller.delete);
// post post
router.post("/new_post", post_controller.post);
@ -28,10 +41,4 @@ router.post("/:postID/new_comment", comment_controller.post);
// comment get
router.get("/:postID/:commentID", comment_controller.get);
// comment put
router.put("/:postID/:commentID", comment_controller.put);
// comment delete
router.delete("/:postID/:commentID", comment_controller.delete);
module.exports = router;

View file

@ -1,20 +0,0 @@
const User = require("../models/user.js");
const JwtStrategy = require("passport-jwt").Strategy;
const ExtractJwt = require("passport-jwt").ExtractJwt;
const opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken();
opts.secretOrKey = process.env.SECRET_KEY;
const getAdmin = async () => {
const admin = await User.findOne({}).lean().exec(); // only one user in DB - admin. pull it up, jamie!
return admin;
};
module.exports = new JwtStrategy(opts, (jwt_payload, done) => {
const admin = getAdmin();
if (jwt_payload.username === admin.username) {
return done(null, true);
}
return done(null, false);
});