2017/04/26 curl部分に間違いがあったので修正、ついでにログインの必要な動作を追記
目標
Rails v5.0.0 から追加されたapiオプションを使い、ユーザーの作成と認証機能を実装したベーシックな Rails API を作る
rails new
まずはプロジェクトを作成します
$ rails new devise-api --api --skip-bundle
Gemfile に次の gem を追加し, bundle install
gem 'devise' gem 'active_model_serializers'
devise
devise を立ち上げます
$ rails generate devise:install
create config/initializers/devise.rb
create config/locales/devise.en.yml
User モデルを作成します
$ rails generate devise User
invoke active_record
create db/migrate/20160710134334_devise_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
insert app/models/user.rb
route devise_for :users
token 認証
access_token カラムをユーザーテーブルに追加します
$ rails generate migration add_access_token_to_user
class AddAccessTokenToUser < ActiveRecord::Migration[5.0] def change add_column :users, :access_token, :string end end
サンプルとしてトークンの生成にユーザーのidとdeviseが生成するトークン自身を使用します。 devise モジュールの説明についてはこちら
# app/models/user.rb class User < ApplicationRecord # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable # :recoverable, :rememberable, :trackable devise :database_authenticatable, :registerable, :validatable after_create :update_access_token! validates :email, presence: true def update_access_token! self.access_token = "#{self.id}:#{Devise.friendly_token}" save end end
ユーザー認証のロジックは application_controller に配置されます。
具体的には、ヘッダーのAuthorizationにてトークンを受け取って、データベースのアクセストークンと照合しています。
# app/controllers/application_controller.rb class ApplicationController < ActionController::API include AbstractController::Translation before_action :authenticate_user_from_token! respond_to :json ## # User Authentication # Authenticates the user with OAuth2 Resource Owner Password Credentials def authenticate_user_from_token! auth_token = request.headers['Authorization'] if auth_token authenticate_with_auth_token auth_token else authenticate_error end end private def authenticate_with_auth_token auth_token unless auth_token.include?(':') authenticate_error return end user_id = auth_token.split(':').first user = User.where(id: user_id).first if user && Devise.secure_compare(user.access_token, auth_token) # User can access sign_in user, store: false else authenticate_error end end ## # Authentication Failure # Renders a 401 error def authenticate_error render json: { error: t('devise.failure.unauthenticated') }, status: 401 end end
sessions_controller に対して login ルートを割り当てます
# config/routes.rb Rails.application.routes.draw do devise_for :user, only: [] namespace :v1, defaults: { format: :json } do resource :login, only: [:create], controller: :sessions end end
sessions_controller は次のようにログインリクエストを処理します。
skip_before_action :authenticate_user_from_token!によって、認証処理をスキップします。
# app/controllers/v1/sessions_controller.rb module V1 class SessionsController < ApplicationController skip_before_action :authenticate_user_from_token! # POST /v1/login def create @user = User.find_for_database_authentication(email: params[:email]) return invalid_email unless @user if @user.valid_password?(params[:password]) sign_in :user, @user render json: @user, serializer: SessionSerializer, root: nil else invalid_password end end private def invalid_email warden.custom_failure! render json: { error: t('invalid_email') } end def invalid_password warden.custom_failure! render json: { error: t('invalid_password') } end end end
session_serializer.rb によって、オブジェクトを整形することができます
# app/serializers/v1/session_serializer.rb module V1 class SessionSerializer < ActiveModel::Serializer attributes :email, :token_type, :user_id, :access_token def user_id object.id end def token_type 'Bearer' end end end
ユーザーのサインアップ
次にユーザーの作成プロセスを実装します
# app/controllers/v1/users_controller.rb module V1 class UsersController < ApplicationController skip_before_action :authenticate_user_from_token!, only: [:create] # POST # Create an user def create @user = User.new user_params if @user.save! render json: @user, serializer: V1::SessionSerializer, root: nil else render json: { error: t('user_create_error') }, status: :unprocessable_entity end end private def user_params params.require(:user).permit(:email, :password) end end end
# config/routes.rb Rails.application.routes.draw do devise_for :user, only: [] namespace :v1, defaults: { format: :json } do resource :login, only: [:create], controller: :sessions resource :users, only: [:create] end end
動作確認
データベースを作成、マイグレーション(rake db:create db:migrate)して、サーバーを立ち上げ(rails server)ます。
コンソールからユーザーを作成、ログインをテストします。
まずはPOST /v1/users:
パラメーターをjson形式で送ります。
$ curl localhost:3000/v1/users --data '{"user": {"email": "user@example.com", "password": "mypass"}}' -v -H "Accept: application/json" -H "Content-type: application/json"
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> POST /v1/users HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.51.0
> Accept: application/json
> Content-type: application/json
> Content-Length: 61
>
* upload completely sent off: 61 out of 61 bytes
< HTTP/1.1 200 OK
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: application/json; charset=utf-8
< ETag: W/"fc7ab698e44a0b4687a7d41dc95e8c7a"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 85aa1e62-ac17-4074-b593-7ad45e40e166
< X-Runtime: 0.192480
< Transfer-Encoding: chunked
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:ZsQVFPi8qvc_zLe2Zjtj"}
ユーザーが作成できたので、次にPOST /v1/login
$ curl localhost:3000/v1/login --data 'email=user@example.com&password=mypass'
{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:ZsQVFPi8qvc_zLe2Zjtj"}
ちゃんとaccess_tokenが返ってくることを確認できました。
認証が必要なメソッドの作成
access_tokenを用いて認証しなければ、使うことの出来ないメソッドを作ってみます。
Userコントローラーにindexメソッドを追加します:
module V1 class UsersController < ApplicationController skip_before_action :authenticate_user_from_token!, only: [:create] def index render json: User.all, each_serializer: V1::UserSerializer end # POST # Create an user def create @user = User.new user_params if @user.save! render json: @user, serializer: V1::SessionSerializer, root: nil else render json: { error: t('user_create_error') }, status: :unprocessable_entity end end private def user_params params.require(:user).permit(:email, :password) end end end
このとき、skip_before_action :authenticate_user_from_token!には何も追加しないことで、indexメソッドが認証を必要とするメソッドとなります。逆に認証を必要としないメソッドを作成したい場合は追加するようにしましょう。
ルートも忘れずに設定しましょう:
resources :users, only: [:index, :create]
user_serializer.rbを追加します:
# app/serializers/v1/user_serializer.rb module V1 class UserSerializer < ActiveModel::Serializer attributes :id, :email, :created_at, :updated_at end end
ではここで、コマンドからGET /v1/usersを行ってみましょう:
$ curl localhost:3000/v1/users
{"error":"You need to sign in or sign up before continuing."
ちゃんと、application_controller内のAuthenticate Failureが返ってきています。
次に認証を行ってみましょう。
まずはログインしてaccess_tokenを取得:
curl localhost:3000/v1/login --data 'email=user@example.com&password=mypass'
{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:ZsQVFPi8qvc_zLe2Zjtj"}
そのaccess_tokenをヘッダーのAuthorizationに入れ、リクエストを送る:
curl localhost:3000/v1/users -v -H "Authorization: 1:ZsQVFPi8qvc_zLe2Zjtj"
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /v1/users HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.51.0
> Accept: */*
> Authorization: 1:ZsQVFPi8qvc_zLe2Zjtj
>
< HTTP/1.1 200 OK
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: application/json; charset=utf-8
< ETag: W/"6e18487e56deb9bae7d6fa2bebffc7af"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 1f7e86b8-ff99-45be-a818-69c34605ac62
< X-Runtime: 0.006412
< Transfer-Encoding: chunked
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
[{"id":1,"email":"user@example.com","created_at":"2017-04-26T01:44:16.104Z","updated_at":"2017-04-26T01:44:16.104Z"}]
このようにユーザーの一覧がリストになって返ってきていれば正しい動作です。
実際にフロントエンドからリクエストを行う際にはユーザーの作成やログイン後に、access_tokenをローカルストレージ等に保存しておくのが良いでしょう。
CORS
フロントエンドのアプリケーションをこの API に対しリクエストを送る形で作成するのですが、通常の設定では別ドメインからのリクエストは拒否されてしまいます。
これを解決するには rack-cors を使ってクロスドメイン通信を許可します。
gem 'rack-cors'
設定を config/initializers/cors.rb に追加します:
config.middleware.insert_before 'Rack::Runtime', 'Rack::Cors' do allow do origins '*' resource '*', headers: :any, methods: [:get, :put, :post, :patch, :delete, :options] end end
この設定のままではすべてのドメインからのリクエストを許可しているので、セキュリティ面は脆弱です
まとめ
難しいトークン認証も、deviseを用いれば結構簡単に実装できますね。不明点やなにか間違っていること等ありましたら、コメントいただければと思います。