Skip to content

Feathers.js 认证与授权 - 构建安全的API系统

发布时间:2024-05-29
作者:一介布衣
标签:Feathers.js, 认证, 授权, JWT, 安全

前言

上一篇文章我们深入学习了钩子系统,今天咱们来重点学习 Feathers.js 的认证与授权系统。说实话,安全性是任何 Web 应用都不能忽视的重要环节,而 Feathers.js 在这方面提供了非常完善的解决方案。

我记得刚开始做 Web 开发的时候,总是把认证和授权搞混,以为登录了就万事大吉。后来踩了不少坑才明白,认证只是确认"你是谁",而授权是确认"你能做什么"。Feathers.js 的认证系统设计得很巧妙,不仅支持多种认证策略,还能很好地与钩子系统结合实现细粒度的权限控制。

今天我就带大家从基础的用户认证开始,一步步构建一个完整的安全系统。

认证系统概述

认证 vs 授权

javascript
// 认证 (Authentication) - 确认用户身份
"你是 John Doe 吗?" → 验证用户名密码、JWT token 等

// 授权 (Authorization) - 确认用户权限  
"John Doe 可以删除这篇文章吗?" → 检查角色、权限、资源所有权等

Feathers.js 认证架构

javascript
// 认证流程
Client Request

Authentication Strategy (local, jwt, oauth)

User Verification

Token Generation/Validation

User Object in context.params.user

Authorization Hooks

Service Method Execution

基础认证配置

1. 安装认证插件

bash
npm install @feathersjs/authentication @feathersjs/authentication-local @feathersjs/authentication-oauth

2. 认证配置

javascript
// config/default.json
{
  "authentication": {
    "secret": "your-super-secret-key",
    "strategies": ["jwt", "local"],
    "path": "/authentication",
    "service": "users",
    "jwt": {
      "header": { "typ": "access" },
      "audience": "https://yourdomain.com",
      "subject": "anonymous",
      "issuer": "feathers",
      "algorithm": "HS256",
      "expiresIn": "1d"
    },
    "local": {
      "usernameField": "email",
      "passwordField": "password"
    }
  }
}

3. 应用配置

javascript
// src/authentication.js
const { AuthenticationService, JWTStrategy } = require('@feathersjs/authentication');
const { LocalStrategy } = require('@feathersjs/authentication-local');
const { expressOauth } = require('@feathersjs/authentication-oauth');

module.exports = app => {
  const authentication = new AuthenticationService(app);

  authentication.register('jwt', new JWTStrategy());
  authentication.register('local', new LocalStrategy());

  app.use('/authentication', authentication);
  app.configure(expressOauth());
};

用户模型和服务

1. 用户模型

javascript
// src/models/users.model.js
module.exports = function (app) {
  const modelName = 'users';
  const mongooseClient = app.get('mongooseClient');
  const { Schema } = mongooseClient;

  const schema = new Schema({
    email: {
      type: String,
      unique: true,
      lowercase: true,
      required: true,
      validate: {
        validator: function(v) {
          return /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v);
        },
        message: '邮箱格式不正确'
      }
    },
    password: {
      type: String,
      required: true,
      minlength: 6
    },
    
    // 基本信息
    username: {
      type: String,
      unique: true,
      required: true,
      trim: true,
      minlength: 3,
      maxlength: 30
    },
    firstName: {
      type: String,
      trim: true,
      maxlength: 50
    },
    lastName: {
      type: String,
      trim: true,
      maxlength: 50
    },
    avatar: {
      type: String,
      default: null
    },
    
    // 角色和权限
    role: {
      type: String,
      enum: ['user', 'moderator', 'admin'],
      default: 'user'
    },
    permissions: [{
      type: String,
      enum: [
        'read:posts', 'write:posts', 'delete:posts',
        'read:users', 'write:users', 'delete:users',
        'read:comments', 'write:comments', 'delete:comments',
        'manage:system'
      ]
    }],
    
    // 账户状态
    status: {
      type: String,
      enum: ['active', 'inactive', 'suspended', 'pending'],
      default: 'pending'
    },
    emailVerified: {
      type: Boolean,
      default: false
    },
    emailVerificationToken: {
      type: String,
      default: null
    },
    
    // 安全相关
    lastLoginAt: {
      type: Date,
      default: null
    },
    loginAttempts: {
      type: Number,
      default: 0
    },
    lockUntil: {
      type: Date,
      default: null
    },
    passwordResetToken: {
      type: String,
      default: null
    },
    passwordResetExpires: {
      type: Date,
      default: null
    },
    
    // 偏好设置
    preferences: {
      theme: {
        type: String,
        enum: ['light', 'dark', 'auto'],
        default: 'light'
      },
      language: {
        type: String,
        default: 'zh-CN'
      },
      notifications: {
        email: { type: Boolean, default: true },
        push: { type: Boolean, default: true },
        sms: { type: Boolean, default: false }
      }
    }
  }, {
    timestamps: true
  });

  // 索引
  schema.index({ email: 1 });
  schema.index({ username: 1 });
  schema.index({ role: 1 });
  schema.index({ status: 1 });

  // 虚拟字段
  schema.virtual('fullName').get(function() {
    return `\${this.firstName || ''} \${this.lastName || ''}`.trim();
  });

  schema.virtual('isLocked').get(function() {
    return !!(this.lockUntil && this.lockUntil > Date.now());
  });

  // 实例方法
  schema.methods.toSafeObject = function() {
    const obj = this.toObject();
    delete obj.password;
    delete obj.emailVerificationToken;
    delete obj.passwordResetToken;
    delete obj.loginAttempts;
    delete obj.lockUntil;
    return obj;
  };

  schema.methods.hasPermission = function(permission) {
    // 管理员拥有所有权限
    if (this.role === 'admin') {
      return true;
    }
    
    // 检查具体权限
    return this.permissions.includes(permission);
  };

  schema.methods.hasRole = function(roles) {
    const roleArray = Array.isArray(roles) ? roles : [roles];
    return roleArray.includes(this.role);
  };

  schema.methods.canAccess = function(resource, action = 'read') {
    const permission = `\${action}:\${resource}`;
    return this.hasPermission(permission);
  };

  return mongooseClient.model(modelName, schema);
};

2. 用户服务钩子

javascript
// src/services/users/users.hooks.js
const { authenticate } = require('@feathersjs/authentication').hooks;
const { hashPassword, protect } = require('@feathersjs/authentication-local').hooks;
const { disallow, iff, isProvider } = require('feathers-hooks-common');

// 验证用户数据
const validateUser = () => {
  return async context => {
    const { data } = context;
    
    // 检查用户名唯一性
    if (data.username) {
      const existingUser = await context.app.service('users').find({
        query: { 
          username: data.username,
          $limit: 1
        }
      });
      
      if (existingUser.total > 0 && existingUser.data[0]._id.toString() !== context.id) {
        throw new Error('用户名已存在');
      }
    }
    
    // 检查邮箱唯一性
    if (data.email) {
      const existingUser = await context.app.service('users').find({
        query: { 
          email: data.email,
          $limit: 1
        }
      });
      
      if (existingUser.total > 0 && existingUser.data[0]._id.toString() !== context.id) {
        throw new Error('邮箱已被注册');
      }
    }
    
    return context;
  };
};

// 权限检查
const checkPermissions = () => {
  return async context => {
    const { user } = context.params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    // 用户只能查看自己的信息,除非是管理员
    if (context.method === 'get' && context.id) {
      if (context.id !== user._id.toString() && user.role !== 'admin') {
        throw new Error('无权限访问其他用户信息');
      }
    }
    
    // 用户只能修改自己的信息,除非是管理员
    if (['update', 'patch'].includes(context.method)) {
      if (context.id !== user._id.toString() && user.role !== 'admin') {
        throw new Error('无权限修改其他用户信息');
      }
      
      // 普通用户不能修改角色和权限
      if (user.role !== 'admin' && (context.data.role || context.data.permissions)) {
        delete context.data.role;
        delete context.data.permissions;
      }
    }
    
    // 只有管理员可以删除用户
    if (context.method === 'remove' && user.role !== 'admin') {
      throw new Error('无权限删除用户');
    }
    
    return context;
  };
};

// 限制查询结果
const restrictFind = () => {
  return async context => {
    const { user } = context.params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    // 普通用户只能查看激活的用户基本信息
    if (user.role !== 'admin') {
      context.params.query.status = 'active';
      context.params.query.$select = ['_id', 'username', 'firstName', 'lastName', 'avatar'];
    }
    
    return context;
  };
};

// 设置默认值
const setDefaults = () => {
  return context => {
    if (context.data) {
      // 新用户默认状态
      if (context.method === 'create') {
        context.data.status = 'pending';
        context.data.emailVerified = false;
        context.data.role = context.data.role || 'user';
      }
    }
    return context;
  };
};

module.exports = {
  before: {
    all: [],
    find: [
      iff(isProvider('external'), authenticate('jwt')),
      iff(isProvider('external'), restrictFind())
    ],
    get: [
      iff(isProvider('external'), authenticate('jwt')),
      iff(isProvider('external'), checkPermissions())
    ],
    create: [
      hashPassword('password'),
      validateUser(),
      setDefaults()
    ],
    update: [
      iff(isProvider('external'), authenticate('jwt')),
      iff(isProvider('external'), checkPermissions()),
      hashPassword('password'),
      validateUser()
    ],
    patch: [
      iff(isProvider('external'), authenticate('jwt')),
      iff(isProvider('external'), checkPermissions()),
      hashPassword('password'),
      validateUser()
    ],
    remove: [
      iff(isProvider('external'), authenticate('jwt')),
      iff(isProvider('external'), checkPermissions())
    ]
  },

  after: {
    all: [
      // 确保密码字段不会被返回
      protect('password')
    ],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  },

  error: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: []
  }
};

高级认证策略

1. 自定义认证策略

javascript
// src/authentication/custom-strategy.js
const { AuthenticationBaseStrategy } = require('@feathersjs/authentication');

class ApiKeyStrategy extends AuthenticationBaseStrategy {
  async authenticate(authentication, params) {
    const { apiKey } = authentication;
    
    if (!apiKey) {
      throw new Error('API Key 不能为空');
    }
    
    // 验证 API Key
    const user = await this.app.service('users').find({
      query: {
        apiKey: apiKey,
        status: 'active'
      }
    });
    
    if (user.total === 0) {
      throw new Error('无效的 API Key');
    }
    
    return {
      authentication: { strategy: this.name },
      user: user.data[0]
    };
  }
}

module.exports = ApiKeyStrategy;

2. OAuth 认证

javascript
// src/authentication/oauth.js
const { OAuthStrategy } = require('@feathersjs/authentication-oauth');

class GitHubStrategy extends OAuthStrategy {
  async getEntityData(profile, existing, params) {
    const baseData = await super.getEntityData(profile, existing, params);
    
    return {
      ...baseData,
      githubId: profile.id,
      username: profile.login,
      email: profile.email,
      avatar: profile.avatar_url,
      firstName: profile.name ? profile.name.split(' ')[0] : '',
      lastName: profile.name ? profile.name.split(' ').slice(1).join(' ') : '',
      emailVerified: true,
      status: 'active'
    };
  }
  
  async getProfile(authResult, params) {
    const profile = await super.getProfile(authResult, params);
    
    // 获取用户的邮箱(如果公开的话)
    if (!profile.email) {
      try {
        const response = await this.request({
          url: 'https://api.github.com/user/emails',
          headers: {
            authorization: `token \${authResult.access_token}`
          }
        });
        
        const emails = response.data;
        const primaryEmail = emails.find(email => email.primary);
        if (primaryEmail) {
          profile.email = primaryEmail.email;
        }
      } catch (error) {
        console.error('获取 GitHub 邮箱失败:', error);
      }
    }
    
    return profile;
  }
}

// 配置 OAuth
module.exports = app => {
  const authentication = app.service('authentication');
  
  authentication.register('github', new GitHubStrategy());
  
  // 配置 OAuth 路由
  app.get('/oauth/github', authentication.authenticate('github'));
  app.get('/oauth/github/callback', 
    authentication.authenticate('github'),
    (req, res) => {
      res.redirect('/dashboard');
    }
  );
};

3. 多因素认证

javascript
// src/services/mfa/mfa.class.js
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

class MFAService {
  constructor(options, app) {
    this.options = options || {};
    this.app = app;
  }

  // 生成 TOTP 密钥
  async create(data, params) {
    const { user } = params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    const secret = speakeasy.generateSecret({
      name: `\${this.app.get('appName')}:\${user.email}`,
      issuer: this.app.get('appName')
    });
    
    // 保存密钥到用户记录
    await this.app.service('users').patch(user._id, {
      mfaSecret: secret.base32,
      mfaEnabled: false
    });
    
    // 生成二维码
    const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
    
    return {
      secret: secret.base32,
      qrCode: qrCodeUrl,
      manualEntryKey: secret.base32
    };
  }

  // 验证 TOTP 代码
  async update(id, data, params) {
    const { user } = params;
    const { token } = data;
    
    if (!user || !user.mfaSecret) {
      throw new Error('MFA 未设置');
    }
    
    const verified = speakeasy.totp.verify({
      secret: user.mfaSecret,
      encoding: 'base32',
      token: token,
      window: 2  // 允许前后2个时间窗口
    });
    
    if (!verified) {
      throw new Error('验证码错误');
    }
    
    // 启用 MFA
    await this.app.service('users').patch(user._id, {
      mfaEnabled: true
    });
    
    return { message: 'MFA 启用成功' };
  }

  // 验证登录时的 MFA
  async patch(id, data, params) {
    const { userId, token } = data;
    
    const user = await this.app.service('users').get(userId);
    
    if (!user.mfaEnabled || !user.mfaSecret) {
      throw new Error('用户未启用 MFA');
    }
    
    const verified = speakeasy.totp.verify({
      secret: user.mfaSecret,
      encoding: 'base32',
      token: token,
      window: 2
    });
    
    if (!verified) {
      throw new Error('验证码错误');
    }
    
    return { verified: true };
  }

  // 禁用 MFA
  async remove(id, params) {
    const { user } = params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    await this.app.service('users').patch(user._id, {
      mfaSecret: null,
      mfaEnabled: false
    });
    
    return { message: 'MFA 已禁用' };
  }
}

module.exports = MFAService;

权限控制系统

1. 基于角色的访问控制(RBAC)

javascript
// src/hooks/rbac.js
const roles = {
  admin: {
    permissions: ['*'],  // 所有权限
    inherits: []
  },
  moderator: {
    permissions: [
      'read:posts', 'write:posts', 'delete:posts',
      'read:comments', 'write:comments', 'delete:comments',
      'read:users'
    ],
    inherits: ['user']
  },
  user: {
    permissions: [
      'read:posts', 'write:own:posts',
      'read:comments', 'write:comments',
      'read:profile', 'write:own:profile'
    ],
    inherits: []
  }
};

const getUserPermissions = (userRole) => {
  const role = roles[userRole];
  if (!role) return [];
  
  let permissions = [...role.permissions];
  
  // 继承父角色权限
  role.inherits.forEach(inheritedRole => {
    permissions = permissions.concat(getUserPermissions(inheritedRole));
  });
  
  return [...new Set(permissions)];  // 去重
};

const hasPermission = (user, permission, resource = null) => {
  const userPermissions = getUserPermissions(user.role);
  
  // 检查通配符权限
  if (userPermissions.includes('*')) {
    return true;
  }
  
  // 检查精确权限
  if (userPermissions.includes(permission)) {
    return true;
  }
  
  // 检查资源所有权权限
  if (resource && userPermissions.includes(permission.replace(':', ':own:'))) {
    return resource.userId && resource.userId.toString() === user._id.toString();
  }
  
  return false;
};

// 权限检查钩子
const requirePermission = (permission) => {
  return async (context) => {
    const { user } = context.params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    let resource = null;
    
    // 对于需要资源的操作,获取资源信息
    if (context.id && ['get', 'update', 'patch', 'remove'].includes(context.method)) {
      try {
        resource = await context.service.get(context.id, {
          ...context.params,
          query: {}
        });
      } catch (error) {
        // 资源不存在
        throw new Error('资源不存在');
      }
    }
    
    if (!hasPermission(user, permission, resource)) {
      throw new Error(`需要权限: \${permission}`);
    }
    
    return context;
  };
};

module.exports = {
  getUserPermissions,
  hasPermission,
  requirePermission
};

2. 资源级权限控制

javascript
// src/hooks/resource-permissions.js
const { requirePermission } = require('./rbac');

// 动态权限检查
const checkResourcePermission = (resourceType) => {
  return async (context) => {
    const { method, user } = context.params;
    
    const actionMap = {
      find: 'read',
      get: 'read',
      create: 'write',
      update: 'write',
      patch: 'write',
      remove: 'delete'
    };
    
    const action = actionMap[context.method];
    const permission = `\${action}:\${resourceType}`;
    
    return requirePermission(permission)(context);
  };
};

// 字段级权限控制
const filterFields = (allowedFields) => {
  return (context) => {
    const { user } = context.params;
    
    if (user.role === 'admin') {
      return context;  // 管理员可以看到所有字段
    }
    
    const filterData = (data) => {
      const filtered = {};
      allowedFields.forEach(field => {
        if (data[field] !== undefined) {
          filtered[field] = data[field];
        }
      });
      return filtered;
    };
    
    if (context.result) {
      if (Array.isArray(context.result.data)) {
        context.result.data = context.result.data.map(filterData);
      } else {
        context.result = filterData(context.result);
      }
    }
    
    return context;
  };
};

// 使用示例
app.service('posts').hooks({
  before: {
    all: [authenticate('jwt')],
    find: [checkResourcePermission('posts')],
    get: [checkResourcePermission('posts')],
    create: [checkResourcePermission('posts')],
    update: [checkResourcePermission('posts')],
    patch: [checkResourcePermission('posts')],
    remove: [checkResourcePermission('posts')]
  },
  after: {
    all: [
      filterFields(['id', 'title', 'content', 'author', 'createdAt', 'updatedAt'])
    ]
  }
});

安全增强

1. 账户锁定机制

javascript
// src/hooks/account-security.js
const MAX_LOGIN_ATTEMPTS = 5;
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2小时

const handleLoginAttempt = () => {
  return async (context) => {
    const { email } = context.data;
    
    const user = await context.app.service('users').find({
      query: { email, $limit: 1 }
    });
    
    if (user.total === 0) {
      throw new Error('用户不存在');
    }
    
    const userData = user.data[0];
    
    // 检查账户是否被锁定
    if (userData.isLocked) {
      throw new Error(`账户已被锁定,请在 \${new Date(userData.lockUntil).toLocaleString()} 后重试`);
    }
    
    // 将用户信息存储到 context 中,供后续使用
    context.userData = userData;
    
    return context;
  };
};

const handleLoginFailure = () => {
  return async (context) => {
    const { userData } = context;
    
    if (!userData) return context;
    
    const updates = {
      loginAttempts: userData.loginAttempts + 1
    };
    
    // 如果达到最大尝试次数,锁定账户
    if (updates.loginAttempts >= MAX_LOGIN_ATTEMPTS) {
      updates.lockUntil = new Date(Date.now() + LOCK_TIME);
    }
    
    await context.app.service('users').patch(userData._id, updates);
    
    throw new Error(`登录失败,剩余尝试次数: \${MAX_LOGIN_ATTEMPTS - updates.loginAttempts}`);
  };
};

const handleLoginSuccess = () => {
  return async (context) => {
    const { user } = context.result;
    
    // 清除登录尝试记录
    await context.app.service('users').patch(user._id, {
      loginAttempts: 0,
      lockUntil: null,
      lastLoginAt: new Date()
    });
    
    return context;
  };
};

module.exports = {
  handleLoginAttempt,
  handleLoginFailure,
  handleLoginSuccess
};

2. 密码安全策略

javascript
// src/hooks/password-security.js
const bcrypt = require('bcryptjs');

const validatePasswordStrength = () => {
  return (context) => {
    const { password } = context.data;
    
    if (!password) return context;
    
    const minLength = 8;
    const hasUpperCase = /[A-Z]/.test(password);
    const hasLowerCase = /[a-z]/.test(password);
    const hasNumbers = /\d/.test(password);
    const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
    
    const errors = [];
    
    if (password.length < minLength) {
      errors.push(`密码长度至少 \${minLength} 位`);
    }
    
    if (!hasUpperCase) {
      errors.push('密码必须包含大写字母');
    }
    
    if (!hasLowerCase) {
      errors.push('密码必须包含小写字母');
    }
    
    if (!hasNumbers) {
      errors.push('密码必须包含数字');
    }
    
    if (!hasSpecialChar) {
      errors.push('密码必须包含特殊字符');
    }
    
    if (errors.length > 0) {
      throw new Error(`密码强度不足: \${errors.join(', ')}`);
    }
    
    return context;
  };
};

const preventPasswordReuse = (historyCount = 5) => {
  return async (context) => {
    const { password } = context.data;
    const { user } = context.params;
    
    if (!password || !user) return context;
    
    // 获取用户的密码历史
    const passwordHistory = user.passwordHistory || [];
    
    // 检查是否重复使用了最近的密码
    for (const oldPassword of passwordHistory.slice(-historyCount)) {
      if (await bcrypt.compare(password, oldPassword)) {
        throw new Error(`不能重复使用最近 \${historyCount} 次的密码`);
      }
    }
    
    // 更新密码历史
    const newHistory = [...passwordHistory, user.password].slice(-historyCount);
    context.data.passwordHistory = newHistory;
    
    return context;
  };
};

module.exports = {
  validatePasswordStrength,
  preventPasswordReuse
};

总结

通过这篇文章,我们深入学习了 Feathers.js 的认证与授权系统:

认证系统基础

  • 认证 vs 授权的区别
  • JWT 和本地认证策略
  • 用户模型设计

高级认证策略

  • 自定义认证策略
  • OAuth 第三方登录
  • 多因素认证(MFA)

权限控制系统

  • 基于角色的访问控制(RBAC)
  • 资源级权限控制
  • 字段级权限过滤

安全增强措施

  • 账户锁定机制
  • 密码安全策略
  • 登录失败处理

掌握了这些知识,你就能够构建一个安全可靠的 API 系统,包括:

  • 多层次的身份验证
  • 细粒度的权限控制
  • 完善的安全防护机制
  • 用户友好的安全体验

下一篇文章,我们将学习 Feathers.js 的实时功能,看看如何构建强大的 WebSocket 应用。


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!