7839

雑草魂エンジニアブログ

【Rails】DB を使わずに、jsonファイルで簡易ユーザー管理機能を実装する

ある Rails アプリケーションにおいて、下図のように、1つの DB を複数のアプリケーションが参照する場合がある。(ただし、複数のアプリケーションから、自由に DB の書き込みができるのではなく、1つのみに Read/Write 権限を持たせて、その他は Read 権限しかない。)

f:id:serip39:20201230080142p:plain

この場合に、DB の Read 権限しかない Rails アプリケーションでは、主にデータの可視化だけを行っている。そして、データの可視化において、ログインユーザーに応じて表示を変えたいという話になり、本題の DB を使わずに、jsonファイルで簡易ユーザー管理を実装したので、今回紹介する。(マルチDBでの実装も検討したが、一旦はログインユーザー管理のみとのことだったので、jsonファイルでの代用で押し切ることとした。)

Rails のユーザー管理といえば、device だと思うが、今回、deviceライクな実装になるようにしてみた。

jsonファイル/ディレクトリ構成

今回、jsonファイルは DB と同じ扱いをするため、Rails.root/db に配置することとした。

Rails.root
├─ app
├─ db
│  └─ user.json
└─ Gemfile

簡単であるが、試験的に2人のユーザーを設定した。

{
  "members": [
    {
      "user_id": "admin",
      "name": "管理者",
      "password": "admin",
    },
    {
      "user_id": "test1",
      "name": "テストユーザー1",
      "password": "test1",
    }
  ]
}

Uerモデルを作る

Userモデルでは、以下を考慮している。

  • ログイン画面から、ID/PASSで認証できる
  • ログイン後には、sessionにID/PASSを暗号化したaccess_tokenを保存しておき、リロード時には自動的にログインさせる(復号化時に使用した OpensslUtil に関しては、後ほど紹介する)
  • membersのデータを都度読み込むのではなく、インスタンス生成時に、読み込ませるようにしている
class User
  include ActiveModel::Model

  def initialize(user_id: "", password: "", name: "")
    @user_id = user_id
    @password = password
    @name = name
    @members = []
    # jsonファイルのmembersを読み込む
    File.open("#{Rails.root}/db/user.json") do |file|
      hash = JSON.load(file)
      @members = hash["members"]
    end
    return @members
  end

  attr_accessor :user_id, :name, :password
  validates :user_id, presence: true
  validates :name, presence: true
  validates :password, presence: true

  # ログインユーザーを認証する
  def authenticated?
    @members.any? {|member| member["user_id"] == @user_id && member["password"] == @password}
  end

  # sessionのaccess_tokenから自動で再ログインさせる
  def present?(access_token)
    str_decrypted_token = OpensslUtil.aes_decrypt(access_token)
    str_arr = str_decrypted_token.split("::")
    current_user = @members.find {|member| member["user_id"] == str_arr[0] && member["password"] == str_arr[1]}
    if !current_user.blank?
      @user_id = current_user["user_id"]
      @password = current_user["password"]
      @name = current_user["name"]
      return self
    else
      return false
    end
  end
end

include ActiveModel::Model を追加しておくことで、Action PackやAction Viewと連携する以下の機能を使うことができるようになる。

  • モデル名の調査
  • 変換
  • バリデーション

また、form_forやrenderなどのAction Viewヘルパーメソッドも使えるようになるので、便利である。

routesを設定する

ログイン画面、ログイン処理、ログアウト処理ができるように、ルートを追加する。

Rails.application.routes.draw do
  <-- 省略 -->
  controller :sessions do
    get    'login' => :new #ログイン画面表示
    post   'login' => :create #フォームに入力されたID/PASSでログインを行う
    delete 'logout' => :destroy #ログアウトを行う
  end
end

Controller を作る

全てのControllerで、ログインユーザーである current_user を参照したいので、application_controller.rb に current_user を定義する。

また、before_action :login_requiredを行うことで、ログインユーザーでない場合は、ログイン画面にリダイレクトさせる。

class ApplicationController < ActionController::Base
  helper_method :current_user
  before_action :login_required

  private

  def current_user
    @current_user ||= User.new().present?(session[:access_token]) if session[:access_token]
  end

  def login_required
    redirect_to login_path status: :unauthorized unless current_user
  end
end

SessionsController に関しては、ログインしていない場合にログイン画面を表示させたいので、skip_before_action :login_requiredを追加しておく必要がある。

また、認証された場合には、#{user_id}::#{password} の文字列として暗号化を行い、sessionに保存する。(暗号化時に使用した OpensslUtil に関しては、後ほど紹介する)

class SessionsController < ApplicationController
  skip_before_action :login_required

  def new
  end
  #ログイン処理
  def create
    login_user = User.new(user_id: params[:user_id], password: params[:password])
    if login_user.authenticated? #認証された場合
      session[:access_token] = OpensslUtil.aes_encrypt("#{login_user.user_id}::#{login_user.password}")
      redirect_to(root_path) && return
    else
      render :new, status: :bad_request
    end
  end
  #ログアウト処理
  def destroy
    session.delete :access_token
    redirect_to action: :new
  end
end

Viewを作る

ログイン画面を作る

.row.justify-content-center
  .col-xl-5.col-lg-6.col-md-9
    .card.o-hidden.border-0.shadow-lg.my-5
      .card-body.p-0
        .row
          .col-lg-12
            .p-5
              = form_tag login_path, method: :post, class: "user" do
                .form-group
                  = text_field_tag(:user_id, nil, class: "form-control form-control-user", placeholder: "ユーザーID", required: true, value: params[:user_id])
                .form-group
                  = password_field_tag(:password, nil, class: "form-control form-control-user", placeholder: "パスワード", required: true)

                button.btn.btn-primary.btn-user.btn-block(type="submit") ログイン

暗号化・復号化に関して

暗号化・復号化のメソッドに関しては、modelやcontrollerでも使いたかったので、Rails.root/lib/utils/の配下に作成することとした。

Rails.root/lib/utils/ を初回起動時に、読み込むように config/application.rb に以下を追記する。

module App
  class Application < Rails::Application
    # ~ 省略 ~
    # Autoload utils
    config.paths.add 'lib/utils', eager_load: true
  end
end

暗号化と復号化に関しては、OpenSSL を使って実装している。

class OpensslUtil
  SALT = "AM3hFSF0MLkTgKRG6U418gBC"
  PASSWORD = "TEST1234"
  BIT = 256
  # ======================================
  # <暗号化>
  # ======================================
  def self.aes_encrypt(plain_text)
    enc = OpenSSL::Cipher::AES.new(BIT, :CBC)
    enc.encrypt
    salt = Base64.decode64(SALT)
    key_iv = OpenSSL::PKCS5.pbkdf2_hmac(PASSWORD, salt, 2000, enc.key_len + enc.iv_len, "sha256")
    enc.key = key_iv[0, enc.key_len]
    enc.iv = key_iv[enc.key_len, enc.iv_len]
    encrypted_text = enc.update(plain_text) + enc.final
    Base64.encode64(encrypted_text).chomp
  end
  # ======================================
  # <復号>
  # ======================================
  def self.aes_decrypt(encrypted_text)
    encrypted_text = Base64.decode64(encrypted_text)
    salt = Base64.decode64(SALT)
    dec = OpenSSL::Cipher::AES.new(BIT, :CBC)
    dec.decrypt
    key_iv = OpenSSL::PKCS5.pbkdf2_hmac(PASSWORD, salt, 2000, dec.key_len + dec.iv_len, "sha256")
    dec.key = key_iv[0, dec.key_len]
    dec.iv = key_iv[dec.key_len, dec.iv_len]
    dec.update(encrypted_text) + dec.final
  end
end

sessionの保存期間を設定する

access_tokenを保存している session の保存期間を設定するために、config/initializers/session_store.rb を作成する。今回は、保存期間を1時間とした。

# Set Expired Session
# https://edgeapi.rubyonrails.org/classes/ActionDispatch/Session/CookieStore.html
Rails.application.config.session_store :cookie_store, expire_after: 1.hour

まとめ

今回は、ちょっとイレギュラーな DB を使わずに、jsonファイルで簡易ユーザー管理機能を実装した。deviceライフな感じにしたので、使い勝手としても悪くない気がしている。ただし、Token管理やログイン履歴まではできないので、本当に簡易版での使用用途になると思われる。

それでは、ステキな開発ライフを。