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