Passport.js 本地策略 #
什么是本地策略? #
本地策略(Local Strategy)是 Passport.js 最基础的认证策略,使用用户名和密码进行认证。它适合传统的登录表单场景,是最常用的认证方式之一。
text
┌─────────────────────────────────────────────────────────────┐
│ 本地认证流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户 ──── 提交表单 ────> Express 服务器 │
│ │ │ │
│ │ │ Passport 验证 │
│ │ ▼ │
│ │ 查询数据库 │
│ │ │ │
│ │ │ 验证密码 │
│ │ ▼ │
│ │ 创建会话 │
│ │ │ │
│ └───────────────────> 返回结果 │
│ │
└─────────────────────────────────────────────────────────────┘
安装依赖 #
bash
# 安装本地策略
npm install passport-local
# 安装密码加密库
npm install bcryptjs
基本配置 #
策略配置 #
javascript
// config/passport.js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcryptjs');
const User = require('../models/User');
passport.use(new LocalStrategy(
{
usernameField: 'username',
passwordField: 'password',
passReqToCallback: false
},
async (username, password, done) => {
try {
// 查找用户
const user = await User.findOne({ username });
// 用户不存在
if (!user) {
return done(null, false, { message: '用户名不存在' });
}
// 验证密码
const isMatch = await bcrypt.compare(password, user.password);
// 密码错误
if (!isMatch) {
return done(null, false, { message: '密码错误' });
}
// 认证成功
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// 序列化用户
passport.serializeUser((user, done) => {
done(null, user.id);
});
// 反序列化用户
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (error) {
done(error);
}
});
module.exports = passport;
配置选项 #
javascript
new LocalStrategy({
usernameField: 'username', // 用户名字段名,默认 'username'
passwordField: 'password', // 密码字段名,默认 'password'
passReqToCallback: false, // 是否传递 req 到回调,默认 false
session: true // 是否使用会话,默认 true
}, verifyCallback);
用户模型 #
Mongoose 模型 #
javascript
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, '用户名不能为空'],
unique: true,
trim: true,
minlength: [3, '用户名至少3个字符'],
maxlength: [20, '用户名最多20个字符']
},
email: {
type: String,
required: [true, '邮箱不能为空'],
unique: true,
trim: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, '请输入有效的邮箱地址']
},
password: {
type: String,
required: [true, '密码不能为空'],
minlength: [6, '密码至少6个字符']
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
isActive: {
type: Boolean,
default: true
},
lastLogin: {
type: Date
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// 保存前加密密码
userSchema.pre('save', async function(next) {
// 只在密码修改时加密
if (!this.isModified('password')) {
return next();
}
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// 更新时间
userSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});
// 验证密码方法
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// 转换为 JSON 时隐藏密码
userSchema.methods.toJSON = function() {
const user = this.toObject();
delete user.password;
return user;
};
module.exports = mongoose.model('User', userSchema);
Sequelize 模型(MySQL) #
javascript
// models/User.js
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');
const sequelize = require('../config/database');
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
username: {
type: DataTypes.STRING(20),
allowNull: false,
unique: true
},
email: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true
},
password: {
type: DataTypes.STRING(255),
allowNull: false
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user'
}
}, {
tableName: 'users',
timestamps: true,
hooks: {
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
}
}
});
User.prototype.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = User;
注册功能 #
注册路由 #
javascript
// routes/auth.js
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const { ensureNotAuthenticated } = require('../middleware/auth');
// 注册页面
router.get('/register', ensureNotAuthenticated, (req, res) => {
res.render('auth/register', {
title: '注册',
errors: req.flash('errors'),
formData: req.flash('formData')[0] || {}
});
});
// 注册处理
router.post('/register', ensureNotAuthenticated, async (req, res) => {
const { username, email, password, confirmPassword } = req.body;
const errors = [];
// 验证必填字段
if (!username || !email || !password || !confirmPassword) {
errors.push({ msg: '请填写所有必填字段' });
}
// 验证用户名长度
if (username && (username.length < 3 || username.length > 20)) {
errors.push({ msg: '用户名长度应在3-20个字符之间' });
}
// 验证邮箱格式
const emailRegex = /^\S+@\S+\.\S+$/;
if (email && !emailRegex.test(email)) {
errors.push({ msg: '请输入有效的邮箱地址' });
}
// 验证密码长度
if (password && password.length < 6) {
errors.push({ msg: '密码至少6个字符' });
}
// 验证两次密码一致
if (password !== confirmPassword) {
errors.push({ msg: '两次输入的密码不一致' });
}
// 有错误,返回注册页面
if (errors.length > 0) {
req.flash('errors', errors);
req.flash('formData', { username, email });
return res.redirect('/auth/register');
}
try {
// 检查用户名是否已存在
const existingUsername = await User.findOne({ username });
if (existingUsername) {
errors.push({ msg: '用户名已被使用' });
}
// 检查邮箱是否已存在
const existingEmail = await User.findOne({ email });
if (existingEmail) {
errors.push({ msg: '邮箱已被注册' });
}
if (errors.length > 0) {
req.flash('errors', errors);
req.flash('formData', { username, email });
return res.redirect('/auth/register');
}
// 创建用户
const user = new User({
username,
email,
password
});
await user.save();
req.flash('success', '注册成功,请登录');
res.redirect('/auth/login');
} catch (error) {
console.error(error);
req.flash('errors', [{ msg: '注册失败,请稍后重试' }]);
res.redirect('/auth/register');
}
});
module.exports = router;
登录功能 #
登录路由 #
javascript
// routes/auth.js (续)
const passport = require('passport');
// 登录页面
router.get('/login', ensureNotAuthenticated, (req, res) => {
res.render('auth/login', {
title: '登录',
error: req.flash('error'),
success: req.flash('success')
});
});
// 登录处理 - 基本方式
router.post('/login', ensureNotAuthenticated, (req, res, next) => {
passport.authenticate('local', {
successRedirect: '/dashboard',
failureRedirect: '/auth/login',
failureFlash: true
})(req, res, next);
});
// 登录处理 - 自定义回调
router.post('/login-api', ensureNotAuthenticated, (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).json({
success: false,
message: info.message || '登录失败'
});
}
req.logIn(user, (err) => {
if (err) {
return next(err);
}
return res.json({
success: true,
message: '登录成功',
user: user.toJSON()
});
});
})(req, res, next);
});
// 登录处理 - 记住我功能
router.post('/login-remember', ensureNotAuthenticated, (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) {
req.flash('error', info.message);
return res.redirect('/auth/login');
}
req.logIn(user, (err) => {
if (err) return next(err);
// 记住我:设置更长的会话有效期
if (req.body.remember) {
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 天
} else {
req.session.cookie.expires = false; // 浏览器关闭时过期
}
return res.redirect('/dashboard');
});
})(req, res, next);
});
更新最后登录时间 #
javascript
// 在策略中更新
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await User.findOne({ username });
if (!user) {
return done(null, false, { message: '用户名不存在' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return done(null, false, { message: '密码错误' });
}
// 更新最后登录时间
user.lastLogin = new Date();
await user.save();
return done(null, user);
} catch (error) {
return done(error);
}
}
));
登出功能 #
javascript
// routes/auth.js (续)
const { ensureAuthenticated } = require('../middleware/auth');
// 登出
router.get('/logout', ensureAuthenticated, (req, res, next) => {
const username = req.user.username;
req.logout((err) => {
if (err) return next(err);
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie('connect.sid');
req.flash('success', `${username},您已成功登出`);
res.redirect('/auth/login');
});
});
});
密码管理 #
修改密码 #
javascript
// routes/auth.js (续)
// 修改密码页面
router.get('/change-password', ensureAuthenticated, (req, res) => {
res.render('auth/change-password', {
title: '修改密码',
errors: req.flash('errors')
});
});
// 修改密码处理
router.post('/change-password', ensureAuthenticated, async (req, res) => {
const { currentPassword, newPassword, confirmPassword } = req.body;
const errors = [];
// 验证
if (!currentPassword || !newPassword || !confirmPassword) {
errors.push({ msg: '请填写所有字段' });
}
if (newPassword && newPassword.length < 6) {
errors.push({ msg: '新密码至少6个字符' });
}
if (newPassword !== confirmPassword) {
errors.push({ msg: '两次输入的新密码不一致' });
}
if (errors.length > 0) {
req.flash('errors', errors);
return res.redirect('/auth/change-password');
}
try {
const user = await User.findById(req.user.id);
// 验证当前密码
const isMatch = await user.comparePassword(currentPassword);
if (!isMatch) {
req.flash('errors', [{ msg: '当前密码错误' }]);
return res.redirect('/auth/change-password');
}
// 更新密码
user.password = newPassword;
await user.save();
req.flash('success', '密码修改成功,请重新登录');
req.logout();
res.redirect('/auth/login');
} catch (error) {
console.error(error);
req.flash('errors', [{ msg: '修改密码失败' }]);
res.redirect('/auth/change-password');
}
});
重置密码 #
javascript
// routes/auth.js (续)
const crypto = require('crypto');
// 忘记密码页面
router.get('/forgot-password', ensureNotAuthenticated, (req, res) => {
res.render('auth/forgot-password', {
title: '忘记密码',
message: req.flash('message')
});
});
// 发送重置邮件
router.post('/forgot-password', ensureNotAuthenticated, async (req, res) => {
const { email } = req.body;
try {
const user = await User.findOne({ email });
if (!user) {
req.flash('message', '如果该邮箱已注册,您将收到重置邮件');
return res.redirect('/auth/forgot-password');
}
// 生成重置令牌
const token = crypto.randomBytes(20).toString('hex');
user.resetPasswordToken = token;
user.resetPasswordExpires = Date.now() + 3600000; // 1小时
await user.save();
// 发送邮件(示例)
// await sendEmail({
// to: user.email,
// subject: '密码重置',
// text: `请点击以下链接重置密码:http://localhost:3000/auth/reset-password/${token}`
// });
req.flash('message', '重置邮件已发送,请查收');
res.redirect('/auth/forgot-password');
} catch (error) {
console.error(error);
req.flash('message', '发送失败,请稍后重试');
res.redirect('/auth/forgot-password');
}
});
// 重置密码页面
router.get('/reset-password/:token', async (req, res) => {
const { token } = req.params;
try {
const user = await User.findOne({
resetPasswordToken: token,
resetPasswordExpires: { $gt: Date.now() }
});
if (!user) {
req.flash('error', '重置链接无效或已过期');
return res.redirect('/auth/forgot-password');
}
res.render('auth/reset-password', {
title: '重置密码',
token,
errors: req.flash('errors')
});
} catch (error) {
console.error(error);
res.redirect('/auth/forgot-password');
}
});
// 重置密码处理
router.post('/reset-password/:token', async (req, res) => {
const { token } = req.params;
const { password, confirmPassword } = req.body;
const errors = [];
if (!password || password.length < 6) {
errors.push({ msg: '密码至少6个字符' });
}
if (password !== confirmPassword) {
errors.push({ msg: '两次密码不一致' });
}
if (errors.length > 0) {
req.flash('errors', errors);
return res.redirect(`/auth/reset-password/${token}`);
}
try {
const user = await User.findOne({
resetPasswordToken: token,
resetPasswordExpires: { $gt: Date.now() }
});
if (!user) {
req.flash('error', '重置链接无效或已过期');
return res.redirect('/auth/forgot-password');
}
// 更新密码
user.password = password;
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
req.flash('success', '密码重置成功,请登录');
res.redirect('/auth/login');
} catch (error) {
console.error(error);
req.flash('errors', [{ msg: '重置失败,请稍后重试' }]);
res.redirect(`/auth/reset-password/${token}`);
}
});
验证增强 #
使用邮箱登录 #
javascript
// config/passport.js
passport.use('local-email', new LocalStrategy(
{
usernameField: 'email',
passwordField: 'password'
},
async (email, password, done) => {
try {
const user = await User.findOne({ email: email.toLowerCase() });
if (!user) {
return done(null, false, { message: '邮箱未注册' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return done(null, false, { message: '密码错误' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
同时支持用户名和邮箱 #
javascript
// config/passport.js
passport.use('local', new LocalStrategy(
{
usernameField: 'login',
passwordField: 'password'
},
async (login, password, done) => {
try {
// 尝试通过用户名或邮箱查找
const user = await User.findOne({
$or: [
{ username: login },
{ email: login.toLowerCase() }
]
});
if (!user) {
return done(null, false, { message: '用户不存在' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return done(null, false, { message: '密码错误' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
登录失败次数限制 #
javascript
// models/User.js 添加字段
const userSchema = new mongoose.Schema({
// ... 其他字段
loginAttempts: {
type: Number,
default: 0
},
lockUntil: {
type: Date
}
});
// 检查是否锁定
userSchema.virtual('isLocked').get(function() {
return !!(this.lockUntil && this.lockUntil > Date.now());
});
// 增加登录尝试次数
userSchema.methods.incLoginAttempts = function() {
// 如果锁定已过期,重置
if (this.lockUntil && this.lockUntil < Date.now()) {
return this.updateOne({
$set: { loginAttempts: 1 },
$unset: { lockUntil: 1 }
});
}
// 增加次数
const updates = { $inc: { loginAttempts: 1 } };
// 如果达到最大次数,锁定账户
if (this.loginAttempts + 1 >= 5) {
updates.$set = { lockUntil: Date.now() + 2 * 60 * 60 * 1000 }; // 锁定2小时
}
return this.updateOne(updates);
};
// 重置登录尝试
userSchema.methods.resetLoginAttempts = function() {
return this.updateOne({
$set: { loginAttempts: 0 },
$unset: { lockUntil: 1 }
});
};
javascript
// config/passport.js
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await User.findOne({ username });
if (!user) {
return done(null, false, { message: '用户不存在' });
}
// 检查是否锁定
if (user.isLocked) {
return done(null, false, { message: '账户已锁定,请稍后再试' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
await user.incLoginAttempts();
return done(null, false, { message: '密码错误' });
}
// 登录成功,重置尝试次数
if (user.loginAttempts > 0) {
await user.resetLoginAttempts();
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
完整示例 #
项目结构 #
text
my-auth-app/
├── config/
│ └── passport.js
├── models/
│ └── User.js
├── routes/
│ └── auth.js
├── middleware/
│ └── auth.js
├── views/
│ └── auth/
│ ├── login.ejs
│ ├── register.ejs
│ ├── change-password.ejs
│ └── forgot-password.ejs
├── app.js
└── package.json
使用示例 #
javascript
// app.js
const express = require('express');
const session = require('express-session');
const flash = require('connect-flash');
const passport = require('./config/passport');
const app = express();
// 中间件配置
app.use(express.urlencoded({ extended: true }));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());
// 路由
app.use('/auth', require('./routes/auth'));
// 受保护路由
app.get('/dashboard', (req, res) => {
if (!req.isAuthenticated()) {
return res.redirect('/auth/login');
}
res.render('dashboard', { user: req.user });
});
app.listen(3000);
下一步 #
现在你已经掌握了本地策略的使用,接下来学习 OAuth策略,实现第三方登录功能!
最后更新:2026-03-28