展示
-
前端仓库: github.com/Tim-Wu-330/…
-
后端仓库: github.com/Tim-Wu-330/…
后端接口:
Post API:
-
Create, read, update, delete (CRUD)
-
File upload
-
Like, dislike
-
Number of views
-
Post Category (Create only by admin)
-
Post comment
-
Add category
Users API:
-
File upload to Cloudinary
-
Image resize
-
Account Verification and Authentication
-
Data modeling
-
Toggle like and dislike of a post
-
User management
-
Profile Management (CRUD / Block or Unblock a user / Follow and unfollow a user)
-
Update / Forget password
-
Sending an email using SendGrid
-
Roles Management (User can be admin or not)
-
Who views my profile
-
Unhealthy words detect and handling
后端
设计模式 - MVC(Model View Controller)
- Model: 包含了项目的数据结构是项及的骨架,处包含理数据的方式和数据库逻辑。比如,要创建一个人,我们要创建手,眼和脚的部分。
- Contoller: 包含项目的业务逻辑,基于Model. 比如人要用脚去行走。 如果说人是
- View: 是项目的展示页面(graphical representation of the proj - what the end user see). The response of model and controller.
MVC模式的工作流
Model (Data logic) <--> Controller (Bussiness Logic) <--> View (Presenting View). DataBase <--> Model || User <--> View
视图层的用户想获取数据,通过Controller发送请求给Model,Model负责和数据库交互,如果能成功拿到数据就通过Controller把数据传回到视图层,如果失败则返回错误信息。
使用配置文件.env报错敏感信息
结合Node.js的全局内置对象process,.env配置文件可以保存数据库密钥一类的敏感字符串,并在项目运行时暴露给项目中其他文件。
任何写在.env里的字符串变量(密码),都会以一个变量组的形式暴露给process.env,它可以用属性索引process.env.的方式访问这些字符串。
想用process.env这个对象需要再先引入dot.env, 使用dotenv.config() 配置后就可以在process.env中访问所有配置在.env里的内容了
使用中间件 - 对请求和响应做中间处理
next()函数代表执行中间件后一个动作,如果配置中间件时没有添加next()动作则会一直停留在中间件处理完成状态直到程序运行超时停止。
使用中间件使用Hash加密用户密码并存到数据库中
Usage for bcrypt:
const bcrypt = require('bcrypt');
const saltRounds = 10; //The length of the crypto key, 10 is recommended too long will affect the performance.
const myPlaintextPassword = 's0//\P4$$w0rD';
//To hash a password:
bcrypt.hash(myPlaintextPassword, saltRounds, function(err, hash) {
// Store hash in your password DB.
}); //the last err handling func could be void
//To check a password:
// Load hash from your password DB.
bcrypt.compare(myPlaintextPassword, hash, function(err, result) {
// result == true
});
bcrypt.compare(someOtherPlaintextPassword, hash, function(err, result) {
// result == false
});
//Hash password middleware, it is a middleware so we need to add next() arg or it will be stucked.
//UserSchema.pre() is a middleware in mongoose , it will execute before the user is saved to the DB.
//it will stop if we don't call the inserted func next().
//We can't use the array func here because this keyword cannot link to the objc who has been created.
// this here in the normal func means the instance of the user objc who has been created.
userSchema.pre("save", async function (next) {
if (!this.isModified("password")) {
next();
}
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// match password
userSchema.methods.isPasswordMatched = async function (enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
Error handler
为登录用户生成Token
当用户登录时,使用固定加密规则,一个字符串密钥, 用户id创建一个对应token并把它返回给前端。前端收到token后存到localStorage中,后续需要在数据库中查用户就把localStorage里的token放到header中经过解密再发给数据库进行操作。
generateToken.js
const jwt = require("jsonwebtoken");
const generateToken = (id) => {
return jwt.sign({ id }, process.env.JWT_KEY, {
expiresIn: "20d",
});
};
module.exports = generateToken;
const loginUserCtrl = expressAsyncHandler(async (req, res) => {
//check if user exists
const { email, password } = req.body;
// check if password is match
const userFound = await User.findOne({ email });
blockUser(req.user);
if (userFound && (await userFound.isPasswordMatched(password))) {
res.json({
_id: userFound?._id,
firstName: userFound?.firstName,
lastName: userFound?.lastName,
email: userFound?.email,
profilePhoto: userFound?.profilePhoto,
isAdmin: userFound?.isAdmin,
token: generateToken(userFound._id),
isVerified: userFound?.isAccountVerified,
});
} else {
res.status(401);
throw new Error("Invalid Login Credentials");
}
});
Authentication Logic
Server is stateless, how can we make our server remember the login user? Steps:
- Attach the token after login to the header of the request
- Upon any request check if there is any token (in localStorage or header)
- Get the token, verify it using JWT (middleware) and change it to the user_id and use the user_id to find the user object in DB.
- Attach the user to the request object using a middleware. (In any of our routes, we have the req.user). Since we have the user obj in the req.user, we can use the property of it like the username, id, etc. to do many things, search or validate info.
Code:
const expressAsyncHandler = require("express-async-handler");
const jwt = require("jsonwebtoken");
const User = require("../../model/user/User");
const authMiddleware = expressAsyncHandler(async (req, res, next) => {
let token;
//use token to get the user info
if (req.headers?.authorization?.startsWith("Bearer")) {
try {
token = req.headers.authorization.split(" ")[1];
if (token) {
const decoded = jwt.verify(token, process.env.JWT_KEY);
//find the user by id using the same encoded string to decode
const user = await User.findById(decoded?.id).select("-password");
//attach the user to the request object and delete the password from the response, we don't want pass password to the user
req.user = user;
next(); //go to the next middleware
}
} catch (error) {
throw new Error("Not authorized token expired, login again");
}
} else {
throw new Error("There is no token attached to the header");
}
});
module.exports = authMiddleware;
//usersRoute
userRoutes.post("/register", userRegisterCtrl);
userRoutes.post("/login", loginUserCtrl);
userRoutes.get("/", authMiddleware, fetchUsersCtrl);
userRoutes.get("/profile/:id", authMiddleware, userProfileCtrl);
userRoutes.put("/", authMiddleware, updateUserCtrl);
const fetchUsersCtrl = expressAsyncHandler(async (req, res) => {
// console.log(req.headers);
try {
const users = await User.find({}).populate("posts");
res.json(users);
} catch (error) {
res.json(error);
}
});
Forget password
点击页面上的Forget password按钮后,根据路由匹配规则先跳转到ResetPasswordForm组件页面。在页面上的文本框内输入用户注册的邮箱,提交后将输入的邮箱作为参数调用Redux函数passwordResetTokenAction(values?.email)。这个函数会将邮箱号并作为参数向后端服务器发送路径为${baseUrl}/api/users/forget-password-token的post请求。后端路由器收到基于该路径的请求后调用后端服务器中Controller下的forgetPasswordToken函数。这个函数查找并记录req.body中所带的email,用email去服务器查找对应的user。然后调用服务器上的创建临时token的函数createPasswordResetToken()并将token挂在邮件中超链接的后缀发给user的email。超链接为:<a href="${baseURL}/reset-password/${token}">Click to verify your account</a>. 点击链接后 根据前端的react router规则<Route exact path="/reset-password/:token" component={ResetPassword} /> 带着token(位于url后缀的param参数)跳转到ResetPassword组件。在组件中输入新密码后,向后端服务器下路径${baseUrl}/api/users/reset-password发出post请求,并发送token和更新的密码。服务器收到请求后将目前req中保存的token用固定的加密算法与之前点击reset时服务器创建并保存的token对比并确保没有过期。如果验证通过则更用户新密码并将服务器中该用户生成的token删除。更新密码完成后向前端发出响应消息,前端页面组件收到响应后调用react router中的路由跳转动作props.history.push("/login");将页面跳转至登录页面。
// Login page
<Link to="/password-reset-token" className="font-medium text-indigo-600 hover:text-indigo-500">
Forget Password ?
</Link>
//App.js
function App() {
return (
<BrowserRouter>
<Navbar />
<Switch>
<Route
exact
path="/password-reset-token"
component={ResetPasswordForm}
/>
<Route exact path="/reset-password/:token" component={ResetPassword} />
//ResetPasswordForm.js
//Form schema
const formSchema = Yup.object({
email: Yup.string().required("Email is required"),
});
const ResetPasswordForm = () => {
const dispatch = useDispatch();
//formik
const formik = useFormik({
initialValues: {
email: "",
},
onSubmit: values => {
//dispath the action
dispatch(passwordResetTokenAction(values?.email));
},
validationSchema: formSchema,
});
//Password reset token generator
export const passwordResetTokenAction = createAsyncThunk(
"password/token",
async (email, { rejectWithValue, getState, dispatch }) => {
const config = {
headers: {
"Content-Type": "application/json",
},
};
//http call
try {
const { data } = await axios.post(
`${baseUrl}/api/users/forget-password-token`,
{ email },
config,
);
return data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error?.response?.data);
}
},
);
Backend route: users -> usersRoute.js
userRoutes.post("/forget-password-token", forgetPasswordToken);
controllers -> users -> usersCtrl
//----------------------------------------------------------------
// Forget token generator
//----------------------------------------------------------------
const forgetPasswordToken = expressAsyncHandler(async (req, res, next) => {
// find the user by their email
const { email } = req.body;
// console.log(email);
const user = await User.findOne({ email });
if (!user) throw new Error("User not found");
// console.log(baseURL);
try {
const token = await user.createPasswordResetToken();
// console.log(token);
await user.save();
//userSchema.pre("save", async function (next) {
// if (!this.isModified("password")) {
// next();
// }
// const salt = await bcrypt.genSalt(10);
// this.password = await bcrypt.hash(this.password, salt);
// next();
// });
// Build your message
const resetURL = `If you were requested to reset your password, reset now within 10 minutes, otherwise ignore this message <a href="${baseURL}/reset-password/${token}">Click to verify your account</a>`;
const msg = {
to: email,
from: "tim.wu330702@gmail.com",
subject: "Reset Password",
html: resetURL,
};
await sgMail.send(msg);
return res.json({
msg: `A verification message is successfully sent to ${user?.email}. Reset now within 10 minutes, ${resetURL}`,
});
} catch (err) {
res.json(err);
}
});
model -> user -> User.js
//Password reset/forget
userSchema.methods.createPasswordResetToken = async function () {
const resetToken = crypto.randomBytes(32).toString("hex");
this.passwordResetToken = crypto
.createHash("sha256")
.update(resetToken)
.digest("hex");
this.passwordResetTokenExpires = Date.now() + 10 * 60 * 1000; //10 minutes
return resetToken;
};
//Compile schema into model
const User = mongoose.model("User", userSchema);
module.exports = User;
const ResetPasswordForm = () => {
const dispatch = useDispatch();
//formik
const formik = useFormik({
initialValues: {
email: "",
},
onSubmit: values => {
//dispath the action
dispatch(passwordResetTokenAction(values?.email));
},
validationSchema: formSchema,
});
//select data from store
const users = useSelector(state => state?.users);
const { passwordToken, loading, appErr, serverErr } = users;
return (
{/* Sucess msg */}
<div className="text-green-700 text-center">
{passwordToken && (
<h3>
Email is successfully sent to your email. Verify it within 10
minutes.
</h3>
)}
)
ResetPassword.js 组件
//Form schema
const formSchema = Yup.object({
password: Yup.string().required("Password is required"),
});
const ResetPassword = (props) => {
const token = props.match.params.token;
const dispatch = useDispatch();
//formik
const formik = useFormik({
initialValues: {
pasword: "",
},
onSubmit: (values) => {
//dispath the action
const data = {
password: values?.password,
token,
};
dispatch(passwordResetAction(data));
},
validationSchema: formSchema,
});
//select data from store
const users = useSelector((state) => state?.users);
const { passwordReset, loading, appErr, serverErr } = users;
// useEffect(() => {}, []) the call back will be executed one time when the component is mounted
//Redirect, useEffect will keep monitoring the status of the binded properties like passwordReset, when its properties are changed, the callback will be called automatically
useEffect(() => {
setTimeout(() => {
if (passwordReset) props.history.push("/login");
}, 5000);
}, [passwordReset]);
return (
{/* Sucess msg */}
<div className="text-green-700 text-center">
{passwordReset && (
<h3>
Password Reset Successfully. You will be redirected to login with
5 seconds
</h3>
)}
</div>
<form className="mt-8 space-y-6" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="email-address" className="sr-only">
Enter Your New Password
</label>
<input
type="password"
autoComplete="password"
value={formik.values.password}
onChange={formik.handleChange("password")}
onBlur={formik.handleBlur("password")}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Enter new Password"
/>
{/* Err msg */}
<div className="text-red-400 mb-2">
{formik.touched.password && formik.errors.password}
</div>
</div>
</div>
)
userSlices.js 中的passwordResetAction
//Password reset
export const passwordResetAction = createAsyncThunk(
"password/reset",
async (user, { rejectWithValue, getState, dispatch }) => {
const config = {
headers: {
"Content-Type": "application/json",
},
};
//http call
try {
const { data } = await axios.post(
`${baseUrl}/api/users/reset-password`,
{ password: user?.password, token: user?.token },
config,
);
return data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error?.response?.data);
}
},
);
express服务器路由usersRoute.js中的reset-password路由
userRoutes.post("/reset-password", passwordResetCtrl);
将目前req中保存的token用固定的加密算法与之前点击reset时服务器创建并保存的token对比并确保没有过期。如果没有过期且能查找到用户就更新密码并将服务器中之前该用户生成的token删除。更新密码完成后向前端发出响应消息,前端页面组件收到响应后调用react router中的路由跳转动作props.history.push("/login");将页面跳转至登录页面。
const passwordResetCtrl = expressAsyncHandler(async (req, res) => {
const { token, password } = req.body;
const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
//find this user by token
const user = await User.findOne({
passwordResetToken: hashedToken,
passwordResetExpires: { $gt: Date.now() },
});
if (!user) throw new Error("Token Expired, try again later");
// Update/ change the password
user.password = password;
user.passwordResetToken = undefined;
user.passwordResetxpires = undefined;
await user.save();
res.json(user);
});