1. 项目背景:API-only Rails 的认证为什么不能照搬 Session
做 Rails API-only 项目多了以后,你会发现一个绕不开的问题:登录状态怎么保持。普通 Rails 全栈应用可以直接用 Cookie + Session,用户跳转页面时浏览器会自动带上 Session ID,服务端查一下就能知道是谁。但移动端、小程序、后端对后端的接口调用,都没有“浏览器自动带 Cookie”这个便利条件。你总不能要求 App 端每次请求都手动维护一个 Cookie 罐子,更不能为了兼容 Session 去引入一堆和 JSON 接口无关的中间件。
所以 Token 认证几乎是 API-only Rails 项目的默认选择。用户登录成功后,服务端返回一个随机字符串,客户端保存起来,后续每个请求都把这个字符串放到 Header 里。这个方案听起来简单,真做起来却有很多细节:Token 存哪里、怎么过期、怎么撤销、怎么保证一个用户在不同设备上登录不互相踢下线。
我这次要聊的 Tiddle,就是针对这套需求的一套轻量级解决方案。它不是一个万能的认证框架,而是给 Devise 加一个 Token 认证策略,让你不需要自己从头写 Warden Strategy,也不需要为 Token 表、Token 过期、多 Token 管理等常见逻辑造轮子。适合的场景很明确:你的项目已经用了 Devise 管理用户体系,现在又要开一个 API,给 App 或第三方系统提供 JSON 接口,并且希望每个用户可以有多个有效 Token,按设备或会话维度独立撤销。
如果你正处于“Rails 项目要接 API 认证”的阶段,正在犹豫是自己写 Token 认证还是用现成方案,这篇文章会比较对胃口。我会从方案选型讲起,再把 Tiddle 的接入流程、Token 生命周期、多设备管理、常见坑全部过一遍,最后给出一套可以直接抄的工程实践。
2. 方案选型:Tiddle 和其他 Token 认证方案怎么选
2.1 常见 Token 认证方案的横向对比
在 Rails 生态里,Token 认证方案其实不少,但各自设计取向差很多。我遇到过不少团队,第一个方案就是用has_secure_token给 User 表加一个authentication_token字段,然后每个接口用before_action手动查一遍。这个方案对付一两个接口还行,一旦用户量上来、设备多起来,问题马上暴露:一个用户只有一个 Token,手机上登录会直接把 Web 端挤掉,而且 Token 过期、撤销、防重放都要自己写。
另一个常用方案是 JWT。JWT 无状态,服务端不用存 Token,乍一看很适合 API 场景。但无状态带来的副作用就是撤销难:用户点了“退出登录”或者改了密码,老的 JWT 在过期之前依然是可用的。你需要在 Redis 里做黑名单,那就等于又回到“服务端有状态”这条路,只是把状态从数据库搬到了 Redis,复杂度一点都不低。
Tiddle 走的是另一种路线:Token 存在数据库里,一个用户对应多行authentication_tokens记录。这样 Token 天然有服务端状态,撤销一个 Token 只是删一行数据,对其他设备没有任何影响。和simple_token_authentication这类方案比,Tiddle 不要求你在 User 表上只留一个 Token 字段,而是用独立的表维护 Token 和用户的对应关系,多设备并发登录非常自然。
2.2 为什么选 Tiddle 而不选更重的方案
我见过有人把devise_token_auth当默认选择,确实它功能全,支持注册、登录、密码找回、批量接口,还自带一套前端配合逻辑。但对很多只需要“让现有用户体系多一个 Token 认证入口”的项目来说,它太笨重了。它会引入自己的路由、控制器、模型扩展,甚至会改变你对 Devise 默认行为的预期。迁移成本不是零,尤其项目里已经有自定义的 Devise 配置时,改起来会比较头疼。
Tiddle 的优势正好是“轻”。它不是一个完整的认证体系,而是一个挂在 Devise 底下的策略。你原有的current_user、authenticate_user!、User.find_for_authentication全都还能用,设备登录、退出、Token 过期也都是围绕数据库记录来操作。走进业务代码的人一看就懂,后续接手的人不至于被一堆抽象层搞得云里雾里。
当然,轻也意味着它不打算替你做所有事情。比如第三方 OAuth、短信验证码登录、角色权限,这些还是要靠 Devise 或其他 gem 来解决。Tiddle 只解决一个核心问题:API-only Rails 应用的多用户 Token 认证。
3. 接入 Tiddle:一步步搭好基础认证链路
3.1 安装和生成数据库结构
先确认你的项目是 Rails 5 以上,并且已经装好 Devise。Gemfile 里加一行:
gem 'tiddle'然后执行安装命令:
bundle install rails generate tiddle:install User rails db:migratetiddle:install后面跟的User是你的用户模型名称。如果你用的不是 User,比如 Account,就写成rails generate tiddle:install Account。生成器会帮你创建AuthenticationToken模型和对应的数据库迁移文件。
我习惯在 migrate 之前先打开 migration 看一眼结构。通常 Tiddle 生成的表类似这样:
class CreateAuthenticationTokens < ActiveRecord::Migration[6.1] def change create_table :authentication_tokens do |t| t.references :user, null: false, foreign_key: true t.string :token, null: false, index: { unique: true } t.datetime :expires_at, null: false t.timestamps end end end核心就三块:user_id关联到用户,token存认证凭证,expires_at控制过期时间。如果你的版本生成的字段少一两个,比如没有expires_at,建议自己补上,后面做 Token 过期会方便很多。
3.2 在 User 模型里挂上 Tiddle
安装完成之后,你的 User 模型需要引入 Tiddle。一个典型的配置长这样:
class User < ApplicationRecord include Tiddle::Model devise :database_authenticatable, :registerable, :recoverable, :validatable has_many :authentication_tokens, dependent: :destroy endinclude Tiddle::Model会提供和 Token 相关的内部逻辑。这里需要注意的是dependent: :destroy。如果不加,用户删除账户时,authentication_tokens表里会残留一堆孤儿记录,长期下来不干净。Tiddle 的生成器通常会把关联也挂好,但我会再检查一遍,防止某些版本漏掉。
3.3 配置 Devise,让 Warden 认识 Token
Devise 底层的认证是通过 Warden 实现的,Tiddle 做的就是一个 Warden 策略。在config/initializers/devise.rb里加这一段:
Devise.setup do |config| config.warden do |manager| manager.strategies.add :token_authenticatable, Tiddle::Strategy manager.default_strategies(scope: :user).unshift :token_authenticatable end config.navigational_formats = [] endunshift的意思是让 Token 策略排在默认策略前。当请求里带了 Token,就用 Tiddle 的策略认证;当请求里没带 Token,Warden 会继续走其他策略。这样做的兼容性最好,不会因为加了一个策略就把原有的 Devise 登录模式弄坏。
如果你是纯 API 项目,config.navigational_formats = []这行很关键。不设置的话,当认证失败时 Devise 可能尝试返回 HTML 页面或做重定向,API 客户端拿到的就不是 JSON 401,而是 302 或 404,排查起来非常迷惑。
3.4 写登录、退出、当前用户接口
我建议把认证相关的接口单独放到Api::AuthController里,而不是散落在各个资源控制器中。下面这套代码是一个可以直接复制的骨架:
class Api::BaseController < ActionController::API before_action :authenticate_user! endclass Api::AuthController < Api::BaseController skip_before_action :authenticate_user!, only: :sign_in def sign_in user = User.find_for_authentication(email: params[:email]) if user&.valid_password?(params[:password]) authentication_token = user.authentication_tokens.create! render json: { authentication_token: authentication_token.token, expires_at: authentication_token.expires_at }, status: :created else render json: { error: 'invalid_email_or_password' }, status: :unauthorized end end def sign_out token = request.headers['X-Auth-Token'] current_user.authentication_tokens.find_by(token: token)&.expire! head :no_content end end路由可以这样组织:
namespace :api do post 'auth/sign_in', to: 'auth#sign_in' delete 'auth/sign_out', to: 'auth#sign_out' end这里有一个容易被忽略的设计点:退出登录时,我只撤销当前请求带的那个 Token,调用的方法是expire!,而不是把当前用户的所有 Token 全删掉。这么做才能保证一个设备退出登录,不影响用户在另一个设备上的会话。Tiddle 默认会为这个场景提供模型支持,如果你的版本没有现成的expire!,实现上等价于直接把expires_at改成当前时间并保存。
4. 关键细节:Token 生命周期、过期与多设备管理
4.1 Token 的生成和存储
Tiddle 创建 Token 的方式很简单,就是调用user.authentication_tokens.create!。服务端会生成一段随机字符串,同时写入expires_at。这个随机 Token 在返回给客户端之后,客户端应该自己保存好,因为数据库里存储的是 Token 本身,服务端不会再把完整的纯文本 Token 给第二遍。
从安全性考虑,我更建议在生产环境把 Token 做一次单向哈希再入库。也就是说,接口返回给用户的是一段明文 Token,但数据库存的是Digest::SHA256.hexdigest(token)。这样做的好处是数据库一旦泄露,攻击者拿到的哈希值不能直接冒充用户。Tiddle 的不同版本在底层实现上可能没有强约束这一点,所以如果你对安全要求比较高,请在拿到 Token 后手动做哈希,并重写查找逻辑。对绝大多数中小项目来说,至少要在传输层用 HTTPS,并且不要把 Token 写在日志里。
Token 的有效期也需要根据业务场景设置。移动端长期登录的 Token 可以给 14 天或 30 天;Web 管理后台为了保护敏感操作,可以给 8 到 12 小时。过期之后,客户端需要重新走一遍登录流程拿到新 Token。这里不需要做后台任务去批量清理过期 Token,直接在查询时加一个where('expires_at > ?', Time.current)的条件,等用户下次操作时把旧记录清掉就行。
4.2 请求认证时怎么携带 Token
Tiddle 的策略可以从 Header 读取 Token。在工程里,我会统一规定客户端用X-Auth-Token这个 Header 来传:
POST /api/orders Content-Type: application/json X-Auth-Token: 8f1d3c9e2b8a4f6d为什么不用Authorization: Bearer?因为 Bearer 在语义上经常和 OAuth 2.0 混在一起,如果项目后续要接第三方授权,拆起来比较麻烦。用X-Auth-Token一眼能看出这是项目自己的 Token,不会和标准协议混淆。
从 Tiddle 的视角看,它会在每次请求进来时先尝试从 Header 或参数里提取 Token,然后去authentication_tokens表里找匹配记录,再通过user_id找到用户。如果 Token 有效,current_user就会被赋值;如果无效,请求就会走进 401 逻辑。这一切都发生在before_action :authenticate_user!里,你不用在每个控制器里手动查一遍 Token。
4.3 多用户多 Token 的撤销逻辑
多用户 Token 认证最容易做歪的地方是“退出登录”。很多新手写代码时,退出登录直接调用current_user.authentication_tokens.destroy_all,结果手机退出登录后,Web 端的会话也一起掉了。用户投诉“我只是在手机上退出一下,为什么网页也登出了”,多半是这里写错了。
正确的逻辑是每次请求只处理当前这个 Token:
token = request.headers['X-Auth-Token'] current_user.authentication_tokens.find_by(token: token)&.expire!如果想要“修改密码后强制所有设备退出”,那才应该清空该用户的所有 Token:
current_user.authentication_tokens.destroy_all这两种场景要分开做。如果你需要在密码重置后让旧 Token 全部失效,可以在用户的after_password_reset回调里加上清空逻辑。没有这个需求就别加,否则多设备登录的用户会感觉很莫名其妙。
多设备登录是 Tiddle 的天然优势。同一个用户可以在手机上有一个 Token,在电脑上有一个 Token,在第三方系统里再有另一个 Token。它们彼此独立,互不干扰。你甚至可以根据需要在authentication_tokens表上增加一列device_name或client_name,让用户登录时可以给设备起名字,界面上也能清楚看到哪些设备在线,手动踢掉某个不常用的设备。
4.4 安全注意清单
用 Tiddle 不代表安全就万无一失。我整理了几条比较容易踩的点:
- 不要让前端把 Token 存到
localStorage的同源策略之外的共享区域,尤其不要放到会被第三方脚本读走的地方。 - 不要用
params传 Token。GET 请求的 query string 会被日志记录,Token 一旦进了访问日志,就等于在你的基础设施里裸奔。 - 如果确实有客户端习惯用
auth_token参数,记得在 Rails 配置里加上config.filter_parameters << :auth_token,把参数过滤掉。 - 所有鉴权接口都要保证走 HTTPS。明文 Token 在任何公共网络里都可能被中间人截获。
- 接入反向代理或 Rack 中间件时,注意不要吞掉
X-Auth-Token这个 Header。有些网关会默认过滤自定义 Header,下一层 Rails 根本读不到。
5. 常见问题与排查技巧
5.1 问题速查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 登录接口直接返回 404 | Devise 没有正确处理 API 格式 | 在 devis.rb 设置config.navigational_formats = [] |
| 带了 Token 仍然 401 | Header 名不一致或 Warden 策略顺序不对 | 确认前后端使用同一个 Header 名,检查unshift配置 |
| CORS 预检请求失败 | 网关没有放行自定义 Header | 在允许的请求头里加X-Auth-Token |
| 手机退出登录后网页也掉线 | 退出时销毁了该用户所有 Token | 改成只expire!当前 Token |
| Token 过期时间无效 | 查询时没有带过期条件 | 确认模型查询使用了expires_at > Time.current |
| 用户改密码后旧 Token 仍有效 | 没有主动撤销旧 Token | 在改密码回调里清空旧 Token |
5.2 一次真实的线上排查记录
有一次同事反馈,接口在 Postman 里测一切正常,但在 App 里总是莫名其妙的 401。我第一反应是 CORS,但登录接口又能通,说明跨域本身是允许的。后来抓请求发现,App 端发的 Header 名是Auth-Token,而服务端读的是X-Auth-Token,两个名字差了一个前缀,服务端自然认为没有 Token。
这个坑其实很好避免。我建议在 Api::BaseController 里做一个统一的 Token 提取方法,不要在每个地方都直接读request.headers['X-Auth-Token']。这样即使前端要改成Authorization或者其他名字,也只需要改一个地方:
def auth_token request.headers['X-Auth-Token'] || request.headers['Authorization']&.delete_prefix('Bearer ') end另一个比较隐蔽的问题是日志泄漏。有一版代码为了调试方便,把完整请求 Header 打印到日志里,结果X-Auth-Token跟着一起进了日志系统。日志系统一般比较开放,几乎每个后端同学都能看,Token 一旦出现在那里,就已经算泄露了。排查思路是把所有日志配置检查一遍,再用config.filter_parameters滤掉 Token 相关字段。
5.3 请求规格测试怎么写
我给新项目写 Token 认证接口时,一定会先写请求规格。核心是验证“带 Token 的请求能过,不带 Token 会被拦,退出登录后旧 Token 失效”。下面是一个浓缩过的 Rspec 示例:
require 'rails_helper' RSpec.describe 'Api::Auth', type: :request do let(:user) { create(:user, email: 'alice@example.com', password: 'password123') } it 'signs in and returns a token' do post '/api/auth/sign_in', params: { email: user.email, password: 'password123' } expect(response).to have_http_status(:created) expect(json['authentication_token']).to be_present end it 'rejects requests without token' do get '/api/orders', headers: {} expect(response).to have_http_status(:unauthorized) end it 'invalidates only the token used for sign out' do token_one = user.authentication_tokens.create!.token token_two = user.authentication_tokens.create!.token delete '/api/auth/sign_out', headers: { 'X-Auth-Token' => token_one } expect(user.authentication_tokens.find_by(token: token_one)).to be_expired expect(user.authentication_tokens.find_by(token: token_two)).not_to be_expired end end这个测试覆盖了最核心的业务规则:多 Token 独立失效。只要你改动了退出登录逻辑,这个测试能马上告诉你有没有把其他设备误伤。
6. 一点个人建议:什么时候不要硬上 Tiddle
聊了这么多 Tiddle 的好,但它并不是银弹。我自己在项目选型时会先问一个问题:这个项目的认证需求会不会在未来一两年内长成 OAuth 2.0?
如果答案是会,比如你要开放 API 给第三方开发者,让他们用授权码模式访问用户数据,那 Tiddle 不适合作为主方案。这种场景应该直接用Doorkeeper这类专门做 OAuth 的库,Tiddle 顶多作为内部服务间调用的补充。
如果你的项目是纯前端 SPA,而且只用 JWT,那也没有必要为了用 Tiddle 把整套认证改成数据库 Token。JWT 的优点是跨服务共享,缺点在撤销,这是需要整个技术架构一起权衡的事,不是单库单表能解决的。
但我个人经验里,大多数“业务系统 + 移动 App”的常规项目,认证语义就是“用户登录一个后台,拿到一个凭证,凭证过期再登”。这种情况下 Tiddle 的价值非常大。它没有强迫你改变用户模型,没有给你塞一堆用不到的注册流程,也没有把你绑死在某种请求格式上。你拿它当一个成熟的 Warden 策略,然后专注写接口业务就行。
最后分享一个小技巧:Tiddle 接入之后,别急着把代码铺到所有控制器。先用一个新接口或者一个内测接口跑通全链路,让前端同学一起联调,确认X-Auth-Token的传递格式、Token 过期行为、退出登录是否只影响当前设备,再逐步推广到生产接口。认证这种东西,前期多花一小时验证,能省掉后面无数个“为什么这个接口一会能用一会不能”的深夜。