大多数时候,当我们在Rails应用程序上实现API端点时,我们希望将这些API的访问权限制在授权用户手中,有几种策略可以通过API认证用户,从简单的令牌认证到带JWT的完整OAuth提供商。
在本教程中,我们将使用Devise和Doorkeepergem,在我们为用户提供服务的同一个Rails应用程序上实现一个用于API认证的OAuth提供商。
本教程结束后,你将能够在Rails前端实现Devise登录/注册,并在API端实现Doorkeeper OAuth(登录、注册),用于移动应用客户端,或像React等单独的前端客户端。
本教程假设你有一些使用Devise的经验,并且你的Rails应用程序将同时拥有一个前端UI和API供用户注册和登录。我们也可以使用Doorkeeper来允许第三方在我们自己的Rails应用平台上创建他们自己的OAuth应用,但这不在本文的范围内,因为本文将专注于创建我们自己的OAuth应用,仅供自己使用。
目录
架构一个模型
让我们从一些脚手架开始,这样我们就可以有一个用于CRUD的模型、控制器和视图,如果你已经有一个现有的Rails应用,你可以跳过这一部分:
rails g scaffold bookmarks title:string url:string
然后在routes.rb中,设置根路径为'bookmarks#index'。Devise要求我们在routes中设置一个根路径来工作:
# config/routes.rb
root 'bookmarks#index'
现在我们有了一个CRUD Rails应用的样本,我们可以继续进行下一步了。
设置Devise gem
在Gemfile中添加devise gem:
# Gemfile
# ...
gem 'devise', '~> 4.7.3'
并运行bundle install 来安装它。
接下来,运行Devise安装生成器:
rails g devise:install
然后,我们使用Devise创建用户模型(或你使用的任何其他模型名称,如管理员、工作人员等):
rails g devise User
你可以在生成的迁移文件中定制你想要的Devise功能,也可以在用户模型文件中定制。
然后运行rake db:migrate ,创建用户表。
现在我们已经设置了Devise用户,我们可以在bookmarks_controller.rb中添加authenticate_user!,所以现在只有登录的用户可以查看控制器:
# app/controllers/bookmarks_controller.rb
class BookmarksController < ApplicationController
before_action :authenticate_user!
# ....
end
接下来我们将进入主要部分,即使用Doorkeeper gem为API设置认证。
设置Doorkeeper gem
在Gemfile中添加doorkeeper gem:
# Gemfile
# ...
gem 'doorkeeper', '~> 5.4.0'
并运行bundle install 来安装它:
接下来,运行Doorkeeper安装生成器。
rails g doorkeeper:install
这将在config/initializers/doorkeeper.rb中生成Doorkeeper的配置文件,我们稍后将对其进行自定义。
接下来,运行Doorkeeper迁移生成器。
rails g doorkeeper:migration
这将在db/migrate/..._create_doorkeeper_tables.rb中为Doorkeeper生成一个迁移文件。
我们将定制这个迁移文件,因为我们不需要生成的所有表/属性。
打开**...._create_doorkeeper_tables.rb**迁移文件,然后编辑,使其看起来像下面这样:
# frozen_string_literal: true
class CreateDoorkeeperTables < ActiveRecord::Migration[6.0]
def change
create_table :oauth_applications do |t|
t.string :name, null: false
t.string :uid, null: false
t.string :secret, null: false
# Remove `null: false` if you are planning to use grant flows
# that doesn't require redirect URI to be used during authorization
# like Client Credentials flow or Resource Owner Password.
t.text :redirect_uri
t.string :scopes, null: false, default: ''
t.boolean :confidential, null: false, default: true
t.timestamps null: false
end
add_index :oauth_applications, :uid, unique: true
create_table :oauth_access_tokens do |t|
t.references :resource_owner, index: true
# Remove `null: false` if you are planning to use Password
# Credentials Grant flow that doesn't require an application.
t.references :application, null: false
t.string :token, null: false
t.string :refresh_token
t.integer :expires_in
t.datetime :revoked_at
t.datetime :created_at, null: false
t.string :scopes
# The authorization server MAY issue a new refresh token, in which case
# *the client MUST discard the old refresh token* and replace it with the
# new refresh token. The authorization server MAY revoke the old
# refresh token after issuing a new refresh token to the client.
# @see https://tools.ietf.org/html/rfc6749#section-6
#
# Doorkeeper implementation: if there is a `previous_refresh_token` column,
# refresh tokens will be revoked after a related access token is used.
# If there is no `previous_refresh_token` column, previous tokens are
# revoked as soon as a new access token is created.
#
# Comment out this line if you want refresh tokens to be instantly
# revoked after use.
t.string :previous_refresh_token, null: false, default: ""
end
add_index :oauth_access_tokens, :token, unique: true
add_index :oauth_access_tokens, :refresh_token, unique: true
add_foreign_key(
:oauth_access_tokens,
:oauth_applications,
column: :application_id
)
end
end
我对迁移文件所做的修改:
- 在oauth_applications表的redirect_uri上删除null: false。由于我们使用OAuth进行API认证,我们不需要将用户重定向到一个回调页面(就像你在应用上用Google / Apple / Facebook登录后,他们通常会重定向到一个页面)。
- 移除创建的表oauth_access_grants,以及其相关索引和外键。
OAuth应用程序表是用来跟踪我们创建的用于认证的应用程序的。例如,我们可以创建三个应用程序,一个用于Android应用程序客户端,一个用于iOS应用程序客户端,一个用于React前端,这样我们就可以知道用户在使用哪个客户端。如果你只需要一个客户端(例如:Web前端),也是可以的。
这里有一个Github OAuth应用的例子。

接下来,运行rake db:migrate ,将这些表添加到数据库中。
接下来,我们将定制Doorkeeper的配置。
定制Doorkeeper配置
打开config/initializers/doorkeeper.rb,并编辑以下内容。
注释或删除文件顶部的resource_owner_authenticator块:
#config/initializers/doorkeeper.rb
Doorkeeper.configure do
# Change the ORM that doorkeeper will use (requires ORM extensions installed).
# Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
orm :active_record
# This block will be called to check whether the resource owner is authenticated or not.
# resource_owner_authenticator do
# raise "Please configure doorkeeper resource_owner_authenticator block located in #{__FILE__}"
# Put your resource owner authentication logic here.
# Example implementation:
# User.find_by(id: session[:user_id]) || redirect_to(new_user_session_url)
# end
resouce_owner_authenticator块是用来获取认证的用户信息或将用户从OAuth重定向到登录页面,例如像这样的Twitter OAuth页面:

由于我们将通过使用API上的用户登录凭证(电子邮件+密码)来交换OAuth令牌,我们不需要实现这个块,所以我们可以把它注释掉。
为了告诉Doorkeeper我们正在使用用户凭证登录,我们需要像这样实现resource_owner_from_credentials块:
#config/initializers/doorkeeper.rb
Doorkeeper.configure do
# Change the ORM that doorkeeper will use (requires ORM extensions installed).
# Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
orm :active_record
# This block will be called to check whether the resource owner is authenticated or not.
# resource_owner_authenticator do
# raise "Please configure doorkeeper resource_owner_authenticator block located in #{__FILE__}"
# Put your resource owner authentication logic here.
# Example implementation:
# User.find_by(id: session[:user_id]) || redirect_to(new_user_session_url)
# end
resource_owner_from_credentials do |_routes|
User.authenticate(params[:email], params[:password])
end
# ...
这将允许我们发送用户的电子邮件和密码到/oauth/token端点以验证用户。
然后我们需要在app/models/user.rb模型文件中实现authenticate类方法:
# app/models/user.rb
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
validates :email, format: URI::MailTo::EMAIL_REGEXP
# the authenticate method from devise documentation
def self.authenticate(email, password)
user = User.find_for_authentication(email: email)
user&.valid_password?(password) ? user : nil
end
end
接下来,在config/initializers/doorkeeper.rb中启用密码授予流,这将允许我们发送用户电子邮件和密码到/oauth/token端点,并获得OAuth令牌作为回报:
Doorkeeper.configure do
orm :active_record
resource_owner_from_credentials do |_routes|
User.authenticate(params[:email], params[:password])
end
# enable password grant
grant_flows %w[password]
# ....
你可以在这个文件中搜索 "grant_flows",然后取消注释并编辑它。
接下来,在配置中插入allow_blank_redirect_uri true,这样我们就可以用空白的重定向URL创建OAuth应用程序(用户在登录后不会被重定向,因为我们使用的是API):
Doorkeeper.configure do
orm :active_record
resource_owner_from_credentials do |_routes|
User.authenticate(params[:email], params[:password])
end
grant_flows %w[password]
allow_blank_redirect_uri true
# ....
由于我们创建的OAuth应用程序是供我们自己使用的(不是第三方),我们可以跳过授权。
像这样在配置中插入skip_authorization:
Doorkeeper.configure do
orm :active_record
resource_owner_from_credentials do |_routes|
User.authenticate(params[:email], params[:password])
end
grant_flows %w[password]
allow_blank_redirect_uri true
skip_authorization do
true
end
# ....
我们跳过的授权是这样的:

由于我们跳过了授权,用户就不需要点击 "授权 "按钮来与我们的API进行交互。
另外,如果你想在OAuth中启用刷新令牌机制,你可以在配置中插入use_refresh_token。这将允许客户端应用程序在当前访问令牌过期时使用刷新令牌请求新的访问令牌:
Doorkeeper.configure do
orm :active_record
resource_owner_from_credentials do |_routes|
User.authenticate(params[:email], params[:password])
end
grant_flows %w[password]
allow_blank_redirect_uri true
skip_authorization do
true
end
use_refresh_token
# ...
这样,我们就完成了为我们的API配置Doorkeeper认证。
接下来,我们将在routes.rb中添加doorkeeper路由,这将添加/oauth/*路由:
Rails.application.routes.draw do
use_doorkeeper do
skip_controllers :authorizations, :applications, :authorized_applications
end
# ...
end
由于我们不需要应用程序的授权,我们可以跳过授权和authorized_applications控制器。我们也可以跳过应用程序控制器,因为用户将无法创建或删除OAuth应用程序。
接下来,我们需要在控制台中手动创建我们自己的OAuth应用程序,这样我们就可以使用它来进行认证。
打开rails控制台。rails console
然后用这个命令创建一个OAuth应用程序:
Doorkeeper::Application.create(name: "Android client", redirect_uri: "", scopes: "")
你可以把名字改成你想要的任何名字,并把redirect_uri和scopes留空。

这将在oauth_applications表中创建一条记录。请注意uid属性和secret属性,这些都是后来用于API的认证,uid = client_id,secret = client_secret。
对于生产使用,你可以在db/seeds.rb中创建一个数据库种子,用于初始创建OAuth应用程序。
# db/seeds.rb
# if there is no OAuth application created, create them
if Doorkeeper::Application.count.zero?
Doorkeeper::Application.create(name: "iOS client", redirect_uri: "", scopes: "")
Doorkeeper::Application.create(name: "Android client", redirect_uri: "", scopes: "")
Doorkeeper::Application.create(name: "React", redirect_uri: "", scopes: "")
end
然后运行rake db:seed 来创建这些应用程序。
Doorkeeper::Application只是oauth_applications表的一个命名模型名称,你可以像往常一样执行ActiveRecord查询:
# client_id of the application
Doorkeeper::Application.find_by(name: "Android client").uid
# client_secret of the application
Doorkeeper::Application.find_by(name: "Android client").secret
现在我们已经设置了Doorkeeper应用程序,我们可以在下一节尝试登录用户。
如何使用API登录、注销和刷新令牌
我们需要创建一个用户,以便能够使用OAuth端点登录/注销他们,如果你还没有,你可以在devise web UI上注册一个假用户(例如:localhost:3000/users/sign_up)或通过rails控制台创建一个。
下面的HTTP请求可以使用JSON格式或URL编码的形式发送属性。
登录
为了在OAuth端点上登录用户,我们需要向**/oauth/token发送一个HTTP POST请求,并附上grant_type**、email、password、client_id和client_secret属性。

由于我们使用密码来换取OAuth访问和刷新令牌,grant_type的值应该是密码。
email和password是用户的登录凭证。
client_id是我们之前创建的Doorkeeper::Application(OAuth应用程序)的UID,有了这个,我们就可以识别用户用哪个客户端登录。
client_secret是我们之前创建的Doorkeeper::Application(OAuth应用程序)的秘密。
登录成功后,API将返回access_token、refresh_token、token_type、expires_in和created_at属性。
然后我们可以使用access_token来调用需要用户认证的受保护的API。
refresh_token可以用来在当前access_token过期后生成和检索一个新的access_token。
expires_in是访问令牌过期前的时间,从UNIX的时间戳created_at开始,默认值是7200(秒),大约是2小时。
注销
要注销一个用户,我们可以撤销访问令牌,这样同一个访问令牌就不能再被使用。
要撤销一个访问令牌,我们需要向**/oauth/revoke发送一个HTTP POST请求,并附上令牌**、client_id和client_secret属性。
除了这些属性,我们还需要为HTTP请求设置授权头,以使用Basic Auth,使用client_id值作为用户名,client_password值作为密码。(根据Doorkeeper gem仓库的这个回复)


撤销一个令牌后,令牌记录中会有一个revoked_at列被填充。

刷新令牌
当当前的访问令牌(几乎)过期时,要检索一个新的访问令牌,我们可以向**/oauth/token发送HTTP POST,它与login的端点相同,但这次我们使用 "refresh_token "作为grant_type**的值,并且发送refresh令牌的值,而不是登录凭证。
要刷新一个令牌,我们需要发送grant_type、refresh_token、client_id和client_secret属性。
grant_type需要等于 "refresh_token",因为我们正在使用刷新令牌进行认证。
refresh_token应该是你在登录时获取的刷新令牌值。
client_id是我们之前创建的Doorkeeper::Application(OAuth应用程序)的UID。
client_secret是我们之前创建的Doorkeeper::Application(OAuth应用程序)的秘密。

在刷新尝试成功后,API会返回一个新的access_token和refresh_token,我们可以用它来调用需要用户认证的受保护的API。
现在我们已经设置了用户认证,我们现在可以创建需要认证的API控制器。
为此,我建议创建一个基本的API应用控制器,然后将这个控制器子类化,用于需要认证的控制器。
创建一个基础API应用控制器**(application_controller.rb**),并将其放在app/controllers/api/application_controller.rb中:
# app/controllers/api/application_controller.rb
module Api
class ApplicationController < ActionController::API
# equivalent of authenticate_user! on devise, but this one will check the oauth token
before_action :doorkeeper_authorize!
private
# helper method to access the current user from the token
def current_user
@current_user ||= User.find_by(id: doorkeeper_token[:resource_owner_id])
end
end
end
API应用从ActionController::API子类化,它是ActionController::Base的轻量级版本,不包含HTML布局和模板功能(反正我们在API中不需要),也没有CORS保护。
我们在before_action回调中添加了**doorkeeper_authorize!**方法,因为这将在调用控制器中的方法之前检查用户是否通过了有效的token认证,这与devise上的authenticate_user!方法类似。
我们还添加了一个current_user方法来获取当前的用户对象,然后我们可以将当前的用户附加到一些模型的CRUD动作上。
作为一个受保护的API控制器的例子,让我们创建一个书签控制器来检索所有的书签:
app/controllers/api/bookmarks_controller.rb
# app/controllers/api/bookmarks_controller.rb
module Api
class BookmarksController < Api::ApplicationController
def index
@bookmarks = Bookmark.all
render json: { bookmarks: @bookmarks }
end
end
end
书签控制器将继承自我们之前创建的基础API应用控制器,它将以JSON格式返回所有的书签。
不要忘记在routes.rb中为它添加路由:
Rails.application.routes.draw do
# ....
namespace :api do
resources :bookmarks, only: %i[index]
end
end
然后我们可以通过向**/api/bookmarks**发送HTTP GET请求来检索书签,在授权头中加入用户的访问令牌(Authorization: Bearer [User Access Token])。

要访问受保护的API控制器,我们将需要包括授权HTTP头,其值为 "Bearer [User Access Token]"。
创建一个用户注册的端点
如果我们只允许用户通过网站注册,那就太奇怪了,我们还需要添加一个API端点让用户注册账户。
为此,让我们创建一个用户控制器,并把它放在app/controllers/api/users_controller.rb中。
创建动作将通过提供的电子邮件和密码创建一个用户账户:
module Api
class UsersController < Api::ApplicationController
skip_before_action :doorkeeper_authorize!, only: %i[create]
def create
user = User.new(email: user_params[:email], password: user_params[:password])
client_app = Doorkeeper::Application.find_by(uid: params[:client_id])
return render(json: { error: 'Invalid client ID'}, status: 403) unless client_app
if user.save
# create access token for the user, so the user won't need to login again after registration
access_token = Doorkeeper::AccessToken.create(
resource_owner_id: user.id,
application_id: client_app.id,
refresh_token: generate_refresh_token,
expires_in: Doorkeeper.configuration.access_token_expires_in.to_i,
scopes: ''
)
# return json containing access token and refresh token
# so that user won't need to call login API right after registration
render(json: {
user: {
id: user.id,
email: user.email,
access_token: access_token.token,
token_type: 'bearer',
expires_in: access_token.expires_in,
refresh_token: access_token.refresh_token,
created_at: access_token.created_at.to_time.to_i
}
})
else
render(json: { error: user.errors.full_messages }, status: 422)
end
end
private
def user_params
params.permit(:email, :password)
end
def generate_refresh_token
loop do
# generate a random token string and return it,
# unless there is already another token with the same string
token = SecureRandom.hex(32)
break token unless Doorkeeper::AccessToken.exists?(refresh_token: token)
end
end
end
end
由于用户此时还没有账户,我们想免除这个动作对认证信息的要求,所以我们在顶部添加了skip_before_action :doorkeeper_authorize!, only: %i[create]一行。这将使create方法跳过运行我们在基础API控制器中定义的**doorkeeper_authorize!**before_action方法,客户端应用程序可以在没有认证信息的情况下调用用户账户创建API端点。
然后,我们使用**Doorkeeper::AccessToken.create()**在用户成功注册时创建一个AccessToken,并在HTTP响应中返回,这样用户就不需要在注册后立即登录。
记得在routes.rb中为这个用户注册动作添加一个路由:
Rails.application.routes.draw do
# ....
namespace :api do
resources :users, only: %i[create]
resources :bookmarks, only: %i[index]
end
end
然后,我们可以通过向**/api/users发送包含用户电子邮件**、密码和client_id的HTTP POST请求来调用创建用户的API,像这样:

client_id是用来识别用户使用哪个客户端应用程序进行注册的。
说如果你怀疑一个用户的访问令牌被误用或滥用,你可以使用这个函数手动撤销他们。
Doorkeeper::AccessToken.revoke_all_for(application_id, resource_owner)
application_id是我们想要撤销用户的Doorkeeper::Application(OAuth应用程序)的ID。
resource_owner是用户对象。
使用实例:
client_app = Doorkeeper::Application.find_by(name: 'Android client')
user = User.find(7)
Doorkeeper::AccessToken.revoke_all_for(client_app.id , user)