下記を作成してみます。SPA のバックエンド側を想定しています。
-
/auth
でログインし token を発行してもらえる。 -
/users
でユーザー一覧を取得できる。- ただし token が必要。
- 更に admin ユーザーのみでき、member ユーザーはアクセス許可がない。
上記を利用したフロント側の記事も書いておりますので宜しければご覧ください。
Vue.js で簡単なログイン画面 (トークン認証) を作ってみた
User API 作成
rails new で API モードで新規アプリ作成します。
$ rails new yourappname --api
scaffold で User Model と Controller を作ります。
lock_version というカラムを追加すると Rails で楽観ロックを実装してくれます。便利ですね。
$ cd yourappname
$ rails g scaffold User name:string \
email:string \
role:integer \
password_digest:string \
register_user:integer \
update_user:integer \
lock_version:integer \
activated_at:datetime \
deleted_at:datetime
seeds に初期データを書いてみましょう。
User.create!([
{
name: 'admin',
email: 'admin@example.com',
role: 'admin',
password_digest: '$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK',
register_user: 1,
update_user: 1,
lock_version: 0,
activated_at: '2020-02-03 00:00:00',
deleted_at: nil,
},
{
name: 'member',
email: 'member@example.com',
role: 'member',
password_digest: '$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK',
register_user: 1,
update_user: 1,
lock_version: 0,
activated_at: '2020-02-03 00:00:00',
deleted_at: nil,
},
])
開発用の DB をマイグレーション & 初期データを投入します。
$ rails db:migrate && rails db:seed
サーバーを起動します。
$ rails s
別ターミナルにて curl で叩くと初期データが返ってきます。
まずは簡単な API ができたことを確認します。
# 別ターミナル
$ curl -s http://localhost:3000/users | jq
[
{
"id": 1,
"name": "admin",
"email": "admin@example.com",
"role": "admin",
"password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
"register_user": 1,
"update_user": 1,
"lock_version": 0,
"activated_at": "2020-02-03T00:00:00.000Z",
"deleted_at": null,
"created_at": "2020-02-22T04:08:58.769Z",
"updated_at": "2020-02-22T04:08:58.769Z"
},
{
"id": 2,
"name": "member",
"email": "member@example.com",
"role": "member",
"password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
"register_user": 1,
"update_user": 1,
"lock_version": 0,
"activated_at": "2020-02-03T00:00:00.000Z",
"deleted_at": null,
"created_at": "2020-02-22T04:08:58.775Z",
"updated_at": "2020-02-22T04:08:58.775Z"
}
]
テストも実行してエラーが無いことも確認しましょう。
$ rails t
Running via Spring preloader in process 16858
Run options: --seed 26013
# Running:
.....
Finished in 0.320389s, 15.6060 runs/s, 21.8484 assertions/s.
5 runs, 7 assertions, 0 failures, 0 errors, 0 skips
ちなみに jq が入っていない場合は入れると JSON 整形してくれて便利です。
$ brew install jq
Gemfile もろもろ
Gemfile は Node.js で言う、package.json 的なファイルだと思います。
下記を追記します。ついでにオススメの gem も。
gem 'redis-rails' # Redis を扱うための gem
gem 'mock_redis' # Redis のモック。テスト実行時に使用。
gem 'config' # 環境ごとに yml の設定ファイルを作成可能。
gem 'pundit' # 認証周りを REST ベースでシンプルに実装できる。
gem 'paranoia' # 論理削除できる。
下記をコメントアウトします。
has_secure_password を使う際に必要です。
これはテーブルに password_digest というカラムを用意すると、Rails がパスワードをハッシュ化してくれます。
gem 'bcrypt', '~> 3.1.7'
bundle install するとパッケージがインストールされます。
$ bundle install
config をインストールすると rails g config:install が使えます。
環境ごとに yml ファイルができます。便利ですね。
$ rails g config:install
Running via Spring preloader in process 17408
create config/initializers/config.rb
create config/settings.yml
create config/settings.local.yml
create config/settings
create config/settings/development.yml
create config/settings/production.yml
create config/settings/test.yml
append .gitignore
Redis 設定
セッション情報を保存するためにインメモリ DB の Redis を使います。
Mac の方は brew でインストール。
$ brew install redis
redis-server で起動できます。簡単でいいですね。
$ redis-server
いったん、全環境共通の settings.yml に url と timeout を追記します。
こうすると例えば、下記の url の値を取り出すには Settings.session.url
と記述すれば OK です。
session:
url: redis://localhost:6379
timeout: 7200
role:
member: member
admin: admin
ところで Redis 接続時に毎回 Redis.new() を書くのは不便なので、initializers/ 下に redis.rb を作成します。
ついでにテストの時は Redis 立ち上げなくても済むようにしました。
if Rails.env.test?
REDIS = MockRedis.new
else
REDIS = Redis.new(url: Settings.session.url)
end
Auth コントローラー作成
コマンドラインから枠を作ります。
$ rails g controller auth
auth のルーティングを追加します。
個人的にはなるべく resources を使うようにすると綺麗だと思います。
Rails.application.routes.draw do
resources :users
+ resources :auth, :only => [:create, :destroy]
end
セッション作成処理を concerns に切り出してみます。
module Session
def self.create(user)
token = SecureRandom.hex(64)
REDIS.mapped_hmset(
token,
'user_id' => user.id,
'role' => user.role,
)
REDIS.expire(token, Settings.session.timeout)
return token
end
end
AuthController で先ほど作った Session モジュールを利用します。
class AuthController < ApplicationController
+ def create
+ user = User.find_by(email: params[:email])
+ token = ''
+ status = :unauthorized
+ if user && user.authenticate(params[:password])
+ token = Session.create(user)
+ status = :created
+ end
+ render json: { token: token }, status: status
+ end
end
models/user.rb に下記を追記。
物理削除とパスワードハッシュ化を利用します。
enum で member と admin も定義しておきます。
class User < ApplicationRecord
+ acts_as_paranoid
+ has_secure_password
+ enum role: { member: 0, admin: 1 }
end
動作確認してみましょう。
ログイン OK
まずは seeds.rb に記述した email と password で /auth を叩きます。
token が返ってきますね
$ curl -s \
-X POST http://localhost:3000/auth \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "password1234"}'
{"token":"ed64bbcf830e4feca9203b0955c5916fda815a23da80da8a7bb62b8c6466dbc34a07e21e9dd96447882fc8b9cb06121c8ee414b79ffedef4892e7197bbe9edd1"}
redis-cli で Redis に接続して、トークンが作成されたか確認してみましょう。
おお、できています!
$ redis-cli
127.0.0.1:6379> keys *
1) "ed64bbcf830e4feca9203b0955c5916fda815a23da80da8a7bb62b8c6466dbc34a07e21e9dd96447882fc8b9cb06121c8ee414b79ffedef4892e7197bbe9edd1"
ログイン NG
password を適当に変えてみます。
token が空になっていますね。
$ curl -s -X POST http://localhost:3000/auth \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "wrongpassword"}'
{"token":""}
認証を設定
Session モジュールにセッション情報を取得する function を追加します。
module Session
+ def self.get(token)
+ REDIS.hgetall(token)
+ end
def self.create(user)
token = SecureRandom.hex(64)
REDIS.mapped_hmset(
token,
'user_id' => user.id,
'role' => user.role,
)
REDIS.expire(token, Settings.session.timeout)
return token
end
end
続いて基幹コントローラーに手を入れます。
-
authenticate_with_http_token
を利用すると、
リクエストヘッダに 'Authorization: Token hogehoge' がセットされていた場合に、トークン hogehoge を取り出せます。- 上記を利用するには
include ActionController::HttpAuthentication::Token::ControllerMethods
する必要があります。
- 上記を利用するには
-
before_action :set_session
で、Redis に登録してあるセッション (ここでは user.id と user.role ) をメンバにセットしています。 -
before_action :require_login
で、基本的にセッションがない場合に認証エラーとしています。
class ApplicationController < ActionController::API
+ include ActionController::HttpAuthentication::Token::ControllerMethods
+
+ before_action :set_session
+ before_action :require_login
+
+ @session = {}
+
+ def require_login
+ render json: { error: 'unauthorized' }, status: :unauthorized if @session.empty?
+ end
+
+ private
+ def set_session
+ authenticate_with_http_token do |token, options|
+ @session = Session.get(token)
+ end
+ end
end
AuthController に skip_before_action を追加します。
これを追加しないと一生ログインができません・・。
class AuthController < ApplicationController
+ skip_before_action :require_login, only: [:create]
+
def create
user = User.find_by(email: params[:email])
token = ''
status = :unauthorized
if user && user.authenticate(params[:password])
token = Session.create(user)
status = :created
end
render json: { token: token }, status: status
end
end
ユーザー一覧 OK
Authorization: Token xxx
には、先ほど /auth を叩いて得られた token をセットします。
/users の結果が返ってきています。
$ curl -s http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Token ed64bbcf830e4feca9203b0955c5916fda815a23da80da8a7bb62b8c6466dbc34a07e21e9dd96447882fc8b9cb06121c8ee414b79ffedef4892e7197bbe9edd1" \
| jq
[
{
"id": 1,
"name": "admin",
"email": "admin@example.com",
"role": 0,
"password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
"register_user": 1,
"update_user": 1,
"lock_version": 0,
"activated_at": "2020-02-03T00:00:00.000Z",
"deleted_at": null,
"created_at": "2020-02-21T14:28:10.627Z",
"updated_at": "2020-02-21T14:28:10.627Z"
},
{
"id": 2,
"name": "member",
"email": "member@example.com",
"role": 0,
"password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
"register_user": 1,
"update_user": 1,
"lock_version": 0,
"activated_at": "2020-02-03T00:00:00.000Z",
"deleted_at": null,
"created_at": "2020-02-21T14:28:10.635Z",
"updated_at": "2020-02-21T14:28:10.635Z"
}
]
ユーザー一覧 NG
token を適当なものに変えるとちゃんと認証エラーになりました
$ curl -s http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Token wrong_token" | jq
{
"error": "unauthorized"
}
権限周り設定
Web アプリでは権限は必須と言えます。
Pundit を使うと Rest ベースでシンプルに実装できます。
$ rails g pundit:install
$ rails g pundit:policy user
まずは application_policy.rb に管理者かどうかの admin? を追加してみます。
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
+ def admin?
+ @user['role'] == Settings.role.admin
+ end
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
scope.all
end
end
end
続いて、user_policy.rb の index に先ほど作った admin? を追加します。
class UserPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.all
end
end
+ def index?
+ admin?
+ end
end
あとは利用側です。
application_controller.rb で Pundit を include し、current_user メソッドを追加します。
また、Pundit の NotAuthorizedError を拾えるように rescue_from を追加します。
(エラー処理は増えてきたら concerns に切り出すと良いと思います。)
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
+ include Pundit
before_action :set_session
before_action :require_login
@session = {}
+ rescue_from Pundit::NotAuthorizedError do |e|
+ render json: { detail: e.message }, status: :unauthorized
+ end
+
def require_login
render json: { error: 'unauthorized' }, status: :unauthorized if @session.empty?
end
+ def current_user
+ @session
+ end
private
def set_session
authenticate_with_http_token do |token, options|
@session = Session.get(token)
end
end
end
後は users_controller.rb の index に Pundit を追加します。
class UsersController < ApplicationController
before_action :set_user, only: [:show, :update, :destroy]
# GET /users
def index
- @users = User.all
-
- render json: @users
+ users = authorize Pundit.policy_scope(@session, User)
+ render json: users
end
# GET /users/1
def show
render json: @user
end
# POST /users
def create
@user = User.new(user_params)
if @user.save
render json: @user, status: :created, location: @user
else
render json: @user.errors, status: :unprocessable_entity
end
end
# PATCH/PUT /users/1
def update
if @user.update(user_params)
render json: @user
else
render json: @user.errors, status: :unprocessable_entity
end
end
# DELETE /users/1
def destroy
@user.destroy
end
private
# Use callbacks to share common setup or constraints between actions.
def set_user
@user = User.find(params[:id])
end
# Only allow a trusted parameter "white list" through.
def user_params
params.require(:user).permit(:name, :email, :role, :password_digest, :register_user, :update_user, :lock_version, :activated_at, :deleted_at)
end
end
さて、動作確認してみましょう。
まずは member ユーザーでログインし、/users を叩きます。
ユーザー一覧が取得できないことを確認します。
$ curl -s -X POST http://localhost:3000/auth -H "Content-Type: application/json" -d '{"email": "member@example.com", "password": "password1234"}'
{"token":"482125111d11c2882cad25b700221a45bc64d8745771f427cb8039337c0e7cda95fc8498f98d6d784781b177afe84699bccb2d62628b1bee3ccee0e96eb9e576"}
$ curl -s http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Token 482125111d11c2882cad25b700221a45bc64d8745771f427cb8039337c0e7cda95fc8498f98d6d784781b177afe84699bccb2d62628b1bee3ccee0e96eb9e576"
{"error":"not allowed to index? this User::ActiveRecord_Relation"}
続いて admin ユーザーでログインし、/users を叩きます。
結果が取得できましたね
$ curl -s -X POST http://localhost:3000/auth -H "Content-Type: application/json" -d '{"email": "admin@example.com", "password": "password1234"}'
{"token":"a87dee3d5cb1592e3b3b09f78931e26254292f1453ea77a1d491fd949ce508d5ea7e00dec6163fb83ed9c7f5d39b672aa429c770a6d0487952022fc55a493487"}
$ curl -s http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Token a87dee3d5cb1592e3b3b09f78931e26254292f1453ea77a1d491fd949ce508d5ea7e00dec6163fb83ed9c7f5d39b672aa429c770a6d0487952022fc55a493487"
[{"id":1,"name":"admin","email":"admin@example.com","role":"admin","password_digest":"$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK","register_user":1,"update_user":1,"lock_version":0,"activated_at":"2020-02-03T00:00:00.000Z","deleted_at":null,"created_at":"2020-02-11T12:14:52.557Z","updated_at":"2020-02-11T12:14:52.557Z"},{"id":2,"name":"member","email":"member@example.com","role":"member","password_digest":"$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK","register_user":1,"update_user":1,"lock_version":0,"activated_at":"2020-02-03T00:00:00.000Z","deleted_at":null,"created_at":"2020-02-11T12:14:52.565Z","updated_at":"2020-02-11T12:14:52.565Z"}]