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

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で十分ぽい。

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