Help us understand the problem. What is going on with this article?

【rails】投稿にパスワードを設定する方法、url直打ち対策済み

投稿にパスワードを設定する方法

投稿にパスワード機能を付けたのでそのアウトプット用に記事を書きました。

この記事のゴール

以下画像のようにパスワードを設定できるようにします。

post_with_password.gif

投稿機能を作る

さくっと投稿機能を作成。
scaffoldを使えば1分で作れます。

$ rails new post_with_password
$ cd post_with_password
$ rails g scaffold Post description:text
$ rails db:migrate

投稿機能.gif

bcryptを設定

ユーザーがパスワード付きの投稿をして、詳細に飛ぶまでの流れ
1.投稿時(posts/new)にパスワードを設定
2.詳細ページ(posts/show/:id)を開く際パスワードを要求(パスワード有りの場合)
3.idとパスワードが一致すれば詳細ページにリダイレクト

事前の準備としてパスワードを設定するためにbcrypt(gem)を使用します。

Gemfileに以下のgemがコメントアウトされているので#を削除。
gem 'bcrypt', '~> 3.1.7'

$ bundle install

次にモデルに1文追加します。

_form.html.erb
class Post < ApplicationRecord
  has_secure_password
end

$ rails g migration add_password_digest_to_posts password_digest:string
$ rails db:migrate

モデルにhas_secure_passwordを追加、
password_digestカラムを追加することで、
passwordとpassword_confirmationと2つの属性を利用可能になります。

※passwordとpassword_confirmationは左記2つが一致していれば、
password_digestに暗号化して格納されるようにな

1.投稿時にパスワードを設定

本題のパスワードを設定していきましょう。

まずはviewでユーザーがパスワードを設定できるようにします。

post.rb
(省略)
  <div class="field">
    <%= form.label :description %>
    <%= form.text_area :description %>
  </div>

  <div class="field">
    <%= form.label :password %>
    <%= form.password_field :password %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

次にストロングパラメータに:passwordを追加します。

posts_controller.rb
  private
    # Use callbacks to share common setup or constraints between actions.
    def set_post
      @post = Post.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def post_params
      params.require(:post).permit(:description, :password)
    end
end

これでパスワードをdbに保存できるようになりました。

2.詳細ページを開く際にパスワードを要求

次に詳細ページを表示する際にパスワードを要求し、設定したパスワードと一致すれば詳細ページにリダイレクトしましょう。

パスワードを認証させるページを作成します。

posts_with_password.html.erb
<div class="users-new-wrapper">
  <div class="container">
    <div class="row">
      <div class="col-md-offset-4 col-md-4 users-new-container">
        <h1 class="text-center text-white">password</h1>
        <%= form_for(:post, {controller: 'posts', action: "posts_with_password/#{@post}" }) do |f| %>
          <div class="form-group">
            <%= f.label :password %>
            <%= f.password_field :password, class: 'form-control' %>
          </div>
            <%= f.submit "送信", class: 'btn-block' %>
        <% end %>
      </div>
    </div>
  </div>
</div>

ルーティングも追加します。

qiita.rb
Rails.application.routes.draw do
  resources :posts
  get       'posts_with_password/:id', to: 'posts#posts_with_password'
  post      'posts_with_password/:id', to: 'posts#authenticate'
end

パスワードが一致すれば詳細ページにリダイレクト

最後に認証機能を作成します。

posts_controller.rb
(省略)
 def authenticate
    post_id = Post.find(params[:id])
    if post_id && post_id.authenticate(params[:post][:password])
      redirect_to post_path(post_id)
    else
      render 'posts_with_password'
    end
  end

  def posts_with_pass

投稿とパスワードが一致すれば詳細ページにリダイレクトされるようになりました。

昨日はこれで完成です。

ただこれだと全ての投稿にパスワードを設定する必要があるため、パスワードの設定は任意にします。

bcryptで設定したhas_secure_passwordは空の投稿が弾かれてしまうため、
空でも投稿できるように以下を追加します。

post.rb
class Post < ApplicationRecord
  has_secure_password(validations: false)
end

passwaord_digestにパスワードが入っている時と、入っていない時で
詳細ページのリンク先を変更します。

index.hetml.erb
(省略)
<% @posts.each do |post| %>
  <tr>
    <td><%= post.description %></td>
    <td><%= link_to 'Show', post_path(post) %></td>
    <td><%= link_to 'Edit', edit_post_path(post) %></td>
    <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
  </tr>
<% end %>

しかしこれだとせっかくパスワードを設定してもindexからDescriptionが見れてしまいます。

スクリーンショット 2020-07-13 14.02.37.jpeg

password_digestの有無でDescriptionの表示を変更します。

index.hetml.erb
<% @posts.each do |post| %>
  <tr>
    <% if post.password_digest.nil? %>
      <td><%= post.description %></td>
    <% else %>
      <td>secret</td>
    <% end %>
    <td><%= link_to 'Show', post_path(post) %></td>
    <td><%= link_to 'Edit', edit_post_path(post) %></td>
    <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
  </tr>
<% end %>

password_digestが空の場合は通常通り、空ではない場合は"secret"と表示させるように設定しました。

アクセス制限をかける

最後にアクセス制限をかけて完成です。

showをクリックした際にパスワードがなければ、詳細ページに、パスワードがあればパスワード入力フォームに飛ぶようにに設定します。

またurlを直接入力するとパスワードを突破できてしまう問題も解決していきます。

仕組みは以下です。
1.posts/show/:idのパスワードの有無を確認
2.パスワードがなければそのままposts/show/:idを表示
3.パスワードがある場合はurlにpassword_digestの値が含まれていれば、posts/show/:idにリダイレクト
含まれていなければパスワード入力フォームへレダイレクト

3.は解説は解説が必要ですね。
パスワードがない場合は直接urlを打ち込んで詳細ページが表示されて、問題ありません。
パスワード付きの投稿に直接urlを打ち込むと詳細ページが表示されてしまうのは問題です。

とりあえず「パスワード付きの投稿はアクセス制限でパスワード入力フォームにリダイレクトさせよう」
と短絡的に考えるとハマります。(ハマりました)

上記のやり方だと一生詳細ページを開くことはできません。
なぜならパスワード入力フォームで認証をしようがしまいが詳細ページにはパスワードがついているからです。

私が考えたのは、パスワードの認証に成功したときredirect_toに一緒にパラメータを送信し
urlにそのパラメータを含む場合は詳細ページを表示させる方法です。

redirect_toにはパラメータも一緒に送ることができます。
今回はpassword_digestを送ることにします。

理由は2つ。
1.取り出しやすいから
2.安全性が高いから

毎回ランダムの英数字をパラメータに入れて送ろうとも考えたのですが、取り出すのがわからn面倒で諦めました。

また安全性が高いというのはurlにpassword_digestが含まれていれば突破することができるのですが、
値は60桁の大文字、小文字、数字、記号なのでかなり難しいでしょう。

長々と説明して恐縮ですが次からコードを書いていきます。

まずはredirect_toにパラメータを設定します。

posts_controller.rb
def authenticate
    post_id = Post.find(params[:id])
    if post_id && post_id.authenticate(params[:post][:password])
ここ-> redirect_to post_path(post_id, parameter: post_id.password_digest)
    else
      render 'index'
    end
  end

これでパスワード認証後はパラメータが付与され、urlが変わります。

次にパスワードの有無でアクセス制限をかけます。

posts_controller.rb
before_action :with_password, only: :show

(省略)
private
  def with_password
    url = request.url.gsub!(/%21|%22|%23|%24|%24|%25|%26|%27|%28|%29|%2A|%2B|%2C|%2F|%3A|%3B|%3C|%3D|%3E|%3F|%40|%5B|%5D|%5E|%60|%7B|%7C|%7D|%7E|/,
    "%21" => "!", "%22" => '"', "%23" => "#", "%24" => "$", "%25" => "%", "%26" => "&", "%27" => "'", "%28" => "(", "%29" => ")",
    "%2A" => "*", "%2B" => "+", "%2C" => ",", "%2F" => "/", "%3A" => ":", "%3B" => ";", "%3C" => "<", "%3D" => "=", "%3E" => ">", "%3F" => "?", "%40" => "@",
    "%5B" => "[", "%5D" => "]", "%5E" => "^", "%60" => "`", "%7B" => "{", "%7C" => "|", "%7D" => "}", "%7E" => "~")
    @post = Post.find(params[:id])
    if !@post.password_digest.nil? && !url.try(:include?, @post.password_digest)
      redirect_to "/posts_with_password/#{@post.id}", danger: 'パスワードを入力してください'
    end
  end

上から順に説明していきます。
url = request.url
まず上記のコードでurlを取得します。

コードを書いているときにここでハマりました。
urlをそのまま取得しても認証されませんでした。

byebugを使いurlとpassword_digestの中身を比較してみると$など一部の文字が
違っていました。
調べていくとurlには使えない文字列があるようでこれをエンコードする必要が出てきました。

一発で変換する方法を探すことができなかったためgsubを使い無理やり変更しました。(もしこれ以外に良い書き方があったら教えてください。。)

以下の情報を参考にしました。
https://www.seil.jp/doc/index.html#tool/url-encode.html

これでurlの取得は完了です。

最後の仕上げです。
以下のコードの説明をして完成です。

if !@post.password_digest.nil? && !url.try(:include?, @post.password_digest)

パスワード有かつurlにpassword_digestが含まれていない状態で詳細ページを開こうとした場合、
パスワード入力フォームにリダイレクトさせます。

tryを使わないとパスワード無しの場合エラーが起きてしまうために入れています。

まとめ

以上で完成です。

ログイン以外にもbcrypt使ってあげてくださいね。

ポートフォリオなどの参考になれば嬉しいです。

hiruhiru
プログラミング初学者です。 アウトプット用に投稿してます。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away