Ruby on Rails, Vue.js によるモダン WEB アプリケーション 実践編 (その3)

# はじめに

アプリケーションは Rails と Vue.js を想定して、実戦的なアプリケーション動作環境を構築するための方法を段階的に紹介していきます。

Rails と Vue を使ったアプリケーションを初めて開発する場合は [Ruby on Rails, Vue.js で始めるモダン WEB アプリケーション入門](https://qiita.com/tatsurou313/items/4f18c0d4d231e2fb55f4) も参考にしてみて下さい。


# アプリケーションの機能

* [(その1) アプリケーションは冗長構成の Postgres に接続できる](https://qiita.com/tatsurou313/items/b7b0bbabd1d61baccbb3)
* [(その1) アプリケーション、DB は k8s にデプロイされる](https://qiita.com/tatsurou313/items/b7b0bbabd1d61baccbb3)
* [(その1) LE証明書を使う(定期更新できる)](https://qiita.com/tatsurou313/items/b7b0bbabd1d61baccbb3)
* [(その2) CI/CD 環境がある](https://qiita.com/tatsurou313/items/91afe2502acde9370db1)
* (その3) OAuth によりログイン認証できる


# OAuth によりログイン認証する

## OAuth とは

権限の認可(Authorization) を行うためのオープンスタンダードです。[Wikipedia - OAuth](https://ja.wikipedia.org/wiki/OAuth)

WebサービスにおけるHTTPプロトコルを使います。

認可とは例えば「ログインが必要な情報へのアクセスを許可する」といったことを示し、OAuth はこのような許可を行う時に使われる仕組みを仕様としてまとめたものです。

Web アプリケーションを作成した時に、アカウントが存在するユーザがログインした時だけ情報を表示させたいといった要望は一般的であり、その時にユーザ情報を管理する(存在するユーザであり、パスワードが有効)処理を外部の Google や Facebook 等に任せることが出来るといったメリットがあります。

アプリケーションはユーザのパスワードを保存する必要がありません。
これは大きなメリットです。例えば自作のアプリケーションのシステムに何かしらのセキュリティ事故が発生して情報漏洩が発生しても、OAuth に保存されたパスワード等のセキュリティ情報が漏れる心配はありません。


## Rails で OAuth 認証を行う方法

Rails で認証機能を実装する際によく使われる gem に `devise` があります。
※ Rails でログインや権限管理をする方法として[Rails - DB/LDAP認証・認可をdevise,rolify,cancancanで実装する](https://qiita.com/tatsurou313/items/0f632887d049e9503e3b)も参考にしてみて下さい。

devise は OmniAuth を扱うことができ、この OmniAuth が OAuth 認証を行うことが出来ます。

Rails で OAuth と OmniAuth をインストールする方法の概要は次のとおりです。

1. devise をインストールする
2. devise を初期化する
    * devise の設定ファイルを追加する
    * ユーザモデルを作成する
3. OmniAuth(OAuth サーバの種類ごと) をインストールする

```ruby:Gemfileにdeviseを追加する
gem 'devise'
```

```bash:deviseをインストールして初期化する
# deviseをインストールする
$ bundle install

# deviseの設定ファイルを作成してユーザモデルを作成する
$ rails generate devise:install
$ rails generate devise user
```


## Facebook の OAuth 認証を設定する

Facebook の OAuth 認証をする gem に [omniauth-facebook](https://github.com/mkdynamic/omniauth-facebook) があります。

まずは omniauth-facebook gem をインストールして、OAuth 認証をするために User モデルのスキーマを変更するマイグレーションファイルを作成してマイグレーションします。

```ruby:GemfileにOmniAuth(Facebook)を追加する
gem 'omniauth-facebook'
```

```bash:OmniAuthをインストールする
# OmniAuthをインストールする
$ bundle install

# OmniAuth のためにユーザモデルをマイグレーションする
$ rails g migration AddOmniauthToUsers provider:string uid:string
$ rails db:migrate
```

次に OAuth 認証をするときに使うアプリID と app secret を発行します。

まずは [facebook for developers](https://developers.facebook.com/) で「アプリ」を作成します。
作成したら、アプリID と app secret を控えておきましょう。

控えた内容は devise.rb に設定します。

(注: 設定した内容は秘匿情報です。コミットしないようにしてください。後ほど保存方法について説明します)

```config/initializers/devise.rb
config.omniauth :facebook, "＜APP_ID＞", "＜APP_SECRET＞"
```

アプリID と app secret の設定が終わったらログインページを作成します。[OmniAuthのwikiを参照](https://github.com/plataformatec/devise/wiki/OmniAuth:-Overview)

```
$ rails generate devise:views -v sessions
```

OAuth 認証ではログインは OAuth サーバで行います。
そのため、アプリケーションでは OAuth サーバへのリンクを貼ればログインページは完成です。

ユーザはアプリケーションが用意した OAuth サーバへのリンクをたどりログインを行います。
ログインが終わるとアプリケーションサーバにリダイレクトされ、アプリケーションは OAuth 認証の結果を受け取ることが出来ます。

結果を受けたアプリケーションは自身の DB にある User モデルを探し、存在すれば該当の User モデルをログインユーザとして扱い、存在しなければ新規作成してその User モデルをログインユーザとして扱うようコントローラ `app/controllers/users/omniauth_callbacks_controller.rb` を作成します。

作成したコントローラが OAuth 認証後に devise から呼び出されるよう routes.rb にコントローラを指定し、ログイン成功・失敗時のメッセージを i18n の設定ファイル `config/locales/devise.en.yml` に記述すれば完成です。

```erb:app/views/devise/sessions/new.html.erb
<h2>Log in</h2>

<%= link_to "Sign in with Facebook", user_facebook_omniauth_authorize_path %>
```

```app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthController < Devise::OmniauthCallbacksController
  def facebook
    @user = User.find_or_create_for_oauth(request.env['omniauth.auth'])

    if @user.persisted?
      set_flash_message(:notice, :success, kind: "Facebook") if is_navigational_format?
      sign_in_and_redirect @user, event: :authentication
    else
      set_flash_message(:alert, :failure, kind: "Facebook", reason: 'User not found and cannot register')
      session["devise.facebook_data"] = request.env['omniauth.auth']
      redirect_to new_user_session_path
    end
  end

  def failure
    redirect_to new_user_session_path
  end
end
```

```config/routes.rb
Rails.application.routes.draw do
    : <snip>
  devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth' }
    : <snip>
end
```

```config/locales/devise.en.yml
en:
  devise:
    omniauth:
      failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
      success: "Successfully authenticated from %{kind} account."
  : <snip>
```

作成が終わったらログインページが表示できることと、ログインが出来ることを確認してみて下さい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206493/2b507043-63cb-af41-9ea6-4f7b3a4acdd1.png)

ログインが出来るようになったら、最後にアプリケーションの全ページをログインが必要なページにします。

```app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
end
```


## Facebook の OAuth を使うための秘匿情報を k8s の secret リソースで管理する

動作が確認出来たら秘匿情報を保存する方法を考えます。

基本的には、保存するソースコード内に保存しないようにして、アプリケーション起動時に環境変数から取得することにします。

まずはソースコードでは環境変数を参照するようにします。

```config/initializers/devise.rb
config.omniauth :facebook, ENV["FACEBOOK_APP_ID"], ENV["FACEBOOK_APP_SECRET"]
```

次にアプリケーションが安全に環境変数を受け取る方法を考えます。

docker コンテナに環境変数として保存すると誰でも参照できてしまいますので、k8s の secret リソースを使って pod に環境変数として参照できるようにします。

但し、k8s の secret リソースは base64 でエンコードされたデコード可能な形式のためそのままでは安全ではありません。
(**secret にパスワード直接設定したままリポジトリに保存しないようにしないようにしましょう**)

安全に secret リソースにパスワードを設定する方法として、マニフェストファイルの特定の値を外部から注入する [kustomize](https://github.com/kubernetes-sigs/kustomize) コマンドや、[kubesec](https://github.com/shyiko/kubesec) 、CRD (Custom Resource Definition) をつかう [SealedSecret](https://github.com/bitnami-labs/sealed-secrets) があります。

kustomize は環境変数の値をマニフェストに設定する方法は廃止され、外部ファイルを参照する方法に一本化されました。([kustomize/issues/692](https://github.com/kubernetes-sigs/kustomize/issues/692))
そのため、暗号化したファイルを読み込んでマニフェストを作成するには外部ツール(sops等)と併用する必要があり手間です。

kubesec は Secret ファイルを暗号化でき、sops はもっと一般的に YAML ファイルを暗号化できます。

どちらも PGP の仕組みを使って暗号化するため、利用するために必要な手間はほぼ変わりません。
Secret ファイルだけ暗号化できる kubesec に比べて sops は YAML 以外のファイルも暗号化できるため、今回は sops を使うことにしました。
(一方で kubesec は Secret の暗号化に特化しているため、data パラメータだけ暗号化して apiVersion, kind 等は暗号化しないで表示できるためマニフェストの内容は誰でも理解できるメリットがあります)

GnuPG については [Qiita 記事の gpg (GNU Privacy Guard)の使い方](https://qiita.com/moutend/items/5c22d6e57a74845578f6) によくまとまっているため参考にしてみて下さい。

尚、Windows ユーザで Gpg4win をインストールして GUI ツールの kleopatra を使う場合は、"New Key Pair" メニューからプライマリキーとサブキーの作成が出来ますが、その後サブキーを追加・削除する操作は GUI からできないためコマンドプロンプトで gpg コマンドを使う必要があります。

また、注意点として repository にある暗号化されたファイルは総当たり攻撃により時間をかければ解析ができ、解析が完了してしまった場合はファイルの内容は漏洩します。
public リポジトリの限界だとあきらめましょう。


## 本番環境で Facebook の OAuth を使うための修正を行う

本番環境で Facebook の認証を試してみると、Ingress によって認証処理のフェーズが進むごとに 2 つの Pod にアクセスが割り振られてしまい、認証処理が成功しません。

そこで、セッションを cookie に保存して同じ Pod でアクセスされるように変更します。

```yaml
# install command:
#   kubectl apply -f vue-practice.yml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: vue-practice
  annotations:
    nginx.ingress.kubernetes.io/affinity: cookie
spec:
  : <snip>
```

このように Nginx Ingress Controller の設定を annotations により設定します。[参考](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#session-affinity)

設定が完了したら kubectl apply で適用して再度認証を行ってみましょう。

セッションが cookie に保存されていること、認証が成功することが確認できれば成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206493/127b0000-c652-8cc1-ce51-6254a797bae7.png)


## GoogleのOAuth認証を設定する

Google の OAuth 認証を設定することにします。

設定を追加する方法は Facebook とほぼ同じです。

Google 用に新しい gem の [zquestz/omniauth-google-oauth2](https://github.com/zquestz/omniauth-google-oauth2) をインストールします。

次に devise の設定、routes.rb, models/user.rb, controllers/users/omniauth_controller.rb に Google OAuth 用のメソッドを追加します。

最後にログインページに Google OAuth ログインリンクを追加します。

Google OAuth のログイン時に使われる Callback URL に localhost を設定することができないため、開発環境ではログインまでは成功しません。Google による認証失敗画面が表示されるところまで確認しておきましょう。

```app/controllers/users/omniauth_controller.rb
class Users::OmniauthController < Devise::OmniauthCallbacksController
  def facebook
    find_or_create_user_and_login("Facebook")
  end

  def google_oauth2
    find_or_create_user_and_login("Google")
  end

  def failure
    redirect_to new_user_session_path
  end

  private

  def find_or_create_user_and_login(oauth_kind)
    @user = User.find_or_create_for_oauth(request.env['omniauth.auth'])

    if @user.persisted?
      set_flash_message(:notice, :success, kind: oauth_kind) if is_navigational_format?
      sign_in_and_redirect @user, event: :authentication
    else
      set_flash_message(:alert, :failure, kind: oauth_kind, reason: 'User not found and cannot register')
      lowercase_oauth_kind = oauth_kind.downcase
      session["devise.#{lowercase_oauth_kind}_data"] = request.env['omniauth.auth']
      redirect_to new_user_session_path
    end
  end
end
```

```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,
         :omniauthable, omniauth_providers: %i[facebook google_oauth2]
  : <snip>
end
```

```app/views/devise/sessions/new.html.erb
<h2>Log in</h2>

<%= link_to "Sign in with Facebook", user_facebook_omniauth_authorize_path %>
/
<%= link_to "Sign in with Google", user_google_oauth2_omniauth_authorize_path %>
```

```config/initializers/devise.rb
Devise.setup do |config|
  : <snip>
  # ==> OmniAuth
  # Add a new OmniAuth provider. Check the wiki for more information on setting
  # up on your models and hooks.
  # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
  config.omniauth :facebook, ENV["FACEBOOK_APP_ID"], ENV["FACEBOOK_APP_SECRET"]
  config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], {}
  : <snip>
end
```

本番環境へデプロイする前に secret に環境変数を設定しておきます。

```yaml:.kube/production/secret/vue-practice.enc.yml
# install command:
#   sops -p "C4A182FC6D4A914987F332D73CD1AB3C9799A3AE" -d vue-practice.enc.yml | kubectl apply -f -
apiVersion: ENC[AES256_GCM,data:OZw=,iv:/hVGfHvovY6pKJOYypC0NGSlTfKV4mPITDxIbLR5cgk=,tag:STnaspQHJdYLDA8EjaYrqg==,type:str]
data:
    GOOGLE_CLIENT_ID: ENC[AES256_GCM,data:teX2EXVu+Cqn3l1fHjzJBsns7rUG4A7xqJNbd9my/3Y+DgIUsBpmx1ph7171X8wESOy1qCfeoTBj4s2QjGa7G5pZNeMmp0fx20kQ1WXqnLbwiXWvjFyxpJbJNMPPOS6s,iv:z5q4am5bSgdszwOCExfwpK8yUujpsgbZHC5zAMpIzWs=,tag:lVzMM2KmGuxSlq3ioMzfeg==,type:str]
    GOOGLE_CLIENT_SECRET: ENC[AES256_GCM,data:BZJTnGNCV6FmGp2eW0NfzHOT5inaaE4H5Wv1ZW3mkUI=,iv:vLbzihdqeo15xDLBhMz5ejX4GM5A33+aHRdZGU8ywB4=,tag:auAYoTStNj9KfDG3bJP6+w==,type:str]
  : <snip>
kind: ENC[AES256_GCM,data:LdJlyPmy,iv:XoA100+0dgucnQPLNvahzK6sSCi8VpFIlsfkVyAHOEc=,tag:TfzAG5WxW+qRb8ScUTozfw==,type:str]
  : <snip>
```

# おまけ

OAuthの動作詳細について[OAuth 2.0 の仕組みと認証方法](https://murashun.jp/blog/20150920-01.html)が分かりやすく説明しているので参照してみて下さい。

Facebook や Google が OAuth プロバイダです。

OAuth プロトコルの内、事前に行うコンシューマ登録が Facebook, Google から事前に Webアプリケーションを登録して、ID とキーを発行した行為に当たります。(`config/initializers/devise.rb` に設定した値)

今回、Facebook や Google が持つ特定のユーザデータに対する認可は与えていませんが、OAuth による認可を行う過程で認証が行われ、default でユーザデータ(メールアドレスやユーザアカウント名等)に対するアクセスが認可されることを利用して、Webアプリケーションの認証を代替してみました。

Webアプリケーションでアクセストークンを使う場合は以下について注意してください。

アクセストークンには有効期限があり期限が切れた時にリフレッシュトークンを使うことで再発行することが出来ます。

アクセストークンの有効期限は一般的に短いため、漏洩した場合に継続して攻撃されるリスクは小さくなるようになっています。
リフレッシュトークンにも有効期限がありますがアクセストークンに比べて長いため、アクセストークンよりも漏洩しないよう注意する必要があります。