背景
Webアプリのデモをするのに必要となったため、簡単に用意しました。そのためセキュリティ面については深く考慮していません。
実装
以下の2つをsinatraで実装する。
- アカウント情報を管理するModel(ここではUserという名前を使用)
- 1で用意したModelを利用したアカウント作成・ログイン機能
メルアド・パスワードで認証し、認証後はセッションでやりとりする。そんな一般的な方法を採る。
User Modelの作成
ユーザーごとのアカウント情報を管理するModel。DBに対する単純なレコードのCRUDだけでなく、ユーザー認証用メソッドを用意する。パスワードは直接更新できないようにする。削除の時は論理削除にするべきだが今回は入れてない。パスワードの文字数制限もなし。
大まかな仕様
- DBにはMongoDBを使用する。mongoidでオブジェクトへマッピングする。
- ユーザーの認証はメルアドとパスワードで行う。
- ユーザー名とメルアドは一意性を保持する。
- パスワードはsaltを使用してハッシュ化する。ハッシュ化のストレッチはしない。
User Classの作成
$ touch models/user.rb
MongoDBを利用するためにmongoidを使用する。パスワード暗号化のためにbcryptを利用する。
require 'mongoid'
require 'bcrypt'
class User
include Mongoid::Document
end
必要なフィールドを用意する
今回はアカウント情報として名前、メルアド、ハッシュ化パスワード、saltを持たせる。最後の2つは外部から直接更新できないようにする。
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
パスワードを暗号化するメソッドを追加
パスワードの暗号化にはBCrypt::Engineを使って、不可逆に行う。ランダムなsalt値を生成してパスワードのハッシュ化を行い、DBへ保存する。
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を返すようにする。
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モデル全体
以上のように実装すると全体はこのようになる。テストは省略した。
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を使用する。
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のインスタンスを新規作成する。保存に成功したら、ユーザー情報のページヘリダイレクトさせる。
# 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
アカウント作成フォームは以下のようになる。
%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)の2つのルーティングを行う。
ログインフォームへアクセスした時、セッションが残っていればログアウトフォームへリダイレクトする。
ログイン処理では、まずセッションが残っているか調べる。この時はセッションが残っていれば、ユーザー情報のページへリダイレクトする。User Modelでユーザー認証を行い、ユーザー情報が取得できれば、セッションを作成し、ユーザー情報ページヘリダイレクトする。
#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
ログインフォームは以下のようになる。
%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)の2つのルーティングを行う。
ログアウトフォームへアクセスした時、セッションが存在しなければログインフォームへリダイレクトする。
ログアウト処理では、セッションを空にし、ログインフォームヘリダイレクトする。
#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
ログアウトフォームは以下のようになる。
%h1 Log Out
%form{:action => '/session', :method => 'post'}
%fieldset
%input{:type => "hidden", :name => '_method', :value=>'delete' }
%input{:type => "submit", :value => "Log Out"}
ユーザー情報ページ
ログイン後に移動するページ。セッションが存在すれば表示される。
#user dashboard
get '/users' do
@user = User.where(_id: session[:user_id]).first
if @user
haml :dashboard
else
redirect '/log_in'
end
end
%h1 Dashboard
%table.table
%tbody
%tr
%td Name
%td= @user.name
%tr
%td Email
%td= @user.email
app.rb全体
以上のルーティングを含めた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で十分ぽい。