定义问题:现代化改造一个庞大 Ruby 应用的认证瓶颈
我们维护着一个大型、成熟的 Ruby on Rails 应用。它的核心业务逻辑稳定,但认证模块已成为技术债的重灾区。基于传统密码的登录机制不仅是钓鱼攻击和凭证填充的主要目标,其控制器逻辑也与核心业务代码紧密耦合,导致任何小改动都牵一发而动全身。引入 WebAuthn 这类现代无密码标准的需求日益迫切,但直接在现有代码库中实施,面临着严峻的挑战。
方案A:在 Ruby Monolith 中直接集成 WebAuthn
这是最直接的方案。利用现有的 Ruby 生态,例如 webauthn-ruby
gem,在 Rails 的 UsersController
或专门的 SessionsController
中实现 WebAuthn 的注册和验证流程。
优势分析:
- 架构简单: 无需引入新的服务,减少了运维和部署的复杂性。
- 数据一致性: 用户和凭证数据存储在同一个 PostgreSQL 数据库中,事务管理直观。
- 开发速度快: 复用现有的开发环境、测试框架和部署流水线。
劣势分析:
- 性能与并发模型: WebAuthn 认证流程涉及多次客户端-服务器的往返,这些是短暂、高频的 I/O 密集型操作。Ruby on Rails 在 Puma 等服务器下虽然支持多线程,但其并发性能与为高并发 I/O 设计的运行时(如 Node.js)相比存在差距。在一个已经承载大量业务逻辑的重应用中增加这类负载,可能会影响整体性能。
- 技术栈污染: 在庞大的 monolith 中引入 WebAuthn 这样一套全新的、协议细节复杂的逻辑,会进一步加剧代码库的混乱。它的依赖项(如加密库)可能与现有库冲突。
- 迭代与演进受限: 认证作为一项基础服务,其演进速度可能快于核心业务。将它与 monolith 绑定,意味着每次更新认证逻辑(例如支持 Passkeys 的新特性)都需要对整个应用进行回归测试和部署,风险高,效率低。在真实项目中,这种紧耦合是创新的巨大阻力。
方案B:构建独立的、解耦的认证微服务
该方案主张将认证功能剥离出来,创建一个专门的、轻量级的微服务。这个服务将全权负责 WebAuthn 协议的实现,并通过内部 API 与主 Ruby 应用通信,同步必要的用户和凭证信息。
优势分析:
- 技术异构性: 我们可以为这个特定的任务选择最合适的工具。对于高并发 I/O 场景,Node.js 及其生态(特别是 Fastify 框架)是业界公认的优选。
- 关注点分离: 认证服务的代码库保持纯粹、精简。它只做一件事并把它做好。主应用则可以继续专注于核心业务,两者通过清晰的 API 边界进行交互。
- 独立扩展与部署: 认证服务的流量模式与主应用不同。在登录高峰期,可以独立扩展该服务,而无需扩展整个庞大的 monolith,这在成本和资源利用上更优。
- 安全隔离: 认证是系统的安全门户。将其隔离在一个独立的服务中,可以对其进行更严格的安全加固和监控,缩小攻击面。
劣势分析:
- 架构复杂性增加: 引入了服务间通信、服务发现、额外的部署单元和监控需求。
- 数据一致性挑战: 用户凭证信息现在跨服务存储或管理,需要处理分布式系统中的数据同步和最终一致性问题。
- 网络开销: 服务间的每次调用都带来了网络延迟,尽管在内网环境中这通常是可控的。
最终决策:选择方案 B 并明确技术栈
在生产环境中,长期的可维护性、安全性和性能的可预测性,远比初期的开发便利性更重要。方案 A 的短期优势无法掩盖其长期带来的技术债。因此,我们决定采用方案 B。
技术选型如下:
- 认证服务: **Fastify (Node.js)**。选择 Fastify 是因为它极致的性能、极低的开销和强大的插件生态。它的 schema-based 验证机制能确保 API 的健壮性,而其基于 Pino 的日志系统也为后续的观测性打下了坚实基础。
- 核心后端: Ruby on Rails。保持不变,作为用户数据和核心业务逻辑的权威来源(System of Record)。
- 无密码协议: WebAuthn。使用
simplewebauthn
库,它封装了复杂的协议细节,提供了清晰的服务端和客户端工具。 - 安全审计: Elasticsearch。所有认证相关的事件(成功、失败、挑战生成等)都将作为结构化日志实时推送到 Elasticsearch 集群。这不仅仅是为了调试,更是为了实现实时的安全审计、异常检测和态势感知。一个常见的错误是仅将日志视为排错工具,而忽略了它们在安全领域的巨大价值。
核心实现概览
架构流程图
整个流程涉及浏览器、Fastify 认证服务、Ruby 后端和 Elasticsearch。
sequenceDiagram participant Browser participant FastifyAuthService as Fastify 认证服务 participant RubyBackend as Ruby on Rails 后端 participant Elasticsearch Browser->>FastifyAuthService: 1. 请求注册/登录挑战 FastifyAuthService->>RubyBackend: 2. (内部API) 查询用户信息 RubyBackend-->>FastifyAuthService: 3. 返回用户信息 (或不存在) FastifyAuthService->>Elasticsearch: 4. Log: challenge_generated FastifyAuthService-->>Browser: 5. 返回 WebAuthn 挑战 Browser->>Browser: 6. 用户使用认证器 (指纹/面容/YubiKey) 签名挑战 Browser->>FastifyAuthService: 7. 发送签名后的响应 FastifyAuthService->>RubyBackend: 8. (内部API) 获取用户现存凭证 RubyBackend-->>FastifyAuthService: 9. 返回凭证数据 FastifyAuthService->>FastifyAuthService: 10. 验证签名和挑战 alt 验证成功 FastifyAuthService->>RubyBackend: 11a. (内部API) 保存新凭证/更新登录时间 RubyBackend-->>FastifyAuthService: 12a. 确认更新 FastifyAuthService->>Elasticsearch: 13a. Log: verification_success FastifyAuthService-->>Browser: 14a. 返回 Session Token (JWT) else 验证失败 FastifyAuthService->>Elasticsearch: 11b. Log: verification_failure FastifyAuthService-->>Browser: 12b. 返回错误信息 end
1. Fastify 认证服务
这是系统的入口。代码必须是生产级的,包含配置管理、日志、错误处理和验证。
项目结构:
.
├── .env.example
├── config.js
├── logger.js
├── routes
│ └── webauthn.js
├── services
│ ├── rubyApiService.js
│ └── elasticsearchService.js
└── server.js
config.js
- 环境配置
// config.js
// 在真实项目中,应使用更安全的配置管理,如 HashiCorp Vault
import 'dotenv/config';
export default {
port: process.env.PORT || 3000,
host: process.env.HOST || '0.0.0.0',
relyingParty: {
id: process.env.RP_ID || 'localhost',
name: process.env.RP_NAME || 'My Awesome App',
origin: process.env.RP_ORIGIN || 'http://localhost:3000',
},
rubyBackend: {
url: process.env.RUBY_BACKEND_URL,
apiKey: process.env.RUBY_BACKEND_API_KEY, // 用于服务间认证的密钥
},
elasticsearch: {
node: process.env.ELASTICSEARCH_NODE,
apiKey: process.env.ELASTICSEARCH_API_KEY,
index: 'auth_events',
}
};
logger.js
- 结构化日志
// logger.js
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty', // 在开发环境中易于阅读
options: {
colorize: true,
ignore: 'pid,hostname',
},
},
});
export default logger;
server.js
- Fastify 服务器入口
// server.js
import Fastify from 'fastify';
import config from './config.js';
import logger from './logger.js';
import webauthnRoutes from './routes/webauthn.js';
const server = Fastify({
logger,
// 强制 AJV 使用严格模式,防止意外属性
ajv: {
customOptions: {
allErrors: true,
removeAdditional: 'failing',
}
}
});
// 注册全局错误处理器
server.setErrorHandler((error, request, reply) => {
request.log.error(error);
// 避免泄露内部实现细节
reply.status(500).send({ error: 'Internal Server Error' });
});
// 注册路由
server.register(webauthnRoutes, { prefix: '/v1/webauthn' });
const start = async () => {
try {
await server.listen({ port: config.port, host: config.host });
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start();
routes/webauthn.js
- 核心 WebAuthn 路由逻辑
这里的代码是整个服务的核心。它处理挑战生成和验证,并协调与其他服务的通信。
// routes/webauthn.js
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import config from '../config.js';
import RubyAPIService from '../services/rubyApiService.js';
import ElasticsearchService from '../services/elasticsearchService.js';
// 用于临时存储挑战的内存缓存,生产环境应使用 Redis
// 这里的坑在于,挑战是带状态的,必须在生成和验证之间持久化
const challengeStore = new Map();
export default async function webauthnRoutes(fastify, options) {
const rubyApi = new RubyAPIService(config.rubyBackend);
const esService = new ElasticsearchService(config.elasticsearch);
fastify.post('/register/challenge', {
schema: {
body: {
type: 'object',
properties: { username: { type: 'string', minLength: 3 } },
required: ['username'],
},
},
}, async (request, reply) => {
const { username } = request.body;
const user = await rubyApi.findOrCreateUser(username);
if (!user) {
reply.code(404);
return { error: 'User not found or could not be created' };
}
const options = await generateRegistrationOptions({
rpName: config.relyingParty.name,
rpID: config.relyingParty.id,
userID: user.id.toString(),
userName: user.username,
// 不允许用户注册已经存在的凭证
excludeCredentials: user.credentials.map(cred => ({
id: isoBase64URL.toBuffer(cred.external_id),
type: 'public-key',
transports: cred.transports,
})),
authenticatorSelection: {
userVerification: 'preferred',
residentKey: 'required', // 强制创建可发现凭证 (Passkeys)
},
});
// 存储挑战以备后续验证
challengeStore.set(user.id, options.challenge);
await esService.logEvent('registration_challenge_generated', {
user_id: user.id,
relying_party_id: config.relyingParty.id,
client_ip: request.ip,
});
return options;
});
fastify.post('/register/verify', async (request, reply) => {
const { username, response } = request.body;
const user = await rubyApi.findUserByUsername(username);
if (!user) {
reply.code(404);
return { error: 'User not found' };
}
const expectedChallenge = challengeStore.get(user.id);
if (!expectedChallenge) {
reply.code(400);
return { error: 'Challenge not found or expired' };
}
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: config.relyingParty.origin,
expectedRPID: config.relyingParty.id,
requireUserVerification: true,
});
} catch (error) {
request.log.error(error, 'Registration verification failed');
await esService.logEvent('registration_verification_failed', {
user_id: user.id,
error: error.message,
client_ip: request.ip,
});
reply.code(400);
return { error: error.message };
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter } = registrationInfo;
const newCredential = {
external_id: isoBase64URL.fromBuffer(credentialID),
public_key: isoBase64URL.fromBuffer(credentialPublicKey),
sign_count: counter,
};
await rubyApi.addCredentialToUser(user.id, newCredential);
challengeStore.delete(user.id);
await esService.logEvent('registration_verification_success', {
user_id: user.id,
credential_id: newCredential.external_id,
client_ip: request.ip,
});
return { verified: true };
}
reply.code(400).send({ verified: false, error: 'Cannot verify registration' });
});
// ... 登录的 challenge 和 verify 路由实现类似
}
2. Ruby on Rails 后端 (内部 API)
Ruby 后端需要暴露几个安全的内部 API 端点,供 Fastify 服务调用。这些端点绝不能暴露在公网上。
config/routes.rb
# config/routes.rb
namespace :internal_api, path: 'internal-api' do
namespace :v1 do
# 用于服务间认证的中间件
# constraints(InternalApiConstraint.new) do
resources :users, only: [] do
member do
get 'credentials'
post 'credentials', to: 'users#add_credential'
end
collection do
post 'find_or_create'
end
end
# end
end
end
app/controllers/internal_api/v1/users_controller.rb
# app/controllers/internal_api/v1/users_controller.rb
class InternalApi::V1::UsersController < ApplicationController
# 在真实项目中,这里必须有严格的认证和鉴权
# 例如,检查来自 Fastify 服务的 X-Internal-Api-Key 头
before_action :authenticate_internal_service!
# 避免 Rails 的 CSRF 保护
skip_before_action :verify_authenticity_token
def find_or_create
user = User.find_or_create_by!(username: user_params[:username])
render json: user_with_credentials(user), status: :ok
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_entity
end
def credentials
user = User.find(params[:id])
render json: user_with_credentials(user), status: :ok
end
def add_credential
user = User.find(params[:id])
credential = user.webauthn_credentials.new(credential_params)
if credential.save
render json: { status: 'success' }, status: :created
else
render json: { error: credential.errors.full_messages }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:username)
end
def credential_params
params.require(:credential).permit(:external_id, :public_key, :sign_count)
end
def authenticate_internal_service!
api_key = request.headers['X-Internal-Api-Key']
unless ActiveSupport::SecurityUtils.secure_compare(api_key, ENV['INTERNAL_API_KEY'])
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
def user_with_credentials(user)
# 序列化用户及其凭证
# 一个常见的错误是在这里序列化过多数据,应只返回必要信息
user.as_json(
only: [:id, :username, :created_at],
include: {
webauthn_credentials: {
only: [:external_id, :sign_count]
}
}
)
end
end
3. Elasticsearch 安全审计日志
这是将整个架构提升到生产级可用性的关键一步。Fastify 服务中的 ElasticsearchService
负责将结构化的事件发送到 ES。
services/elasticsearchService.js
// services/elasticsearchService.js
import { Client } from '@elastic/elasticsearch';
import logger from '../logger.js';
export default class ElasticsearchService {
constructor(config) {
if (!config.node || !config.apiKey) {
logger.warn('Elasticsearch config missing, logging to console instead.');
this.client = null;
} else {
this.client = new Client({
node: config.node,
auth: {
apiKey: config.apiKey,
},
});
}
this.index = config.index;
}
async logEvent(eventType, payload) {
const document = {
'@timestamp': new Date().toISOString(),
event: {
type: eventType,
},
...payload,
};
if (!this.client) {
logger.info({ elasticsearchLog: document }, 'Mock ES Log Event');
return;
}
try {
await this.client.index({
index: this.index,
document,
});
} catch (error) {
logger.error({ err: error, document }, 'Failed to log event to Elasticsearch');
}
}
}
Elasticsearch 索引模板
为了让数据可被有效查询,我们需要一个索引模板。
PUT _index_template/auth_events_template
{
"index_patterns": ["auth_events*"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"event": {
"properties": {
"type": { "type": "keyword" }
}
},
"user_id": { "type": "keyword" },
"credential_id": { "type": "keyword" },
"relying_party_id": { "type": "keyword" },
"client_ip": { "type": "ip" },
"error": { "type": "text" }
}
}
}
}
有了这些数据,安全团队就可以在 Kibana 中轻松创建仪表盘,监控登录失败率、新设备注册情况,并设置告警规则,例如:在5分钟内,同一 IP 地址的 verification_failure
事件超过10次,则触发高优先级告警。
架构的扩展性与局限性
这种解耦的架构为未来演进提供了巨大灵活性。如果需要支持 OAuth 或其他身份提供商,只需在 Fastify 服务中增加新的路由即可,Ruby monolith 完全不受影响。认证服务可以根据其独特的负载模式进行独立的性能调优和资源伸缩。
然而,这个方案并非没有代价。系统的复杂性确实增加了。运维团队需要同时监控 Node.js 和 Ruby 两个应用栈。服务间的网络通信成为一个新的潜在故障点,必须有完善的超时、重试和熔断机制。最关键的是,Fastify 服务与 Ruby 后端之间的内部 API 的安全性至关重要,它必须被视为内部网络中最敏感的资产之一,采用 mTLS 或严格的 API 密钥管理。此外,数据在两个服务间流转,意味着需要更精细的分布式追踪来定位问题。