Edited at

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

More than 5 years have passed since last update.


背景

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を利用する。


models/user.rb

require 'mongoid'

require 'bcrypt'

class User
include Mongoid::Document

end



必要なフィールドを用意する

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


models/user.rb

field :name

field :email
field :password_hash
field :password_salt

attr_readonly :password_hash, :password_salt



フィールドのバリデーションを追加

すべての項目を必須とする。名前とメルアドは一意性を持たせる。


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へ保存する。


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を返すようにする。


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モデル全体

以上のように実装すると全体はこのようになる。テストは省略した。


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を使用する。


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のインスタンスを新規作成する。保存に成功したら、ユーザー情報のページヘリダイレクトさせる。


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


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


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)の2つのルーティングを行う。

ログインフォームへアクセスした時、セッションが残っていればログアウトフォームへリダイレクトする。

ログイン処理では、まずセッションが残っているか調べる。この時はセッションが残っていれば、ユーザー情報のページへリダイレクトする。User Modelでユーザー認証を行い、ユーザー情報が取得できれば、セッションを作成し、ユーザー情報ページヘリダイレクトする。


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


ログインフォームは以下のようになる。


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)の2つのルーティングを行う。

ログアウトフォームへアクセスした時、セッションが存在しなければログインフォームへリダイレクトする。

ログアウト処理では、セッションを空にし、ログインフォームヘリダイレクトする。


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


ログアウトフォームは以下のようになる。


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"}


ユーザー情報ページ

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


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


dashboard.haml

%h1 Dashboard

%table.table
%tbody
%tr
%td Name
%td= @user.name
%tr
%td Email
%td= @user.email


app.rb全体

以上のルーティングを含めた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で十分ぽい。