认证与安全
后端核心 认证 6 级安全一、为什么需要认证
1.1 核心原因
| 原因 | 说明 |
|---|---|
| 数据关联 | 将用户生成的数据(帖子、消息、收藏)与个人账户绑定 |
| 隐私保护 | 防止他人查看你的私信、邮件等私密信息 |
| 访问控制 | 按用户状态限制访问(如 Netflix/Spotify 付费内容) |
1.2 认证的难点
创建账户和登录本身很简单,难的是安全性。
Level 1 → 明文密码
Level 2 → 数据库加密
Level 3 → 哈希(MD5)
Level 4 → 加盐哈希(bcrypt)
Level 5 → Cookie 与 Session
Level 6 → OAuth 2.0(第三方登录)二、Level 1 — 明文用户名 + 密码
2.1 基本流程
javascript
// 用户 Schema
const userSchema = new mongoose.Schema({
email: String,
password: String
});
const User = new mongoose.model("User", userSchema);2.2 注册
javascript
app.post("/register", (req, res) => {
const newUser = new User({
email: req.body.username,
password: req.body.password // ⚠️ 明文存储!
});
newUser.save((err) => {
if (err) {
console.log(err);
} else {
res.render("secrets");
}
});
});2.3 登录
javascript
app.post("/login", (req, res) => {
const username = req.body.username;
const password = req.body.password;
User.findOne({ email: username }, (err, foundUser) => {
if (err) {
console.log(err);
} else {
if (foundUser) {
if (foundUser.password === password) {
res.render("secrets");
}
}
}
});
});2.4 问题
数据库中:
{ email: "user@test.com", password: "123456" }
↑ 明文!任何人都能看到致命缺陷
- 内部员工可以直接看到所有人的密码
- 数据库被黑客入侵 → 所有密码立即泄露
- 用户通常在多个网站使用相同密码 → 连锁反应
三、Level 2 — 数据库加密
3.1 加密原理
加密 = 打乱信息 + 密钥 → 只有知道密钥才能还原
明文 "Hello" + 密钥(shift=3) → 密文 "Khoor"(凯撒密码)现代加密算法 AES 比凯撒密码复杂得多,数学上更难破解。
3.2 使用 mongoose-encryption
bash
npm i mongoose-encryptionjavascript
const encrypt = require("mongoose-encryption");
// Schema 必须是 mongoose.Schema 对象(不是普通 JS 对象)
const userSchema = new mongoose.Schema({
email: String,
password: String
});
// 定义加密密钥
const secret = "ThisisourLittlesecret.";
// 作为插件添加到 Schema — 只加密 password 字段
userSchema.plugin(encrypt, {
secret: secret,
encryptedFields: ["password"] // 不加密 email,方便查询
});
const User = new mongoose.model("User", userSchema);3.3 工作原理
调用 .save() → 自动加密 password 字段
调用 .find() → 自动解密 password 字段- 注册时存入数据库的 password 是 加密后的二进制字符串
- 登录时 mongoose-encryption 自动解密,对比密码
3.4 问题
仍有漏洞
黑客入侵服务器 → 找到 app.js → 获取加密密钥(secret)→ 解密所有密码
只要密码可以被"还原"为明文,就不够安全。
四、环境变量(dotenv)
4.1 问题:密钥泄露
javascript
// ❌ 密钥硬编码在代码中
const secret = "ThisisourLittlesecret.";
// 提交到 GitHub → 全世界都能看到你的密钥!真实案例: 开发者将 AWS 密钥提交到 GitHub → 被自动爬虫发现 → 被用于挖矿 → 产生 $3000+ 的账单。
4.2 解决方案:dotenv
bash
npm i dotenvjavascript
// app.js 最顶部(必须在最前面)
require("dotenv").config();4.3 创建 .env 文件
bash
touch .env # 在项目根目录创建env
SECRET=ThisisourLittlesecret.
API_KEY=your-api-key-here.env 格式规则
NAME=VALUE(无空格、无引号、无分号)- 变量名使用 全大写 + 下划线(SNAKE_CASE)
- 每行一个变量
4.4 使用环境变量
javascript
// 通过 process.env 访问
const secret = process.env.SECRET;
console.log(process.env.API_KEY);4.5 .gitignore — 防止提交
gitignore
# .gitignore 文件
node_modules/
.env # ← 关键!不提交 .env注意
如果你在添加 .gitignore 之前已经提交过 .env → Git 历史中仍然有密钥!
最佳实践: 项目创建之初就配置 .gitignore + .env。
4.6 部署时的处理
| 平台 | 环境变量设置方式 |
|---|---|
| Heroku | Dashboard → Settings → Config Vars |
| Vercel | Project Settings → Environment Variables |
| AWS | Parameter Store / Secrets Manager |
五、Level 3 — 哈希(Hash)
5.1 哈希 vs 加密
| 特征 | 加密(Encryption) | 哈希(Hash) |
|---|---|---|
| 可逆性 | ✅ 可解密 | ❌ 不可逆 |
| 需要密钥 | ✅ 是 | ❌ 否 |
| 安全性 | 密钥泄露 → 全部暴露 | 无密钥可泄露 |
| 类比 | 上锁的箱子(有钥匙就能开) | 绞肉机(不可能还原) |
5.2 哈希函数的数学原理
正向计算(极快): 13 × 29 = 377 ← 毫秒级
反向计算(极慢): 377 的因子是? → 逐个试 ← 耗时很长💡 真正的哈希函数远比因式分解复杂,使反向计算在当前算力下 不可行。
5.3 使用 MD5
bash
npm i md5javascript
const md5 = require("md5");
// 注册:哈希密码后存储
app.post("/register", (req, res) => {
const newUser = new User({
email: req.body.username,
password: md5(req.body.password) // "123456" → "e10adc..."
});
newUser.save(/* ... */);
});
// 登录:哈希输入的密码,与数据库中的哈希比较
app.post("/login", (req, res) => {
User.findOne({ email: username }, (err, foundUser) => {
if (foundUser.password === md5(password)) {
res.render("secrets"); // 哈希匹配 → 密码正确
}
});
});5.4 核心特性
相同输入 → 永远产生相同哈希
md5("123456") → 总是 "e10adc3949ba59abbe56e057f20f883e"六、黑客如何破解哈希
6.1 哈希表攻击(Hash Table Attack)
如果数据库中多个用户有相同的哈希
→ 说明他们使用了相同的密码
→ 黑客构建"密码→哈希"对照表
→ 反查就能知道原始密码6.2 构建哈希表
| 来源 | 组合数量 |
|---|---|
| 字典中所有单词 | ~150,000 |
| 电话号码簿 | ~数百万 |
| 所有 ≤6 位字符组合 | ~数十亿 |
| 总计 | ~19.8 亿 |
用最新 GPU 计算 19.8 亿个 MD5 哈希需要多久?
0.9 秒。 (最新 GPU 每秒可计算 200 亿 个 MD5 哈希)
6.3 常见弱密码 Top 5
123456passwordqwerty111111123456789
6.4 密码强度的关键
6 位随机密码 → 标准 PC 3 秒破解
12 位随机密码 → 标准 PC 31 年,快速 GPU 2 年💡 密码长度 >> 密码复杂度。 增加字符数的效果是 指数级 的。
6.5 实用工具
| 工具 | 用途 |
|---|---|
| haveibeenpwned.com | 检查你的邮箱是否在数据泄露中 |
| plaintextoffenders.com | 曝光以明文存储密码的网站 |
七、Level 4 — 加盐哈希(bcrypt)
7.1 盐(Salt)是什么
普通哈希:password → hash(password) → 存入数据库
加盐哈希:password + 随机盐 → hash(password + salt) → 存入数据库- 盐 = 随机生成的字符串,每个用户不同
- 即使两个用户密码相同,因为盐不同 → 哈希也不同
- 盐存储在数据库中(不需要保密,但需要保存)
7.2 Salt Rounds(加盐轮数)
第 1 轮:hash(password + salt) → hash1
第 2 轮:hash(hash1 + salt) → hash2
第 3 轮:hash(hash2 + salt) → hash3
...
第 N 轮:hash(hashN-1 + salt) → 最终哈希每增加 1 轮 → 计算时间 翻倍。
7.3 MD5 vs bcrypt
| 指标 | MD5 | bcrypt |
|---|---|---|
| 每秒哈希数(最新 GPU) | 200 亿 | 17,000 |
| 构建加盐哈希表耗时 | ~3 秒 | ~8 个月 |
| 设计目的 | 快速校验 | 故意设计得慢 |
7.4 使用 bcrypt
bash
npm i bcryptjavascript
const bcrypt = require("bcrypt");
const saltRounds = 10; // 2024-2025 推荐值
// 注册:生成盐 + 哈希
app.post("/register", (req, res) => {
bcrypt.hash(req.body.password, saltRounds, (err, hash) => {
// hash 已包含盐信息
const newUser = new User({
email: req.body.username,
password: hash // 存储哈希(内含盐)
});
newUser.save(/* ... */);
});
});
// 登录:用 bcrypt.compare 对比
app.post("/login", (req, res) => {
User.findOne({ email: username }, (err, foundUser) => {
if (foundUser) {
bcrypt.compare(password, foundUser.password, (err, result) => {
if (result === true) {
res.render("secrets");
}
});
}
});
});7.5 Salt Rounds 与时间
| Salt Rounds | 每次哈希耗时 |
|---|---|
| 10 | ~100ms |
| 12 | ~400ms |
| 15 | ~3s |
| 20 | ~1.5min |
| 31 | ~2-3 天 |
💡 推荐值:10(2024-2025),随硬件提升逐年增加。
八、Level 5 — Cookie 与 Session
8.1 Cookie 是什么
Cookie = 网站存储在 浏览器 上的小数据,类似 幸运饼干(里面有一条信息)。
Day 1:
浏览器 → GET amazon.com → 服务器返回首页
浏览器 → POST "加入购物车:电脑" → 服务器创建 Cookie → 返回给浏览器保存
Day 2:
浏览器 → GET amazon.com(Cookie 随请求发送)→ 服务器读取 Cookie → "这个用户昨天想买电脑"
→ 返回页面 + 购物车中已有电脑8.2 Session 是什么
Session = 浏览器与服务器的一次 交互周期。
登录 → Session 开始 → Cookie 创建(包含 Session ID)
浏览网站 → Cookie 随每次请求发送 → 服务器验证 Session
登出 → Session 结束 → Cookie 销毁8.3 使用 Passport.js
安装
bash
npm i passport passport-local passport-local-mongoose express-session注意
是 express-session(单数),不是 express-sessions(复数)!
配置(顺序很重要!)
javascript
// 1. 引入包
const session = require("express-session");
const passport = require("passport");
const passportLocalMongoose = require("passport-local-mongoose");
// 2. 配置 Session(在 mongoose.connect 之前)
app.use(session({
secret: "Our little secret.",
resave: false,
saveUninitialized: false
}));
// 3. 初始化 Passport(紧跟 session 之后)
app.use(passport.initialize());
app.use(passport.session());
// 4. Schema 添加插件
userSchema.plugin(passportLocalMongoose);
// 5. 创建 Model 后,配置序列化/反序列化
passport.use(User.createStrategy());
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());代码顺序
session 配置 → passport.initialize → passport.session
→ Schema 插件 → Model 创建 → serialize/deserialize
→ 路由定义
顺序错误 = 无法工作!序列化与反序列化
| 概念 | 作用 | 类比 |
|---|---|---|
| Serialize | 将用户信息 塞入 Cookie | 把信息塞进幸运饼干 |
| Deserialize | 从 Cookie 中 提取 用户信息 | 掰开饼干读取信息 |
注册
javascript
app.post("/register", (req, res) => {
User.register(
{ username: req.body.username }, // 用户名
req.body.password, // 密码
(err, user) => {
if (err) {
console.log(err);
res.redirect("/register");
} else {
passport.authenticate("local")(req, res, () => {
res.redirect("/secrets"); // 认证成功 → 重定向
});
}
}
);
});登录
javascript
app.post("/login", (req, res) => {
const user = new User({
username: req.body.username,
password: req.body.password
});
req.login(user, (err) => {
if (err) {
console.log(err);
} else {
passport.authenticate("local")(req, res, () => {
res.redirect("/secrets");
});
}
});
});受保护的路由
javascript
app.get("/secrets", (req, res) => {
if (req.isAuthenticated()) {
res.render("secrets"); // ✅ 已登录 → 显示页面
} else {
res.redirect("/login"); // ❌ 未登录 → 强制登录
}
});登出
javascript
app.get("/logout", (req, res) => {
req.logout();
res.redirect("/");
});8.4 Session 生命周期
注册/登录 → passport.authenticate("local")
→ 创建 Session → 序列化用户 → Cookie 保存到浏览器
→ 后续请求自动携带 Cookie → 服务器反序列化 → 验证身份
关闭浏览器 → Cookie 过期 → Session 结束 → 需要重新登录
服务器重启 → Session 丢失 → 需要重新登录九、Level 6 — OAuth 2.0
9.1 什么是 OAuth
OAuth = Open Authorization — 开放授权标准。
允许用户通过第三方(Google/Facebook)登录你的网站,无需向你透露密码。
9.2 为什么用 OAuth
| 优势 | 说明 |
|---|---|
| 安全委托 | 密码管理交给 Google/Facebook,他们有更强的安全团队 |
| 用户体验 | 一键登录,无需注册新账户 |
| 数据获取 | 经用户授权,可访问第三方数据(联系人、好友列表等) |
| 减少责任 | 不存储用户密码 = 泄露风险大幅降低 |
9.3 OAuth 三大特性
| 特性 | 说明 |
|---|---|
| 细粒度权限 | 可指定需要的数据范围(profile/email/friends) |
| 读写控制 | 只读(获取信息)或读写(代发帖子) |
| 可撤销 | 用户可随时在第三方平台取消授权 |
9.4 OAuth 2.0 流程
1. 开发者注册应用
在 Google Developer Console 注册 → 获取 Client ID + Client Secret
2. 用户点击"使用 Google 登录"
→ 跳转到 Google 登录页面(用户在 Google 的界面上操作)
3. 用户登录并授权
→ 用户同意授予你的应用所请求的权限(profile, email)
4. Google 返回 Authorization Code
→ 你的服务器收到授权码 → 确认用户身份
5. 交换 Access Token(可选)
→ 用授权码换取 Access Token → 可长期访问用户授权的数据Authorization Code = 🎫 单次入场券(一次性,只用于认证)
Access Token = 🪪 年卡(长期有效,可反复获取数据)9.5 实现 Google OAuth
步骤 1:Google Developer Console 配置
- 创建项目 → 设置 OAuth Consent Screen
- 创建 OAuth 2.0 凭据 → 获取 Client ID 和 Client Secret
- 设置 Authorized Redirect URI:
http://localhost:3000/auth/google/secrets - 将凭据保存到
.env:
env
CLIENT_ID=your-google-client-id
CLIENT_SECRET=your-google-client-secret步骤 2:安装与配置
bash
npm i passport-google-oauth20 mongoose-findorcreatejavascript
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const findOrCreate = require("mongoose-findorcreate");
// Schema 中添加 Google ID 字段
const userSchema = new mongoose.Schema({
email: String,
password: String,
googleId: String // ← 新增:关联 Google 账户
});
// 添加插件
userSchema.plugin(passportLocalMongoose);
userSchema.plugin(findOrCreate);
const User = new mongoose.model("User", userSchema);步骤 3:配置通用序列化(支持所有策略)
javascript
// 替换 passport-local-mongoose 的简化版本
// 使用 passport 通用版本(兼容所有认证策略)
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
User.findById(id, (err, user) => {
done(err, user);
});
});步骤 4:配置 Google 策略
javascript
passport.use(new GoogleStrategy({
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: "http://localhost:3000/auth/google/secrets",
userProfileURL: "https://www.googleapis.com/oauth2/v3/userinfo"
// ↑ 使用 userinfo 端点(Google+ API 已弃用)
},
(accessToken, refreshToken, profile, cb) => {
// profile.id = Google 用户唯一 ID
User.findOrCreate({ googleId: profile.id }, (err, user) => {
return cb(err, user);
});
}
));步骤 5:路由设置
javascript
// 发起 Google 登录
app.get("/auth/google",
passport.authenticate("google", { scope: ["profile"] })
);
// Google 回调(认证后重定向)
app.get("/auth/google/secrets",
passport.authenticate("google", { failureRedirect: "/login" }),
(req, res) => {
res.redirect("/secrets");
}
);步骤 6:前端按钮
html
<a class="btn btn-social btn-google" href="/auth/google" role="button">
<i class="fab fa-google"></i> Sign Up with Google
</a>9.6 数据库中的 OAuth 用户
json
{
"_id": "ObjectId(...)",
"googleId": "104721833...", // Google 唯一标识
"username": "user@gmail.com"
// 没有 password!密码由 Google 管理
}💡 同一 Google 用户再次登录 →
findOrCreate找到已有记录 → 不会创建重复用户。
十、六级安全对比
| 级别 | 方法 | 密码存储形式 | 主要弱点 |
|---|---|---|---|
| L1 | 明文 | 123456 | 任何人可直接读取 |
| L2 | 加密(AES) | 二进制密文 | 密钥泄露 → 全部解密 |
| L3 | 哈希(MD5) | e10adc3949ba... | 哈希表/字典攻击可破解 |
| L4 | 加盐哈希(bcrypt) | $2b$10$xyz... | 理论上可破解但耗时极长 |
| L5 | Cookie/Session | 不直接暴露 | Session 管理复杂 |
| L6 | OAuth 2.0 | 不存储密码 | 依赖第三方服务可用性 |
十一、核心包速查
| 包名 | 用途 |
|---|---|
mongoose-encryption | AES 加密/解密 mongoose 字段 |
dotenv | 环境变量管理 |
md5 | MD5 哈希(不推荐生产使用) |
bcrypt | 加盐哈希(推荐 saltRounds=10) |
express-session | Session 管理 |
passport | 认证框架 |
passport-local | 本地策略(用户名 + 密码) |
passport-local-mongoose | Passport + Mongoose 集成 |
passport-google-oauth20 | Google OAuth 2.0 策略 |
mongoose-findorcreate | findOrCreate 方法实现 |
十二、完整项目结构
secrets-project/
├── .env # 密钥(不提交到 Git)
├── .gitignore # 忽略 .env 和 node_modules
├── app.js # 主服务器
├── package.json
├── public/
│ └── css/
│ ├── styles.css
│ └── bootstrap-social.css
└── views/
├── partials/
│ ├── header.ejs
│ └── footer.ejs
├── home.ejs
├── login.ejs # 本地登录 + Google 登录按钮
├── register.ejs # 本地注册 + Google 注册按钮
├── secrets.ejs # 需要认证才能访问
└── submit.ejs