Node.js 项目实战:构建 Members Only 会员俱乐部 —— 基于 Passport、bcrypt 与 PostgreSQL 的认证授权全流程
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
Members Only(会员俱乐部)是一个经典的 Node.js 全栈练习项目:用户注册后可以发布匿名帖子,普通访客只能看到帖子内容,只有"会员"才能看到作者与发布时间,而管理员(Admin)拥有删除任何消息的额外权限。本文将基于nodeJS/authentication/project_members_only.md的完整任务清单,结合仓库中认证、表单处理、数据库与部署四份配套教程,带你从数据建模开始,逐步完成注册校验、密码加密、Passport 登录、角色权限分级与 PaaS 部署的完整闭环。学完本文,你将掌握"创建并认证用户、为不同用户赋予不同能力与权限"这套在真实 Web 开发中极其重要的技能组合。
项目概览:一个"非会员看不到作者"的私密俱乐部
这个项目的产品形态非常明确:一个会员专属的俱乐部(clubhouse),会员可以在其中发布匿名帖子。
- 在俱乐部内部(登录且具有会员身份的用户),可以看到每条帖子的作者是谁;
- 在俱乐部外部(未登录或非会员的访客),只能看到故事内容,猜测是谁写的;
- 管理员(Admin)可以看到一切,并且拥有删除消息的权限。
表面上看这是一个"有点傻气的小应用",但正如原任务文档所强调的,你真正在练习的东西——创建和认证用户、以及给不同用户不同的能力和权限——在真实项目中会非常有用。整个项目可以拆解为以下里程碑:
- 设计数据库模型(用户表 + 消息表);
- 在 PostgreSQL 中建库建表并生成项目骨架;
- 实现带校验与密码加密的注册表单;
- 实现"输入秘密口令加入俱乐部"的会员认证页;
- 使用 Passport.js 实现登录;
- 登录用户才能看到"创建新消息"入口并发布消息;
- 首页展示全部消息,但只有会员能看到作者与日期;
- 增加 Admin 字段与消息删除权限;
- 部署到 PaaS 平台并分享成果。
第一步:设计数据库模型
动手写代码之前,先想清楚需要哪些表、哪些字段。原任务文档给出的需求如下:
users(用户表)
- 全名:
first name与last name; - 用户名:
username(任务允许直接用 email 充当); - 密码:
password; - 会员状态:
membership-status; - (可选,第 8 步加入)管理员标记:
admin。
messages(消息表)
- 标题:
title; - 时间戳:
timestamp(记录发布时间); - 正文:
text; - 作者:数据库需要记录"谁创建了这条消息",即外键指向 users 表。
这里用到了 SQL 的两个核心概念:主键(Primary Key)与外键(Foreign Key)。按照仓库中 databases_and_sql.md 的解释,所有表都包含一个唯一的ID列作为主键;而messages表中的作者列存放的是users表的 ID(例如命名为user_id),这种"存放其他表 ID"的列就是外键,它把两张表链接起来。
第二步:搭建 PostgreSQL 数据库与项目骨架
在 psql 中建库建表
任务要求把数据库建立在 PostgreSQL 上。按照 using_postgresql.md 的标准流程,先在终端进入 PostgreSQL shell 并创建数据库:
CREATE DATABASE members_only;连接到该库:
\c members_only然后创建users表与messages表。仓库教程中的表结构写法可以复用:
CREATE TABLE users ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, first_name VARCHAR ( 255 ), last_name VARCHAR ( 255 ), username VARCHAR ( 255 ), password VARCHAR ( 255 ), membership_status BOOLEAN DEFAULT FALSE, admin BOOLEAN DEFAULT FALSE ); CREATE TABLE messages ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, title VARCHAR ( 255 ), text TEXT, timestamp TIMESTAMP DEFAULT NOW(), user_id INTEGER REFERENCES users(id) );关于GENERATED ALWAYS AS IDENTITY:它把id列定义为标识列(identity column),PostgreSQL 会自动为每一行生成值(默认从 1 开始、每行 +1),并隐式创建一个名为users_id_seq的序列对象来记录下一个要使用的值。
用 node-postgres 连接数据库
安装pg依赖:
npm install express express-session pg passport passport-local ejs express-validator bcryptjs创建db/pool.js初始化连接池。pg提供两种连接方式:Client(单条连接,手动开关,适合一次性查询)和Pool(连接池,查询时自动复用空闲连接,适合 Web 服务器),Web 应用应使用 Pool:
const { Pool } = require("pg"); module.exports = new Pool({ connectionString: "postgresql://<role_name>:<role_password>@localhost:5432/members_only" });连接信息(尤其是生产环境的连接串)不应硬编码,应通过环境变量读取,这一点在后面的部署章节详述。
用脚本初始化并填充数据库
手动建表、造数据很繁琐,仓库教程推荐把 SQL 写进一个脚本db/populatedb.js,用node db/populatedb.js <db-url>运行:
#! /usr/bin/env node const { Client } = require("pg"); const SQL = ` CREATE TABLE IF NOT EXISTS users (...); CREATE TABLE IF NOT EXISTS messages (...); INSERT INTO users (first_name, last_name, username, password, membership_status) VALUES (...); `; async function main() { console.log("seeding..."); const client = new Client({ connectionString: process.argv[2], // 传入本地或生产库的 URL }); await client.connect(); await client.query(SQL); await client.end(); console.log("done"); } main();把数据库 URL 作为命令行参数传入(通过process.argv读取),可以让同一个脚本既服务于本地库也服务于生产库,保持脚本与代码库解耦。
牢记参数化查询
在编写查询函数时(db/queries.js),一定要使用pg的查询参数化特性,把用户输入放进第二个参数数组中,而不是直接拼进 SQL 字符串:
const pool = require("./pool"); async function insertUser({ firstName, lastName, username, hashedPassword }) { await pool.query( "INSERT INTO users (first_name, last_name, username, password) VALUES ($1, $2, $3, $4)", [firstName, lastName, username, hashedPassword] ); } async function findUserByUsername(username) { const { rows } = await pool.query("SELECT * FROM users WHERE username = $1", [username]); return rows[0]; } module.exports = { insertUser, findUserByUsername };否则,恶意用户可以在表单里输入类似sike'); DROP TABLE users; --的内容实施SQL 注入,后果不堪设想。
第三步:注册表单 —— express-validator 校验 + bcrypt 密码加密
任务明确要求:注册表单要清洗(sanitize)和校验(validate)字段,用bcrypt保护密码,并增加confirmPassword字段,通过自定义校验器(custom validator)验证两次密码一致。
校验与清洗的概念
根据 forms_and_data_handling.md:
- 校验(Validation):确保用户输入满足指定标准(如必填、格式正确);
- 清洗(Sanitization):通过删除或编码潜在恶意字符,防止恶意数据被处理。
两者都交给express-validator完成。先引入所需函数:
const { body, validationResult, matchedData } = require("express-validator");编写校验链
body()用于指定请求体中要校验和清洗的字段,并支持链式调用多个规则、为每条规则单独配置错误消息:
const validateUser = [ body("firstName").trim() .notEmpty().withMessage("First name can not be empty.") .isAlpha().withMessage("First name must only contain letters."), body("lastName").trim() .notEmpty().withMessage("Last name can not be empty.") .isAlpha().withMessage("Last name must only contain letters."), body("username").trim().isEmail().withMessage("Username must be a valid email."), // 自定义校验器:确认两次输入的密码一致 body("confirmPassword").custom((value, { req }) => { if (value !== req.body.password) { throw new Error("Password confirmation does not match password"); } return true; }), ];其中custom()就是任务点名的自定义校验器:它接收要校验的字段值(这里是对照confirmPassword与req.body.password),校验失败时抛出错误即可。
在路由中使用校验并渲染错误
把校验数组作为中间件传给 POST 路由,用validationResult汇总错误,失败则回渲染表单并带上错误列表;成功则用matchedData取回已清洗的数据(例如已.trim()过的值):
app.post("/sign-up", validateUser, async (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).render("sign-up-form", { errors: errors.array(), formData: req.body, }); } try { const { firstName, lastName, username, password } = matchedData(req); // ... 密码加密后写入数据库 res.redirect("/"); } catch (err) { return next(err); } });视图模板中用一个partials/errors.ejs渲染错误信息:
<% if (locals.errors) {%> <ul> <% errors.forEach(function(error) { %> <li><%= error.msg %></li> <% }); %> </ul> <% } %>用 bcrypt 加密密码
根据 authentication_basics.md,绝不能明文存储密码。使用bcryptjs(纯 JavaScript 实现,避免原生模块安装问题;其 C++ 版本bcrypt更快,但两者 API 一致):
const bcrypt = require("bcryptjs"); // 注册路由中 const hashedPassword = await bcrypt.hash(req.body.password, 10);- 第二个参数
10是**盐(salt)**的长度。加盐指在密码上追加随机字符后再做哈希,即使多个用户使用相同密码,哈希输出也互不相同,可抵御彩虹表(rainbow tables)与字典攻击; bcryptjs会把盐自动内嵌进哈希串本身,因此无需单独存盐;- 哈希函数较慢,数据库写入操作要放在
await之后按异步流程处理。
注册成功后去数据库查看,password字段应已变成一长串随机字符,而不是明文。
不要自动授予会员身份
任务特别强调:用户注册时不应自动获得会员状态——"如果谁都能加入,那私人俱乐部还有什么乐趣?"所以membership_status默认应为false,注册流程只创建普通用户。
第四步:"加入俱乐部"页面 —— 秘密口令校验
新增一个页面,让用户通过输入**秘密口令(secret passcode)**来"加入俱乐部"。逻辑很简单:
- 路由
GET /join渲染口令输入表单; - 路由
POST /join接收口令,与预设值比对:- 正确 → 更新当前用户的
membership_status为true,然后重定向; - 错误 → 返回错误提示,重新渲染表单。
- 正确 → 更新当前用户的
app.post("/join", async (req, res, next) => { const passcode = req.body.passcode; const SECRET_PASSCODE = process.env.CLUB_PASSCODE; // 口令应放在环境变量中 if (passcode !== SECRET_PASSCODE) { return res.status(400).render("join-club", { errors: [{ msg: "Incorrect passcode" }], }); } try { await pool.query("UPDATE users SET membership_status = TRUE WHERE id = $1", [req.user.id]); res.redirect("/"); } catch (err) { return next(err); } });这一步同样可以用express-validator的body("passcode")做必填校验。口令本身属于敏感信息,应像数据库连接串一样存进环境变量而不是写死在代码里。
第五步:用 Passport.js 实现登录
登录部分直接复用 authentication_basics.md 的完整方案。
中间件装配顺序
Passport 依赖express-session在后台创建会话 Cookie(名为connect.sid的 cookie 存在用户浏览器中)。关键中间件顺序如下:
const session = require("express-session"); const passport = require("passport"); const LocalStrategy = require("passport-local").Strategy; app.use(session({ secret: "cats", resave: false, saveUninitialized: false })); app.use(passport.session()); app.use(express.urlencoded({ extended: false }));注意:旧教程中出现的
app.use(passport.initialize())在当前版本 Passport 中已不再需要单独调用。
函数一:配置 LocalStrategy
Passport 通过**策略(Strategy)**来认证用户,最基础也最常用的是用户名 + 密码的LocalStrategy。它会在passport.authenticate()被调用时自动执行:用请求体中的username/password查库比对,然后通过done回调告知结果:
passport.use( new LocalStrategy(async (username, password, done) => { try { const user = await db.findUserByUsername(username); if (!user) { return done(null, false, { message: "Incorrect username" }); } const match = await bcrypt.compare(password, user.password); if (!match) { return done(null, false, { message: "Incorrect password" }); } return done(null, user); } catch (err) { return done(err); } }) );这里用bcrypt.compare(plainText, hashed)校验登录密码:它把请求中的明文密码与数据库中存储的哈希做比对。注意:在引入 bcrypt 之前注册的旧用户(明文密码)将无法再登录——这正是"下一个项目从一开始就用 bcrypt"的理由。
函数二与三:会话与序列化
为了让用户登录后能在各页面间保持登录状态,Passport 会在内部调用express-session的功能,用一些数据生成存于用户浏览器的connect.sidCookie。你需要定义两个函数告诉 Passport 该存什么、取什么:
passport.serializeUser((user, done) => { done(null, user.id); }); passport.deserializeUser(async (id, done) => { try { const user = await db.findUserById(id); done(null, user); } catch (err) { done(err); } });serializeUser:登录成功后,把用户对象的id存入会话数据;deserializeUser:后续请求携带匹配的会话时,取出存好的id,据此查库,最终把用户对象挂到请求对象的.user属性(req.user)上,供本次请求后续使用。
登录 / 登出路由
登录路由只需一行中间件即可完成"查库 → 认证 → 建会话 Cookie → 按结果重定向":
app.post( "/log-in", passport.authenticate("local", { successRedirect: "/", failureRedirect: "/", failureMessage: true, // 错误消息存入 req.session.messages }) );登出则利用 Passport 挂载到req上的logout方法:
app.get("/log-out", (req, res, next) => { req.logout((err) => { if (err) return next(err); res.redirect("/"); }); });在视图中感知登录状态
Passport 中间件会检查请求携带的 Cookie 是否对应已登录用户,是则把用户挂到req.user。视图模板中据此渲染不同内容:
<% if (locals.user) {%> <h1>WELCOME BACK <%= user.username %></h1> <a href="/log-out">LOG OUT</a> <% } else { %> <!-- 显示登录表单 --> <% } %>进阶技巧:与其在每个控制器里手动传递用户对象,不如写一个自定义中间件,把当前用户放进 Express 的locals对象——这样所有视图都能直接使用currentUser:
app.use((req, res, next) => { res.locals.currentUser = req.user; next(); });把这段代码放在 Passport 中间件之后、渲染视图之前,视图里即可统一用currentUser判断登录状态,配合membershipStatus与admin字段即可实现后续的权限分级。
第六步:创建新消息(仅登录用户可见入口)
任务要求:用户登录后才显示"Create a new message"链接,并实现新消息表单。在首页模板中利用上一步的currentUser条件渲染:
<% if (locals.currentUser) { %> <a href="/new-message">Create a new message</a> <% } %>新消息表单与路由(POST /new-message)把title、text连同当前用户 ID 一起写入messages表,并遵循 Post/Redirect/Get(PRG)模式——提交成功后res.redirect("/"),避免重复提交:
app.post("/new-message", async (req, res, next) => { try { const { title, text } = req.body; await pool.query( "INSERT INTO messages (title, text, user_id) VALUES ($1, $2, $3)", [title, text, req.user.id] ); res.redirect("/"); } catch (err) { return next(err); } });别忘了给title与text也加上express-validator校验链(必填、长度限制),并对输出做转义(EJS 中使用<%= %>而非<%- %>即可自动转义,防范 XSS 攻击)。
第七步:首页消息列表与权限分级显示
这是整个项目权限模型的核心:任何人都能看到全部消息列表,但只有会员能看到每条消息的作者与日期。
首页路由同时查出消息与作者信息(通过外键关联):
app.get("/", async (req, res, next) => { try { const { rows } = await pool.query( `SELECT messages.*, users.username AS author FROM messages JOIN users ON messages.user_id = users.id ORDER BY messages.timestamp DESC` ); res.render("index", { messages: rows }); } catch (err) { return next(err); } });模板中根据currentUser.membershipStatus(或currentUser.admin)决定是否渲染作者与时间:
<% messages.forEach(message => { %> <div class="message"> <h3><%= message.title %></h3> <p><%= message.text %></p> <% if (locals.currentUser && (currentUser.membershipStatus || currentUser.admin)) { %> <small> Posted by <%= message.author %> at <%= message.timestamp.toLocaleString() %> </small> <% } %> </div> <% }); %>这样便实现了原任务描述的最终效果:任何访客能看到消息列表但作者名被隐藏;只有会员能看到作者与日期;管理员能看到一切并额外拥有删除能力。
第八步:Admin 角色与消息删除权限
任务第 8 步为users模型增加可选的admin字段,并实现删除消息能力,仅当admin == true时才显示删除按钮、才能删除消息。
为admin用户提供标记方式(二选一):
- 另设一个秘密口令页面(类似加入俱乐部的
/join,但授予 admin 身份); - 或者在注册表单上放一个 "is admin" 复选框(练习用足够,生产环境绝不可行——任何人都能勾选)。
视图侧的条件渲染:
<% if (locals.currentUser && currentUser.admin) { %> <form action="/messages/<%= message.id %>/delete" method="POST" style="display:inline;"> <button type="submit" onclick="return confirm('Are you sure?');">Delete</button> </form> <% } %>路由侧的双重防线:即使视图隐藏了按钮,服务端仍必须校验,否则用户直接构造 POST 请求即可越权删除。这里有两种典型实现:
- 在路由内先检查再执行:
app.post("/messages/:id/delete", async (req, res, next) => { if (!req.user || req.user.admin !== true) { return res.status(403).send("Forbidden"); } try { await pool.query("DELETE FROM messages WHERE id = $1", [req.params.id]); res.redirect("/"); } catch (err) { return next(err); } });- 更整洁的方式是抽出一个授权中间件(类似
requireAdmin),复用于所有需要管理员身份的受保护路由。这正是任务想让你体会的核心:前端隐藏按钮只是体验优化,真正的权限控制必须落在服务端。删除操作使用DELETE FROM messages WHERE id = $1配合参数化查询,并确保WHERE子句存在,避免误删整表。
第九步:环境变量与部署到 PaaS
用环境变量保护敏感配置
根据 environment_variables.md,应用运行的每个环境(本地开发机、云主机)都可以有不同的环境变量值。敏感信息——数据库连接串、会话密钥secret、俱乐部口令、Admin 口令——都应通过环境变量注入:
# .env DATABASE_URL=postgresql://<role>:<password>@localhost:5432/members_only SESSION_SECRET=some-long-random-string CLUB_PASSCODE=opensesame ADMIN_PASSCODE=letmein代码中通过process.env读取(环境变量永远是字符串,需要数值/布尔时需自行转换):
new Pool({ connectionString: process.env.DATABASE_URL }); app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));必须把.env加入.gitignore,防止凭据随代码推送泄露;同时建议在README.md中记录项目所需的全部环境变量,或附带一份.env.sample示例文件。加载.env可用 Node 自带的--env-file选项(如node --env-file=.env app.js)或dotenv类库;部署到生产环境时注意:仓库里没有.env文件,应在 PaaS 平台的网页界面中配置这些变量(具体以所选平台文档为准)。
部署到 PaaS
任务要求把项目部署到自选的 PaaS 平台(可参考 deployment.md 中推荐的提供商)。PaaS(平台即服务)相比裸云服务器更易上手,它替你管理底层基础设施,你只需专注于应用本身。仓库教程推荐的主要选项包括:
- Railway:可同时部署服务器与数据库,支持关联 GitHub 仓库一键部署,按用量计费;
- Render:支持用 "Blueprints" 关联 GitHub 仓库部署,免费额度约每月 750 小时,但数据库需单独开通;
- Neon / Aiven:纯数据库托管服务(PostgreSQL),免费额度足够课程项目使用。
生产环境部署注意事项:
- 数据库:在 PaaS 上创建一个 PostgreSQL 实例,把它的连接串填入生产环境变量
DATABASE_URL,然后用node db/populatedb.js <production-db-url>在本地一次性填充生产库; - Node 版本兼容性:不同平台支持的 Node 版本不同,可在
package.json的engines字段声明项目兼容的版本范围; - 部署期报错:优先查看构建日志(build logs),定位堆栈信息并搜索解决方案;
- 部署后 500 错误:打开应用日志(application logs),在浏览器刷新复现错误,观察实时请求与查询输出;
- 回退技巧:若新版本部署后出错,用
git log查看最近改动、git checkout回退到上一个可用版本,再逐步恢复变更。
项目验收清单
完成开发后,对照原任务文档做一次完整验收:
- 任何访客都能看到全部消息列表,作者名被隐藏;
- 用户可以注册(含字段清洗、校验、
confirmPassword自定义校验、bcrypt 加密)并创建消息; - 只有会员(输入正确口令获得
membershipStatus)能看到每条消息的作者与日期; - 存在 Admin 用户:能看到一切,且拥有删除消息的能力;
- 项目已部署上线并分享链接。
核心要点回顾
- 数据建模先行:
users(含会员状态、admin 标记)与messages(通过user_id外键关联作者)两张表构成项目骨架; - 安全三件套:
express-validator负责校验与清洗、bcryptjs负责密码哈希(hash存库、compare比对)、pg参数化查询抵御 SQL 注入; - Passport 三部曲:
LocalStrategy定义认证逻辑,serializeUser/deserializeUser定义会话数据的存取,passport.authenticate一行完成登录流程; - 权限必须服务端校验:隐藏按钮/链接只是 UI 层的体验设计,真正的会员与管理员权限判断(
membershipStatus === true、admin === true)必须落在每个受保护的路由上; - 配置与部署:所有敏感配置走环境变量(
.env入.gitignore),部署到 Railway/Render 等 PaaS 时在平台侧配置变量,并用脚本一次性填充生产数据库。
延伸阅读
- 认证基础教程(Passport 全流程):本项目的直接前置知识,含完整的 LocalStrategy、序列化与会话讲解;
- 表单与数据处理教程(express-validator):校验链、
validationResult、matchedData与 XSS 转义的完整示例; - PostgreSQL 使用教程(node-postgres):建库建表、Pool 连接、参数化查询与数据库填充脚本;
- Mini Message Board 项目:本项目的前置练手项目,数据模型可在此基础上扩展;
- 环境变量教程:
.env文件、process.env与部署时变量配置; - 部署教程:PaaS 提供商对比、部署步骤与排错指南;
- SQL 基础教程:主键、外键、建表与 CRUD 语句的概念背景。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考