
Node.js 项目实战构建 Members Only 会员俱乐部 —— 基于 Passport、bcrypt 与 PostgreSQL 的认证授权全流程【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculumMembers 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 columnPostgreSQL 会自动为每一行生成值默认从 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 应用应使用 Poolconst { Pool } require(pg); module.exports new Pool({ connectionString: postgresql://role_name:role_passwordlocalhost: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 中已不再需要单独调用。函数一配置 LocalStrategyPassport 通过**策略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) {% h1WELCOME BACK % user.username %/h1 a href/log-outLOG OUT/a % } else { % !-- 显示登录表单 -- % } %进阶技巧与其在每个控制器里手动传递用户对象不如写一个自定义中间件把当前用户放进 Express 的locals对象——这样所有视图都能直接使用currentUserapp.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-messageCreate a new message/a % } %新消息表单与路由POST /new-message把title、text连同当前用户 ID 一起写入messages表并遵循 Post/Redirect/GetPRG模式——提交成功后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 classmessage 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 methodPOST styledisplay:inline; button typesubmit onclickreturn 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_URLpostgresql://role:passwordlocalhost:5432/members_only SESSION_SECRETsome-long-random-string CLUB_PASSCODEopensesame ADMIN_PASSCODEletmein代码中通过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),仅供参考