1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

コーディング未経験のPO/PdMのためのRails on Dockerハンズオン vol.7 - Secure password -

Last updated at Posted at 2020-02-18

はじめに

第7回目となる今回は、モデルにセキュアなパスワードをもたらしてくれるhas_secure_passwordメソッドの使い方を紹介していきます。
パスワードは他人に知られてはまずいものです。万が一、データを抜かれたり画面に表示されちゃったりしても一目でわからないようになっていることが望ましいですね。そんなことを実現してくれるメソッドがhas_secure_passwordです。

前回のソースコード

前回のソースコードはこちらに格納してます。今回のだけやりたい場合はこちらからダウンロードしてください。

has_secure_password

Railsでは、モデルにセキュアなパスワード属性を実装するメソッドとして、has_secure_passwordが用意されています。モデルに対してhas_secure_passwordを適用することでモデルは以下の恩恵を受けることができます。

  • 仮想属性としてpasswordpassword_confirmationを利用可能
  • passwordpassword_confirmationの同一性について勝手に検証してくれる
  • DBにはハッシュ化したpassword_digestを保存するようになる
  • ハッシュ値でパスワード検証をするauthenticateメソッドを利用可能

ハッシュ化についてちょっとお話ししておきます。ハッシュ化はある文字列を不可逆な別の文字列に変換してくれます。『不可逆』とは元に戻せないという意味です。
『暗号化』の場合は『復号化』することで元の文字列に変換しなおせるんです。これは『可逆』といいますね。

では、早速モデルにhas_secure_passwordメソッドを適用していきます。

has_secure_passwordではハッシュ化を行うためにbcrypt gemを利用します。まずは、bcryptのインストールからやっていきます。Gemfileの中身をみるとわかりますが、bcryptは最初からGemfileの中に書かれておりコメントアウトされているだけです。なのでコメントアウトをとってdocker-compose buildをするだけでOKです。

Gemfile
  ...
  # Use Active Model has_secure_password
- # gem 'bcrypt', '~> 3.1.7'
+ gem 'bcrypt', '~> 3.1.7'
  ...
$ docker-compose build

ビルド後はコンテナを起動してコンテナ内に入っておきます。

$ docker-compose up -d
$ docker-compose exec web ash

次に、has_secure_passwordメソッドを使うようになるとpasswordをハッシュ化した値をpassword_digestカラムにDB保存するようになります。マイグレーションファイルを作成しDBにpassword_digestカラムを追加しておきます。

# rails g migration add_password_digest_column_to_user password_digest:string
Running via Spring preloader in process 251
      invoke  active_record
      create    db/migrate/20200130075100_add_password_digest_column_to_user.rb

# rails db:migrate
== 20200130075100 AddPasswordDigestColumnToUser: migrating ====================
-- add_column(:users, :password_digest, :string)
   -> 0.0522s
== 20200130075100 AddPasswordDigestColumnToUser: migrated (0.0533s) ===========

そして、Userモデルにhas_secure_passwordメソッドを適用します。

app/models/user.rb
  class User < ApplicationRecord
    before_save { self.email = email.downcase }
+   has_secure_password

    validates :name,
      presence: true,
      length: { maximum: 50 }

    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    validates :email,
      presence: true,
      length: { maximum: 255 },
      format: { with: VALID_EMAIL_REGEX },
      uniqueness: { case_sensitive: false }
  end

これで完了です!簡単ですね!
では恩恵がちゃんと受けられているか確認してみましょう。

passwordpassword_confirmation

まずはpassword属性とpassword_confirmation属性の二つの仮想属性が利用できることを確認してみます。

# rails c
> user = User.new(name: "hanako", email: "hanako@sample.com", password: "password", password_confirmation: "password")
=> #<User:0x00005610de139120
 id: nil,
 name: "hanako",
 email: "hanako@sample.com",
 created_at: nil,
 updated_at: nil,
 password_digest: [FILTERED]>
> user.password
=> "password"
> user.password_confirmation
=> "password"
> user.password_digest
=> "$2a$12$KWcqXmTL6sVj0ch127o0.eemXFmAgPRqU98D5CLqXNJ8.5F.10AiS"

DBカラム的にpassword_digestしかないはずですが、passwordpassword_confirmationが使えていることがわかります。さらにその結果がpassword_digestとして利用可能なのもわかりますね。

passwordpassword_confirmationの一致性確認

ここで一度valid?メソッドを使ってみましょう。これは今現在のモデルでvalidationを突破できるのかを確認できるメソッドです。先ほどまではsaveでvalidationできるかを確認していましたが、実はvalidationを調べるためだけであればこれでOK。

> user.valid?
  User Exists? (6.5ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
=> true

今はpasswordpassword_confirmationがマッチしているのでvalidationは通っているようです。では、password_confirmationを別の文字列に変更した場合どうでしょうか?

> user.password_confirmation = "password1"
=> "password1"
> user.valid?
  User Exists? (2.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
=> false
> user.errors.full_messages
=> ["Password confirmationとPasswordの入力が一致しません"]

validationでエラーになっていることがわかりましたね...あ、日本語化していない。
日本語化しましょう!

config/locales/ja.yml
  ja:
    activerecord:
      attributes:
        user:
          name: "お名前"
          email: "メールアドレス"
+         password: "パスワード"
+         password_confirmation: "確認用パスワード"
      errors:
  ...
> reload!
Reloading...
=> true
> user.valid?
  User Exists? (2.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
=> false
> user.errors.full_messages
=> ["確認用パスワードとパスワードの入力が一致しません"]

passwordpassword_confirmationが一致していない場合はエラーが起きていますね。
ただ、password_confirmationのようなものは必要なのか?という論争もあると思います。
登録フォームにおけるパスワード確認用の入力欄は必要か | UX MILK
例えばこちらの記事では、確認用パスワードを用意するのではなく、パスワードの欄のマスクを外せるようにした方がコンバージョンが上がると述べられていたりします。
has_secure_passwordでは、password_confirmationnilの場合、passwordpassword_confirmationの一致を確認しません。

> user.password_confirmation = nil
=> nil
> user.valid?
  User Exists? (3.1ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
=> true

なので確認用パスワードを使う使わないによらず、has_secure_passwordメソッドは強力なのです。

DBではハッシュ化されたpassword_digestが使われる

最初にもお話しした通り、ハッシュ化は不可逆なデータ変換です。
has_secure_passwordではpassword_digestにハッシュ化されたpasswordを保存します。それを確認してみましょう!

実際にUserを作成してみましょう。

> user.save
  (0.4ms)  BEGIN
  User Exists? (2.1ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
  User Create (8.5ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["name", "hanako"], ["email", "hanako@sample.com"], ["created_at", "2020-03-09 14:37:38.358397"], ["updated_at", "2020-03-09 14:37:38.358397"], ["password_digest", "$2a$12$KWcqXmTL6sVj0ch127o0.eemXFmAgPRqU98D5CLqXNJ8.5F.10AiS"]]
   (2.3ms)  COMMIT
=> true
> user = User.find(1)
  User Load (1.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<User:0x00005610e0b21ed8
 id: 1,
 name: "hanako",
 email: "hanako@sample.com",
 created_at: Mon, 09 Mar 2020 14:37:38 JST +09:00,
 updated_at: Mon, 09 Mar 2020 14:37:38 JST +09:00,
 password_digest: [FILTERED]>
> user.password
=> nil
> user.password_digest
=> "$2a$12$KWcqXmTL6sVj0ch127o0.eemXFmAgPRqU98D5CLqXNJ8.5F.10AiS"

user.password_digestをみてもなんのことやらわかりませんね。ちょっと安心です。

パスワード認証するauthenticateメソッド

さて、ハッシュ化されてセキュアになったのはいいのですが、不可逆なのでこのままではパスワードで認証ができません。has_secure_passwordではハッシュ化されたパスワードの認証をするためのauthenticateメソッドが用意されています。これは、password_digestの値と入力したpasswordを同じハッシュ関数でハッシュ化した値の一致をチェックしてくれるものです。

> User.find_by(email: "hanako@sample.com").authenticate("a")
  User Load (3.9ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
=> false
> User.find_by(email: "hanako@sample.com").authenticate("password")
  User Load (2.5ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "hanako@sample.com"], ["LIMIT", 1]]
=> #<User:0x00005610de34c250
 id: 1,
 name: "hanako",
 email: "hanako@sample.com",
 created_at: Mon, 09 Mar 2020 14:37:38 JST +09:00,
 updated_at: Mon, 09 Mar 2020 14:37:38 JST +09:00,
 password_digest: [FILTERED]>

このようにfind_byと組み合わせて使えばメールアドレスとパスワードの2key認証を実装できます。authenticateは不一致の場合はfalseを、一致の場合はモデルオブジェクトを返却します。

passwordにvalidationを設ける

仮想属性であるpasswordに対してもvalidationをつけることができます。
一方で、has_secure_passwordで作られたpasswordにはデフォルトでpresenceのvalidationが設定されています。これに加えて6文字以上でないといけない文字数制限をつけてみましょう。
デフォルトでpresenceがついてはいるのですが、入力がない場合にpresencelengthの両方に引っかかってしまうのでpresenceについては適用しない方がわかりやすいでしょう。
has_secure_passwordvalidations: falseオプションをつけてデフォルトのpresence validationを向こうにした後に、他の属性と同様にlengthのvalidationをつけてみましょう。

app/models/user.rb
  class User < ApplicationRecord
    before_save { self.email = email.downcase }
-   has_secure_password
+   has_secure_password validations: false

    validates :name,
      presence: true,
      length: { maximum: 50 }

    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    validates :email,
      presence: true,
      length: { maximum: 255 },
      format: { with: VALID_EMAIL_REGEX },
      uniqueness: { case_sensitive: false }
+
+   validates :password,
+     length: { minimum: 6 }
end

passwordのvalidationについて試してみましょう。

> reload!
Reloading...
=> true
> user = User.new(name: "john", email: "john@sample.com")
=> #<User:0x00005610deba8110
 id: nil,
 name: "john",
 email: "john@sample.com",
 created_at: nil,
 updated_at: nil,
 password_digest: nil>
> user.valid?
  User Exists? (4.1ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "john@sample.com"], ["LIMIT", 1]]
=> false
> user.errors.full_messages
=> ["パスワードは6文字以上で入力してください"]

passwordnilだとバリデーションにひっかかります。

> user.password = "a" * 5
=> "aaaaa"
> user.valid?
  User Exists? (4.6ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "john@sample.com"], ["LIMIT", 1]]
=> false
> user.errors.full_messages
=> ["パスワードは6文字以上で入力してください"]

passwordが5文字以下でも同様にバリデーションに引っかかります。

> user.password = "a" * 6
=> "aaaaaa"
> user.valid?
  User Exists? (3.2ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "john@sample.com"], ["LIMIT", 1]]
=> true

ということで、passwordlength validationが正しく挙動していることがわかりました。

Userを作成

最後にこのバリデーションの中でちゃんとUserを作成できることを確認しておきましょう!

> User.create(name: "John Smith", email: "john@sample.com", password: "password")  
   (0.8ms)  BEGIN
  User Exists? (4.0ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "john@sample.com"], ["LIMIT", 1]]
  User Create (1.1ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["name", "John Smith"], ["email", "john@sample.com"], ["created_at", "2020-03-09 14:44:33.407908"], ["updated_at", "2020-03-09 14:44:33.407908"], ["password_digest", "$2a$12$FTRT7cRJ4c0k.H68k49yOOurmwSbGaRHwzOXLuP2LYyXD30sg6FDS"]]
   (1.9ms)  COMMIT
=> #<User:0x00005610e06db9a0
 id: 2,
 name: "John Smith",
 email: "john@sample.com",
 created_at: Mon, 09 Mar 2020 14:44:33 JST +09:00,
 updated_at: Mon, 09 Mar 2020 14:44:33 JST +09:00,
 password_digest: [FILTERED]>

validationにひっかからないUserであればちゃんと作成できることが確認できましたね!

ユーザー情報を確認するViewを作ってみる

ここまででUserモデルがほぼ完成しました。この情報をUIで見れるようにViewを作ってみましょう!

Scaffoldを思い出してください。ユーザーの詳細情報を見るためのページは/users/:idのURLにアクセスして閲覧することができ、showアクションにルーティングされていました。

今回もそれにそってユーザーのページを作っていきます。

ユーザー詳細ページを作成する

ユーザー詳細ページは、/users/:idのパスにアクセスした時に、そのidのユーザーの詳細情報が表示されるページにしたいと思います。
この通りになるように、ファイルの編集や作成を行っていきます。いままでrails g ~コマンドでファイルを作成してきましたが、Railsの規則に則っていれば普通にファイルを作成しても動きます。

Routing

まずは、/users/:idのルーティングを生成します。以前、Scaffoldの時にresourcesメソッドを使ってUsersコントローラーに対するルーティングを定義しました。/users/:idはその時のshowアクションへのルーティングと同じです。
resourcesメソッドはonlyオプションをつけることで特定のアクションへのルーティングのみを定義してくれます。今回はこのオプションを使ってルーティングを定義します。

config/routes.rb
  Rails.application.routes.draw do
    root 'static_pages#home'
+
+   resources :users, only: [:show]
end

これでルーティングの設定は完了です。
rails consoleを起動していると思うのでshow-routesコマンドでルートを確認してみましょう。

> show-routes
                               Prefix Verb   URI Pattern                                                                              Controller#Action
                                 root GET    /                                                                                        static_pages#home
                                 user GET    /users/:id(.:format)                                                                     users#show
        rails_mandrill_inbound_emails POST   /rails/action_mailbox/mandrill/inbound_emails(.:format)                                  action_mailbox/ingresses/mandrill/inbound_emails#create
        rails_postmark_inbound_emails POST   /rails/action_mailbox/postmark/inbound_emails(.:format)                                  action_mailbox/ingresses/postmark/inbound_emails#create
           rails_relay_inbound_emails POST   /rails/action_mailbox/relay/inbound_emails(.:format)                                     action_mailbox/ingresses/relay/inbound_emails#create
        rails_sendgrid_inbound_emails POST   /rails/action_mailbox/sendgrid/inbound_emails(.:format)                                  action_mailbox/ingresses/sendgrid/inbound_emails#create
         rails_mailgun_inbound_emails POST   /rails/action_mailbox/mailgun/inbound_emails/mime(.:format)                              action_mailbox/ingresses/mailgun/inbound_emails#create
       rails_conductor_inbound_emails GET    /rails/conductor/action_mailbox/inbound_emails(.:format)                                 rails/conductor/action_mailbox/inbound_emails#index
                                      POST   /rails/conductor/action_mailbox/inbound_emails(.:format)                                 rails/conductor/action_mailbox/inbound_emails#create
    new_rails_conductor_inbound_email GET    /rails/conductor/action_mailbox/inbound_emails/new(.:format)                             rails/conductor/action_mailbox/inbound_emails#new
   edit_rails_conductor_inbound_email GET    /rails/conductor/action_mailbox/inbound_emails/:id/edit(.:format)                        rails/conductor/action_mailbox/inbound_emails#edit
        rails_conductor_inbound_email GET    /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#show
                                      PATCH  /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#update
                                      PUT    /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#update
                                      DELETE /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#destroy
rails_conductor_inbound_email_reroute POST   /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format)                      rails/conductor/action_mailbox/reroutes#create
                   rails_service_blob GET    /rails/active_storage/blobs/:signed_id/*filename(.:format)                               active_storage/blobs#show
            rails_blob_representation GET    /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations#show
                   rails_disk_service GET    /rails/active_storage/disk/:encoded_key/*filename(.:format)                              active_storage/disk#show
            update_rails_disk_service PUT    /rails/active_storage/disk/:encoded_token(.:format)                                      active_storage/disk#update
                 rails_direct_uploads POST   /rails/active_storage/direct_uploads(.:format)                                           active_storage/direct_uploads#create

色々と出てくると思いますが、とりあえず重要なのは最初の2つのルートです。今、config/routes.rbで定義しているルーティングが表示されていることがわかると思います。

同様のことをrails routesコマンドでも確認できます。

> quit
# rails routes
                               Prefix Verb   URI Pattern                                                                              Controller#Action
                                 root GET    /                                                                                        static_pages#home
                                 user GET    /users/:id(.:format)                                                                     users#show
        rails_mandrill_inbound_emails POST   /rails/action_mailbox/mandrill/inbound_emails(.:format)                                  action_mailbox/ingresses/mandrill/inbound_emails#create
        rails_postmark_inbound_emails POST   /rails/action_mailbox/postmark/inbound_emails(.:format)                                  action_mailbox/ingresses/postmark/inbound_emails#create
           rails_relay_inbound_emails POST   /rails/action_mailbox/relay/inbound_emails(.:format)                                     action_mailbox/ingresses/relay/inbound_emails#create
        rails_sendgrid_inbound_emails POST   /rails/action_mailbox/sendgrid/inbound_emails(.:format)                                  action_mailbox/ingresses/sendgrid/inbound_emails#create
         rails_mailgun_inbound_emails POST   /rails/action_mailbox/mailgun/inbound_emails/mime(.:format)                              action_mailbox/ingresses/mailgun/inbound_emails#create
       rails_conductor_inbound_emails GET    /rails/conductor/action_mailbox/inbound_emails(.:format)                                 rails/conductor/action_mailbox/inbound_emails#index
                                      POST   /rails/conductor/action_mailbox/inbound_emails(.:format)                                 rails/conductor/action_mailbox/inbound_emails#create
    new_rails_conductor_inbound_email GET    /rails/conductor/action_mailbox/inbound_emails/new(.:format)                             rails/conductor/action_mailbox/inbound_emails#new
   edit_rails_conductor_inbound_email GET    /rails/conductor/action_mailbox/inbound_emails/:id/edit(.:format)                        rails/conductor/action_mailbox/inbound_emails#edit
        rails_conductor_inbound_email GET    /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#show
                                      PATCH  /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#update
                                      PUT    /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#update
                                      DELETE /rails/conductor/action_mailbox/inbound_emails/:id(.:format)                             rails/conductor/action_mailbox/inbound_emails#destroy
rails_conductor_inbound_email_reroute POST   /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format)                      rails/conductor/action_mailbox/reroutes#create
                   rails_service_blob GET    /rails/active_storage/blobs/:signed_id/*filename(.:format)                               active_storage/blobs#show
            rails_blob_representation GET    /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations#show
                   rails_disk_service GET    /rails/active_storage/disk/:encoded_key/*filename(.:format)                              active_storage/disk#show
            update_rails_disk_service PUT    /rails/active_storage/disk/:encoded_token(.:format)                                      active_storage/disk#update
                 rails_direct_uploads POST   /rails/active_storage/direct_uploads(.:format)                                           active_storage/direct_uploads#create

Controller

次にUsersコントローラーを作成していきます。コントローラーは複数形の名称にするのがルールです。

# touch app/controllers/users_controller.rb
app/controllers/users_controller.rb
class UsersController < ApplicationController
  def show
    @user = User.find(params[:id])
  end
end

usersコントローラにshowアクションがある、というところまでは今までの内容からわかりますね。
showアクションの中身をみてみます。

params[:id]/users/:idのパスパラメータの:idを取得しています。例えば/users/1にアクセスした場合はparams[:id]=1だし、users/2にアクセスした場合はparams[:id]=2です。
User.find()はModel CRUDの回で話した通り、idでUserのモデルオブジェクトを取得するメソッドなので、パスのidのユーザーがこれで取得できるわけです。
あとはViewに引き渡すためにこれをインスタンス変数@userに代入しています。

ではこれを受け取るViewを作っていきます。

View

まず、Viewファイルを格納するディレクトリを作成して、その中にshow.html.erbファイルを作ります。
UsersコントローラのためのViewなのでapp/views/users/ディレクトリ内にファイルを作成していきます。

# mkdir app/views/users
# touch app/views/users/show.html.erb

最初は情報が表示されていればOKとしましょう。特にUIは拘らない。

app/views/users/show.html.erb
<div class="container my-5">
  <%= @user.name %>
  <br>
  <%= @user.email %>
</div>

nameemailを表示。

http://localhost:3000/users/2にアクセスしてみましょう。
image.png
こんなページが表示されていれば成功です!
idは実際にDBに格納されているデータ次第ですので、Rails consoleでUser.allを打つなどして存在するUserのidを確認してみてくださいね。)

後片付け

次回に向けてデータを消しておきます。

# exit
$ docker-compose down
$ docker-compose run --rm web rails db:migrate:reset

DBコンテナが立ち上がった状態だと思うのでdownさせます。

$ docker-compose down

まとめ

今回は、has_secure_passwordメソッドを使ってセキュアなパスワードが利用できるようになりました。
コード的にいえばたった1行has_secure_passwordを付け加えるだけでパスワードをハッシュ化してセキュアに扱うことができるようになる。これって強力なメソッドですよね。

さらにユーザーの情報を表示するページまで作ることができました。
次回はSign up(ユーザー登録)ページを作っていきましょう!

では、次回も乞うご期待!ここまでお読みいただきありがとうございました!

Next: コーディング未経験のPO/PdMのためのRails on Dockerハンズオン vol.8 - Sign up - - Qiita

本日のソースコード

Reference

Other Hands-on Links

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?