sinatraへ簡易アカウント管理機能を追加する

# 背景
Webアプリのデモをするのに必要となったため、簡単に用意しました。そのためセキュリティ面については深く考慮していません。
# 実装
以下の2つをsinatraで実装する。

1. アカウント情報を管理するModel(ここではUserという名前を使用)
2. 1で用意したModelを利用したアカウント作成・ログイン機能

メルアド・パスワードで認証し、認証後はセッションでやりとりする。そんな一般的な方法を採る。

## User Modelの作成
ユーザーごとのアカウント情報を管理するModel。DBに対する単純なレコードのCRUDだけでなく、ユーザー認証用メソッドを用意する。パスワードは直接更新できないようにする。削除の時は論理削除にするべきだが今回は入れてない。パスワードの文字数制限もなし。

### 大まかな仕様
- DBにはMongoDBを使用する。mongoidでオブジェクトへマッピングする。
- ユーザーの認証はメルアドとパスワードで行う。
- ユーザー名とメルアドは一意性を保持する。
- パスワードはsaltを使用してハッシュ化する。ハッシュ化のストレッチはしない。

### User Classの作成
```
$ touch models/user.rb
```
MongoDBを利用するためにmongoidを使用する。パスワード暗号化のためにbcryptを利用する。

```ruby:models/user.rb
require 'mongoid'
require 'bcrypt'

class User
  include Mongoid::Document

end
```
### 必要なフィールドを用意する
今回はアカウント情報として名前、メルアド、ハッシュ化パスワード、saltを持たせる。最後の２つは外部から直接更新できないようにする。

```ruby:models/user.rb
field :name
field :email
field :password_hash
field :password_salt

attr_readonly :password_hash, :password_salt
```
### フィールドのバリデーションを追加
すべての項目を必須とする。名前とメルアドは一意性を持たせる。

```ruby:models/user.rb
validates :name, presence: true
validates :name, uniqueness: true
validates :email, uniqueness: true
validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i, on: :create }
validates :email, presence: true
validates :password_hash, confirmation: true
validates :password_hash, presence: true
validates :password_salt, presence: true
```
### パスワードを暗号化するメソッドを追加
パスワードの暗号化にはBCrypt::Engineを使って、不可逆に行う。ランダムなsalt値を生成してパスワードのハッシュ化を行い、DBへ保存する。

```ruby:models/user.rb
def encrypt_password(password)
  if password.present?
    self.password_salt = BCrypt::Engine.generate_salt
    self.password_hash = BCrypt::Engine.hash_secret(password, password_salt)
  end 
end 
```

### ユーザー認証のメソッドを追加
DBに保存されたハッシュ化パスワードと入力されたパスワードから生成したハッシュ値が一致するか調べ、一致する場合のみuserを返すようにする。

```ruby:models/user.rb
def self.authenticate(email, password)
  user = self.where(email: email).first
  if user && user.password_hash == BCrypt::Engine.hash_secret(password, user.password_salt)
    user
  else
    nil 
  end 
end
```
### Userモデル全体
以上のように実装すると全体はこのようになる。テストは省略した。

```ruby:models/user.rb
require 'mongoid'
require 'bcrypt'

class User
  include Mongoid::Document

  field :name
  field :email
  field :password_hash
  field :password_salt

  attr_readonly :password_hash, :password_salt

  validates :name, presence: true
  validates :name, uniqueness: true
  validates :email, uniqueness: true
  validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i, on: :create }
  validates :email, presence: true
  validates :password_hash, confirmation: true
  validates :password_hash, presence: true
  validates :password_salt, presence: true

  def self.authenticate(email, password)
    user = self.where(email: email).first
    if user && user.password_hash == BCrypt::Engine.hash_secret(password, user.password_salt)
      user
    else
      nil 
    end 
  end 

  def encrypt_password(password)
    if password.present?
      self.password_salt = BCrypt::Engine.generate_salt
      self.password_hash = BCrypt::Engine.hash_secret(password, password_salt)
    end 
  end 
end 
```

## ユーザー登録・ログイン機能を実装する
ログイン・ログアウト・ユーザー登録が行えるコントローラを作成する。それぞれに対して、その処理とフォームのURLを用意する。アカウント削除はつけてない(なんとなく)。
### app.rbの作成
```
$ touch app.rb
```
上で作成したUserモデルを読み込む。セッションの設定を行う。Viewにはhamlを使用する。

```ruby:app.rb
require 'sinatra/base'
require 'haml'

require_relative 'models/user'

class Server < Sinatra::Base

  enable :sessions
  set :session_secret, "My session secret"

end
```

### ユーザー登録
アカウント作成フォーム(/sign_up)とアカウント作成処理(/user)のルーティングを行う。
セッションが残っている状態で、アカウント作成ページヘ言った場合、ログアウトのフォームへ飛ばす。
アカウント作成処理では、パスワードが確認用パスワードと一致しているか確認した上で、作成したUser Modelのインスタンスを新規作成する。保存に成功したら、ユーザー情報のページヘリダイレクトさせる。

```ruby:app.rb
# signup form
get '/sign_up' do
  session[:user_id] ||= nil 
  if session[:user_id]
    redirect '/log_out' #logout form
  end 

  haml :sign_up
end 

#signup action
post '/users' do
  if params[:password] != params[:confirm_password]
    redirect "/sign_up"
  end

  user = User.new(email: params[:email], name: params[:name])
  user.encrypt_password(params[:password])
  if user.save!
    session[:user_id] = user._id
    redirect "/users" #user dashboard page
  else
    redirect "/sign_up"
  end
end
```

アカウント作成フォームは以下のようになる。

```haml:sign_in.haml
%h1 Sign Up
%form{:action => '/users', :method => 'post'}
  %fieldset
    %p  
      %label{:for => "name"} Name:
      %input{:name => "name", :type => "text", :value => ""}
    %p  
      %label{:for => "email"} Email:
      %input{:name => "email", :type => "text", :value => ""}
    %p  
      %label{:for => "password"} Password:
      %input{:name => "password", :type => "password", :value => ""}
    %p  
      %label{:for => "confirm_password"} Confirm Password:
      %input{:name =>  "confirm_password", :type => "password", :value => ""}
    %p  
      %input{:type => "submit", :value => "Sign Up"}
```

### ログイン
ログインを行うためのフォーム(/log_in)とフォームから呼び出されるログイン処理(/session)の２つのルーティングを行う。
ログインフォームへアクセスした時、セッションが残っていればログアウトフォームへリダイレクトする。
ログイン処理では、まずセッションが残っているか調べる。この時はセッションが残っていれば、ユーザー情報のページへリダイレクトする。User Modelでユーザー認証を行い、ユーザー情報が取得できれば、セッションを作成し、ユーザー情報ページヘリダイレクトする。

```ruby:app.rb
#login form
get '/log_in' do
  if session[:user_id]
    redirect '/log_out'
  end 

  haml :log_in
end
 
#login action
post '/session' do
  if session[:user_id]
    redirect "/users"
  end

  user = User.authenticate(params[:email], params[:password])
  if user
    session[:user_id] = user._id
    redirect '/users'
  else
    redirect "/log_in"
  end
end
```
ログインフォームは以下のようになる。

```haml:log_in.haml
%h1 Log In
%form{:action => '/session', :method => 'post'}
  %fieldset
    %p  
      %label{:for => "email"} Email:
      %input{:name => "email", :type => "text", :value => ""}
    %p  
      %label{:for => "password"} Password:
      %input{:name => "password", :type => "password", :value => ""}
    %p  
      %input{:type => "submit", :value => "Log In"}
```


### ログアウト
ログアウトを行うフォーム(/log_out)とフォームから呼び出されるログアウト処理(/session)の２つのルーティングを行う。
ログアウトフォームへアクセスした時、セッションが存在しなければログインフォームへリダイレクトする。
ログアウト処理では、セッションを空にし、ログインフォームヘリダイレクトする。

```ruby:app.rb
#logout form
get '/log_out' do
  unless session[:user_id]
    redirect '/log_in'
  end 

  haml :log_out
end

#logout action
delete '/session' do
  session[:user_id] = nil
  redirect '/log_in'
end
```
ログアウトフォームは以下のようになる。

```haml:log_out.haml
%h1 Log Out 
%form{:action => '/session', :method => 'post'}
  %fieldset
    %input{:type => "hidden", :name => '_method', :value=>'delete' }
    %input{:type => "submit", :value => "Log Out"}
```

### ユーザー情報ページ
ログイン後に移動するページ。セッションが存在すれば表示される。

```ruby:app.rb
#user dashboard
get '/users' do
  @user = User.where(_id: session[:user_id]).first
  if @user
    haml :dashboard
  else
    redirect '/log_in'
  end
end
```
```haml:dashboard.haml
%h1 Dashboard
%table.table
  %tbody
    %tr 
      %td Name
      %td= @user.name
    %tr 
      %td Email
      %td= @user.email
```

### app.rb全体
以上のルーティングを含めたapp.rb全体がこちら。

```ruby:app.rb
require 'sinatra/base'
require 'haml'

require_relative 'model/user'

class Server < Sinatra::Base

  enable :sessions
  set :session_secret, "My session secret"

  # login form
  get '/log_in' do
    if session[:user_id]
      redirect '/log_out'
    end 

    haml :log_in
  end
 
  #logout form
  get '/log_out' do
    unless session[:user_id]
      redirect '/log_in'
    end 

    haml :log_out
  end

  # signup form
  get '/sign_up' do
    session[:user_id] ||= nil 
    if session[:user_id]
      redirect '/log_out'
    end 

    haml :sign_up
  end 

  #logout action
  delete '/session' do
    session[:user_id] = nil
    redirect '/log_in'
  end

  #login action
  post '/session' do
    if session[:user_id]
      redirect "/users"
    end

    user = User.authenticate(params[:email], params[:password])
    if user
      session[:user_id] = user._id
      redirect '/users'
    else
      redirect "/log_in"
    end
  end

  #signup action
  post '/users' do
    if params[:password] != params[:confirm_password]
      redirect "/sign_up"
    end

    user = User.new(email: params[:email], name: params[:name])
    user.encrypt_password(params[:password])
    if user.save!
      session[:user_id] = user._id
      redirect "/users"
    else
      redirect "/sign_up"
    end
  end

  #user dashboard
  get '/users' do
    @user = User.where(_id: session[:user_id]).first
    if @user
      haml :dashboard
    else
      redirect '/log_in'
    end
  end

end
```

# まとめ
コントローラ部分はrackのsessionを使えばとりあえず簡単に実装できる。モデルはDBへmongodbを使用し、暗号化にライブラリ使っとけばサクッと用意できる。いろいろ端折っているけど、この程度であればsinatraで十分ぽい。