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 应用。
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!