Autentykacja — Authlogic

If you care about your user’s security, and your time, it’s time you stop asking them for passwords. Storing user passwords and handling authentication offers a bunch of problems that have already been solved, and requires a lot of work to do it right.

Rails and OpenID: Start Your Engines

Zaczniemy od definicji: An authentication system is how you identify yourself to the computer. The goal behind an authentication system is to verify that the user is actually who they say they are.

There are many ways of authenticating a user. A good example is logging which is a password based authentication.

Jak działa Authlogic? (v2.1.3)

Linki:

[Binary Logic]

There seems to be a consensus to choose Ben Johnsos Authlogic library as the Rails way of securely identifying the users, at least with local user databases.

W przykładach poniżej skorzystamy z gemu Authlogic oraz generatorów nifty-generators.

Instalujemy oba gemy:

gem install authlogic nifty-generators

Fortunka z autentykacją

Link:

Ale własny przykład jest wart więcej, dlatego napiszemy Fortunkę z autentykacją.

Autentykację w Fortunce ustawimy tak, że tylko zalogowany użytkownik będzie mógł dodawać, usuwać i edytować cytaty. Niezalogowani użytkownicy będą mogli tylko przeglądać cytaty.

Generujemy Fortunkę

Zaczynamy od wygenerowania szkieletu aplikacji, instalacji oraz rusztowania REST dla modelu Fortune:

rails fortunka
cd fortunka
rm public/index.html  # zbędne
script/generate nifty_layout
script/generate nifty_scaffold fortune quotation:text
rake db:migrate

W pliku routes.rb ustawiamy stronę główną aplikacji:

map.root :controller => "fortunes", :action => "index"

i usuwamy domyślny routing:

map.connect ':controller/:action/:id'
map.connect ':controller/:action/:id.:format'

W pliku environment.rb dopisujemy gem Authlogic oraz ustawiamy locale na pl:

config.gem "authlogic", "2.1.3"
config.i18n.default_locale = :pl

i instalujemy go (o ile wcześniej nie był już zainstalowany):

rake gems:install
rake gems:unpack

Gotowe! Uruchamiamy aplikację:

script/server thin

i wchodzimy na stronę główną Fortunki:

http://localhost:3000/

Jeśli pojawia się ostrzeżenie:

gem_dependency.rb:119:Warning: Gem::Dependency#version_requirements
is deprecated and will be removed on or after August 2010.
Use #requirement

to czeka nas łatanie kodu. Zaczynamy od nałożenia łatki „na sucho”:

curl http://github.com/rails/rails/commit/268c9040d5c3c7ed30f3923eee71a78eeece8a8a.diff \
 | patch --dry-run -p2 -d /home/wbzyl/.rvm/gems/ree-1.8.7-2010.01/gems/rails-2.3.5/

Oczywiście, powyżej wpisujemy swoją ścieżkę do żródeł Railsów. Jeśli wszystko jest OK, to kasujemy opcję --dry-run i łatamy Railsy.

Acha, ten komunikat ignorujemy:

can't find file to patch at input line 34

Implementujemy logowanie

Zaczynamy od wygenerowania rusztowania dla modelu User (i kontrolera UsersController). Dla users będziemy potrzebować tylko dwóch widoków – new edit:

script/generate nifty_scaffold user \
  login:string email:string password:string new edit

Ponieważ nie będziemy używać czystego tekstu do pamiętania haseł, dlatego wyedytujemy utworzoną migrację, nie zapominając o dopisaniu indeksów:

class CreateUsers < ActiveRecord::Migration
  def self.up
    create_table :users do |t|
      t.string :login, :null => false
      t.string :email, :null => false
      t.string :crypted_password, :null => false
      t.string :password_salt, :null => false
      t.string :persistence_token, :null => false
      t.timestamps
    end
    add_index :users, :login
    add_index :users, :email
  end
  def self.down
    drop_table :users
  end
end

Kilka uwag. Authlogic oczekuje takiej nazwy nazwa crypted_password. Do czego służą pola: password_salt i persistence_token? Wyjaśnienie znajdziemy w dokumentacji Authlogic Example: Ensure proper database fields.

Przy implementacji „resetting passwords” będziemy potrzebować kliku dodatkowych pól w tabelce users. Pola te dopiszemy teraz do migracji:

t.integer :login_count, :default => 0, :null => false
t.datetime :last_request_at
t.datetime :last_login_at
t.datetime :current_login_at
t.string :last_login_ip
t.string :current_login_ip

Dopiero po wprowadzeniu wszystkich tych poprawek migrujemy:

rake db:migrate

Aktywacja autentykacji w Authlogic sprowadza się do dopisania jednego wiersza kodu w klasie User:

class User < ActiveRecord::Base
  acts_as_authentic
end

Jeśli chcemy zmieniać wartości domyślne walidacji Authlogic, to zaglądamy do dokumentacji. Dla przykładu:

class User < ActiveRecord::Base
  attr_accessible :login, :email, :password,
    :password_confirmation  # new; WARNING: Can't mass-assign protected attributes

  acts_as_authentic do |c|
    c.validates_length_of_password_field_options= {:within => 2..4}
    c.validates_length_of_password_confirmation_field_options= {:within => 2..4}
    c.validates_uniqueness_of_login_field_options= {:case_sensitive => true}
    c.validates_uniqueness_of_email_field_options= {:case_sensitive => true}
  end
end

Użyte powyżej opcje opisano w modułach: Authlogic::ActsAsAuthentic::Password::Config, Authlogic::ActsAsAuthentic::Login::Config itd.

Widoki dla rejestracji i logowania

Jak każe zwyczaj, w prawym górnym rogu każdej strony umieścimy linki do rejestracji i logowania.

W pliku layout/application.html.erb dodajemy element div:

<div id="user_nav">
  <%= link_to "Register", new_user_path %>
</div>

który umieszczamy w prawym górnym rogu strony, dopisując w pliku public/stylesheets/application.css:

#user_nav {
  float: right;
}

Przeładowujemy aplikację i klikamy w link Register. W wygenerowanym widok:

[generated register view 1]

i natychmiast zauważamy, że brakuje pola do potwierdzenia hasła:

[generated register view 2]

Dlatego w widoku users/_form.html.erb zmieniamy:

<p>
<%= f.label :password %><br />
<%= f.text_field :password %>
</p>

na:

<p>
  <%= f.label :password %><br />
  <%= f.password_field :password %>
</p>
<p>
  <%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %>
</p>

Uwaga: Rails wie jak obsłużyć pole password_confirmation, zob. validates_confirmation_of w API.

Wchodzimy ponownie na stronę i sprawdzamy czy walidacja działa. Przy okazji dopisujemy tłumaczenia do pliku config/locales/pl.yml. Dla przykładu, tłumaczenie wypisywanego tekstu:

translation missing: pl, activerecord, errors, template, header

wpisujemy w taki sposób:

pl:
  activerecord:
    errors:
      template:
        header: 'Niepoprawnie wypełniłeś formularz'

Po tych poprawkach, wchodzimy na stronę aplikacji:

http://localhost:3000

klikamy w link Register i ponownie sprawdzamy jak teraz działa rejestracja.

Implementujemy logowanie i wylogowywanie

Implementację logowania zaczynamy od wygenerowania modelu UserSession:

script/generate session user_session

Wygenerowany plik models/user_session.rb zawiera kod:

class UserSession < Authlogic::Session::Base
end

Zauważmy, że tym razem model nie dziedziczy po klasie ActiveRecord::Base. Ale model Authlogic::Session::Base będzie zachowywać się tak jak ActiveRecord::Base. Z tego powodu autor Authlogic opisuje gem jako „A simple model based ruby authentication solution.”

Dlaczego do logowania używamy sesji? Co to jest sesja? Czym sesja różni się od ciasteczka?

Po zalogowaniu, dopisujemy id użytkownika do sesji. Użytkownika wylogowujemy usuwając jego id z sesji.

Do sterowania będziemy potrzebować zwykłego kontrolera z metodami new – logowanie i destroy – wylogowywanie:

script/generate nifty_scaffold user_session --skip-model \
  login:string password:string new destroy

W wygenerowanym pliku user_sessions_controller.rb zmieniamy komunikaty:

flash[:notice] = "Successfully created user session."
flash[:notice] = "Successfully destroyed user session."

na:

flash[:notice] = "Successfully logged in."
flash[:notice] = "Successfully logged out."

oraz usuwamy argument (params[:id]) metody klasowej find:

@user_session = UserSession.find(params[:id])

Dlaczego? Wyszukiwanie sesji o podanym id nie ma sensu ponieważ sesja jest tylko jedna.

Musimy wykonać jeszcze kilka oczywistych zmian. W widoku user_sessions/new.html.erb zmieniamy tytuł: "New User Session" na "Log in" oraz

<%= f.text_field :password %>

na:

<%= f.password_field :password %>

Dodajemy też w pliku routes.rb zwyczajowy routing dla logowania i wylogowywania:

map.login "login", :controller => "user_sessions", :action => "new"
map.logout "logout", :controller => "user_sessions", :action => "destroy"

Na koniec pozostało jeszcze uaktualnić zawartość elementu div#user_nav w pliku layout/application.html.erb:

<div id="user_nav">
  <%= link_to "Register", new_user_path %> |
  <%= link_to "Login", login_path %>
</div>

Gotowe!. Sprawdzamy jak to wszystko działa:

http://localhost:3000/

I natychmiast zauważamy, że nie jest to to o co nam chodziło. Przy okazji, o co nam chodziło?

Zmieniamy div:

<div id="user_nav">
  <% if logged_in? %>
    <%= link_to "Edit Profile", edit_user_path(current_user.login) %> |
    <%= link_to "Logout", logout_path %>
  <% else %>
    <%= link_to "Register", new_user_path %> |
    <%= link_to "Login", login_path %>
  <% end %>
</div>

gdzie metodę logged_in? musimy sami napisać.

Uwaga: URI generowany przez

edit_user_path(current_user.login)

jest podobny do:

http://localhost:3000/users/wbzyl/edit

Definicję metody current_user znajdziemy w dokumentacji do przykładu authlogic_example. Wykonujemy cut z dokumentacji & paste do pliku application_controller.rb:

helper_method :current_user_session, :current_user
helper_method :require_user, :require_no_user, :logged_in?

private

def current_user_session
  return @current_user_session if defined?(@current_user_session)
  @current_user_session = UserSession.find
end

def current_user
  return @current_user if defined?(@current_user)
  @current_user = current_user_session && current_user_session.record
end

# to samo co current_user
def logged_in?
  current_user != nil
end

def require_user
  unless current_user
    store_location
    flash[:notice] = "You must be logged in to access this page"
    redirect_to new_user_session_url
    return false
  end
end

def require_no_user
  if current_user
    store_location
    flash[:notice] = "You must be logged out to access this page"
    redirect_to root_url
    return false
  end
end

# metoda wykorzystana w require_user i require_no_user
def store_location
  session[:return_to] = request.request_uri
end

def redirect_back_or_default(default)
  redirect_to(session[:return_to] || default)
  session[:return_to] = nil
end

Po tych poprawkach, wchodzimy na stronę główną aplikacji:

http://localhost:3000/

logujemy się i klikamy link Edit profile. Link ten generuje błąd. Aby go poprawić, zmieniamy wygenerowany kod metod edit i update kontrolera users_controller.rb:

def edit
  @user = User.find(params[:id])
end

na:

def edit
  @user = current_user
end

Analogiczną poprawkę robimy w kodzie metody update. Przy okazji w kodzie tej metody zamieniamy komunikat:

flash[:notice] = "Successfully updated user."

na:

flash[:notice] = "Successfully updated profile."

Le Grande Finale: ograniczamy dostęp

Jak zapewnić sobie, aby tylko zalogowany użytkownik mógł dodawać nowe cytaty oraz edytować i usuwać już istniejące cytaty?

Skorzystamy z metody pomocniczej require_user oraz mechanizmu before_filter.

W pliku fortunes_controller.rb dopisujemy:

class FortunesController < ApplicationController
  before_filter :require_user,
    :only => [:new, :edit, :create, :update, :destroy]
  # albo tak
  # before_filter :require_user,
  #  :except => [:show, :index]

W widokach: fortunes/index.html.erb, fortunes/show.html.erb ograniczymy dostęp dopisując w odpowiednich miejscach:

<% if logged_in? %>
...
<% end %>

Ale zanim to zrobimy, powinniśmy sprawdzić jak działa before_filter.

Bezpieczeństwo

W logach nie powinne być zapisywane żadne poufne informacje, np. hasła. Dopisujemy w application_controller.rb:

filter_parameter_logging :password, :password_confirmation

Wartości jakich pól powinniśmy jeszcze odfiltrować?

Pozostałe szczegóły

Teraz należy przeczytać dokumentację: Authlogic Example.

Generowanie autentykacji

Najnowsza wersja gemu nifty-generators zawiera generator nifty_authentication. Przeczytać dokumentację tego generatora:

script/generate nifty_authentication --help

Przygotować prosty przykład. Jakieś pytania?

Jak zmienić zapomniane hasło?

Zaczynamy od przeczytania samouczka: Tutorial. Reset passwords with Authlogic the RESTful way.

Po lekturze samouczka, widzimy że musimy zacząć od konfiguracji Action Mailer. Tworzymy plik initializers/mail.rb o następującej zawartości:

ActionMailer::Base.smtp_settings = {
  :address => 'inf.ug.edu.pl',
  :port => 25,
  :domain => 'ug.edu.pl',
  :user_name => 'wbzyl',
  :password => 'sekret',
  :authentication => :login
}
#ActionMailer::Base.delivery_method = :sendmail
#ActionMailer::Base.sendmail_settings = {
#  :location => '/usr/sbin/sendmail',
#  :arguments => '-i -t'
#}
ActionMailer::Base.perform_deliveries = true
ActionMailer::Base.raise_delivery_errors = true
ActionMailer::Base.default_charset = 'utf-8'

Jeśli chcemy przetestować wysyłanie email lokalnie w trybie development, to włączamy usługę sendmail na localhost. Usługę pocztową uruchamiamy na Fedorze korzystając z polecenia service:

sudo services sendmail start

Resetowanie hasła krok po kroku

  1. A user requests a password reset
  2. An email is sent to them with a link to reset their password
  3. The user verifies their identity be using the link we emailed them
  4. They are presented with a basic form to change their password
  5. After they change their password we log them in, redirect them to their account, and expire the URL we just sent them

Przesłany mail będzie zawierał link zawierający tekst podobny do: 4LiXF7FiGUppIPubBPey. Ten tekst nazywamy perishable_token. Jest on pamiętany w polu perishable_token tabeli users.

Authlogic zarządza tym tokenem w następujący sposób:

Tworzymy migrację, która doda token to tabeli:

script/generate migration add_users_password_reset_fields

i modyfikujemy ją w następujący sposób:

class AddUsersPasswordResetFields < ActiveRecord::Migration
  def self.up
    add_column :users, :perishable_token, :string,
        :default => "", :null => false
    add_index :users, :perishable_token
  end
  def self.down
    remove_column :users, :perishable_token
  end
end

Po tych zmianach migrujemy:

rake db:migrate

Zmiana hasła na sposób REST

Tworzymy resource/zasób o nazwie password resets i dopisujemy go do routingu:

map.resources :password_resets

generujemy pusty kontroler:

script/generate controller password_resets

i wklejamy gotowca (na razie tylko tyle):

class PasswordResetsController < ApplicationController
  before_filter :load_user_using_perishable_token, :only => [:edit, :update]

  # make sure the user is logged out when accessing these methods
  before_filter :require_no_user

  def new
    render
  end

  private
    def load_user_using_perishable_token
      @user = User.find_using_perishable_token(params[:id])
      unless @user
        flash[:notice] = "We're sorry, but we could not locate your account." +
          "If you are having issues try copying and pasting the URL " +
          "from your email into your browser or restarting the " +
          "reset password process."
        redirect_to root_url
      end
    end
end

Method find_using_perishable_token is a special in Authlogic. Here is what it does for extra security:

Widok new.html.erb dla tego kontrolera:

<h1>Forgot Password</h1>

<p>Fill out the form below and instructions
   to reset your password will be emailed to you:
</p>

<% form_tag password_resets_path do %>
  <label>Email:</label><br />
  <%= text_field_tag "email" %><br />
  <br />
  <%= submit_tag "Reset my password" %>
<% end %>

Kilknięcie na przycisk "Reset my password" powoduje wywołanie metody create tego kontrolera:

def create
  @user = User.find_by_email(params[:email])
  if @user
    @user.deliver_password_reset_instructions!
    flash[:notice] = "Instructions to reset your password have been emailed to you. " +
      "Please check your email."
    redirect_to root_url
  else
    flash[:notice] = "No user was found with that email address"
    render :action => :new
  end
end

Email z linkiem do zmiany hasła

Zobacz Action Mailer Basics: 2.2 Action Mailer and Dynamic deliver_ methods.

Wysyłanie emaila z linkiem realizuje metoda deliver_password_reset_instructions!. Za wysłanie odpowiedzialna jest klasa Notifier.

class User < ActiveRecord::Base
  acts_as_authentic

  def deliver_password_reset_instructions!
    reset_perishable_token!
    Notifier.deliver_password_reset_instructions(self)
  end

gdzie metoda reset_perishable_token! gemu Authlogic nadaje nową przypadkową wartość polu perishable_token.

Pozostaje zdefiniować model Notifier i w niej metodę password_reset_instructions (konwencja nazewnicza: nazwa bez prefixu deliver):

class Notifier < ActionMailer::Base
  default_url_options[:host] = "sigma.ug.edu.pl:3000"
  #default_url_options[:host] = "localhost:3000"

  def password_reset_instructions(user)
    subject    "Password Reset Instructions"
    from       "noreply@sigma.ug.edu.pl"
    recipients  user.email
    sent_on     Time.now
    body        :edit_password_reset_url =>
                   edit_password_reset_url(user.perishable_token)
  end
end

Szablon emaila, to widok app/views/notifier/password_reset_instructions.erb:

A request to reset your password has been made.
If you did not make this request, simply ignore this email.
If you did make this request just click the link below:

<%= @edit_password_reset_url %>

If the above URL does not work try copying and pasting
it into your browser. If you continue to have problem
please feel free to contact us.

Użytkownik kilka na link w emailu

Link który użytkownik ma kliknąć w emailu będzie wyglądał jakoś tak:

http://sigma.ug.edu.pl:3000/password_resets/6th8mSFwxvG-v2IbbRdY/edit

Po kliknięciu wykonana zostanie metoda edit klasy PasswordResetsController:

def edit
  render
end

która renderuje widok edit.html.erb:

<h1>Change My Password</h1>

<% form_for @user, :url => password_reset_path, :method => :put do |f| %>
  <%= f.error_messages %>
  <%= f.label :password %><br />
  <%= f.password_field :password %><br />
  <br />
  <%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %><br />
  <br />
  <%= f.submit "Update my password and log me in" %>
<% end %>

Wyrenderowany formularz wygląda mniej więcej tak (gdzie "RgfuGYh4yU6qbQIsIeiE" poniżej, to perishable_token):

<form action="/password_resets/RgfuGYh4yU6qbQIsIeiE" id="edit_user_1" method="put">
...
<input id="user_password" name="user[password]" size="30" type="password" />
...

Oznacza to, że po kliknięciu na przycisk submit zostanie wywołana metoda update klasy PasswordResetsConttoller (dopisujemy ją):

def update
  @user.password = params[:user][:password]
  @user.password_confirmation = params[:user][:password_confirmation]
  if @user.save
    flash[:notice] = "Password successfully updated"
    redirect_to root_url
  else
    render :action => :edit
  end
end

Podczepienie "Forgot password" pod Fortunkę

W widoku layout/application.html.erb w elemencie div#user_nav dopisujemy link do "Forgot password?":

<div id="user_nav">
  <% if current_user %>
    <%= link_to "Edit Profile", edit_user_path(:current) %> |
    <%= link_to "Logout", logout_path %> |
    Hello <em><%= current_user.login %></em>
  <% else %>
    <%= link_to "Register", new_user_path %> |
    <%= link_to "Login", login_path %> |
    <%= link_to "Forgot password", new_password_reset_path %>
  <% end %>
</div>

Linki