はじめに
Rails4で作られたWebアプリにスマホ向けのAPIを追加する事になり、既存のユーザモデルを使ってトークン認証を実装してみました。私自身、Javaの経験はあるのですが、Railsはまだ日が浅いので変なコードになっているかもしれません。
JWTを組み込むために検討したもの
今回組み込んだもの
-
Rack::JWT
- {grape}のhttp_basic, http_digestを参考に組み込めた!
うまく動かせなかったもの
-
Grape::Knock
- Knockのマルチユーザ対応に追随していないため動作せず
-
Knock
- Rails力が低く、組み込み方が分からず
-
Rails, Devise, JWT and the forgotten Warden
- Rails力が低く、組み込んだ後の利用方法が分からず
URL設計
以下のようなトークン発行APIと認証が必要なAPIの2つを実装してみます。
トークン発行API
curl -X POST \
-H 'Content-Type: application/json' \
-d '{"email": "foo@example.com", "password": "secret"}' \
http://localhost:3000/api/v1/user/token
{"token": "ここにJWT", "expire": 7200}
認証が必要なAPI
curl -X GET \
-H 'Content-Type: application/json' \
-H 'Authorization Bearer ここにJWT' \
http://localhost:3000/api/v1/user/profile
{"email": "foo@example.com", "name": "Example person"}
コードリスト
追加、変更したコードリストです。
+---Gemfile
+---app
| +---apis
| | +---root.rb
| | \---profile.rb
| \---views
| \---api
| +---token.jbuilder
| \---profile.jbuilder
\---config
+---application.rb
\---routes.rb
gem 'grape'
gem 'grape-jbuilder'
gem 'rack-jwt'
config.paths.add File.join('app', 'apis'), glob: File.join('**', '*.rb')
config.autoload_paths += Dir[Rails.root.join('app', 'apis', '*')]
config.middleware.use(Rack::Config) do |env|
env['api.tilt.root'] = Rails.root.join 'app', 'views', 'api'
end
mount API::Root => '/'
module API
class Root < Grape::MiddleWare::Base
prefix 'api'
content_type :json, 'application/json; charset=UTF-8'
format :json
version 'v1', using: :path
# Rack::JWTをGrapeに組み込み
Grape::Middleware::Auth::Strategies.add(
:jwt_auth,
Rack::JWT::Auth,
->(options) { [
[:secret, :verify, :options, :exclude].
select { |key| options.has_key?(key) }.
collect { |key| [key, options[key]] }.to_h
] }
)
# 認証しないURLを定義する
namespace :user do
mount API::Token
end
# 認証するURLを定義する
namespace :user do
auth :jwt_auth, {secret: Rails.application.secrets.secret_key_base}
before do
# Rack::JWTが追加するキーをチェック
error!('Unauthorized.', 401) unless env['jwt.payload']
error!('Unauthorized.', 401) unless env['jwt.payload']['data']
error!('Unauthorized.', 401) unless env['jwt.payload']['data']['id']
@current_user = User.find_by_id env['jwt.payload']['data']['id']
error!('Access Denied.', 403) unless @current_user
end
mount API::Profile
end
end
end
module API
class Token
params do
requires :email, type: String
requires :password, type: String
end
post '/token', jbuilder:'token' do
user = User.find_by_email params[:email]
error!('Unauthorized.', 401) unless user
error!('Unauthorized.', 401) unless user.valid_password?(params[:password])
now = Time.current.to_i
expire = 7200
payload = {
data: {id: user.id},
exp: now + expire,
iat: now
}
jwt = Rack::JWT::Token.encode(payload, Rails.application.secrets_key_base)
@token = {token: jwt, expire: expire}
end
end
end
module API
class Profile
get '/profile', jbuilder:'profile' do
# @current_user を利用するので特に処理はない
end
end
end
json.(@token, :token, :expire)
json.(@current_user, :email, :name)
トークン生成と認証部分について
- Gemfile
- config/application.rb
- config/routes.rb
https://github.com/ruby-grape/grape#rails の説明通りに{grape}をRailsに組み込みます。
- app/apis/root.rb
まず認証しないURLと認証するURLでnamespace
ブロックを分けます。
少なくともトークンを発行するURLは認証しない方に定義が必要です。
次にRegister custom middleware for authenticationの説明通りにRack::JWTを組込むのですが、auth
のブロックを書いていません。これは{grape}のミドルウェアとRack::JWTの引数が異なり、Rack::JWTにブロックを渡せないためです。その代わりにRack::JWTで設定されたキーをbefore
でチェックしています。
そしてauth
の3つ目の引数({secret: Rails.application.secrets.secret_key_base}
の部分)はStrategies.add
の3つ目の引数のブロックに渡され、このブロックの返り値がRack::JWT::Auth
のコンストラクタの2つ目の引数に渡されるので、:secret
, :verify
, :options
, :exclude
の4つから存在するものだけを返しています。
- app/apis/token.rb
- app/views/api/token.jbuilder
クライアントから送られてきたemail
とpassword
をチェック後、Rack::JWTのToken.encode
を使ってJWTのトークンを生成しています。エンコード用の秘密文字にRails.application.secrets_key_base
を使っていますが、他に定義したものでも利用できます。
- app/apis/profile.rb
- app/views/api/profile.jbuilder
認証するURLの説明用に{grape}とJbuilderで作ったサンプルです。
まとめ
Rack::JWTの内部実装に依存してしまいましたが、うまく{grape}でJWTを使ったトークン認証を実装できました。もっとスマートな方法を知っているよ!という方がいれば是非教えてほしいです!!!