Skip to content

Instantly share code, notes, and snippets.

@nowherekai
Last active August 29, 2015 14:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nowherekai/2d08d1197a851e1bf583 to your computer and use it in GitHub Desktop.
Save nowherekai/2d08d1197a851e1bf583 to your computer and use it in GitHub Desktop.

#Rails中打造用户认证系统

相信很多人都知道或者用过Devise这个gem,它在github上有将近1w3的star。但是它十分庞大和复杂,代码很难读懂,据说想学习Rails源码的话可以先看Devise的源码。使用Devise最大的问题就是定制比较困难,需要搜索文档后覆盖一些方法,但是却不知道原理。

其实实现用户认证并不复杂,完全可以从头写一个,没必要使用gem来实现。Rails的ActiveModel里面有SecurePassword,用它可以很容易的实现认证功能。本文使用Rails的SecurePassword 一步步完善用户认证系统。

##Model 首先使用 $rails g model users email:string password_digest:string 新建一个User model,有email和password_digest两个字段。其中password_digest很重要的,是存放加密后的密码的字段。

接下来需要在 User 中调用 has_secure_password 方法。

class User < ActiveRecord::Base
  has_secure_password
  
  #可以加上email的validation等
end

添加之后User 增加了三个实列方法(instance method),分别是password=, password_confirmation=, authentication, 以及一个password 属性(attribute),同时还增加了三个validation,后面会提到。用户的验证的Model中的功能就完成了。可以这样使用:

user = User.new(email: ‘user@example.com’, password: ‘’, password_confirmation: ‘nomatch’)
user.save                                                       # => false, password required
user.password = ‘mUc3m00RsqyRe’
user.save                                                       # => false, confirmation doesn’t match
user.password_confirmation = ‘mUc3m00RsqyRe’
user.save                                                       # => true
user.authenticate(‘notright’)                                   # => false
user.authenticate(‘mUc3m00RsqyRe’)                              # => user
User.find_by(email: ‘user@example.com’).try(:authenticate, ‘notright’)      # => false
User.find_by(email: ‘user@example.com’).try(:authenticate, ‘mUc3m00RsqyRe’) # => user

只添加了一行代码就获得了认证功能!

##has_secur_password 做了什么? 下面分析SecurePassword的源码,内容基于Rails 4.2.1。

def has_secure_password(options = {})
  # Load bcrypt gem only when has_secure_password is used.
  # This is to avoid ActiveModel (and by extension the entire framework)
  # being dependent on a binary library.
  begin
    require ‘bcrypt’
  rescue LoadError
    $stderr.puts “You don’t have bcrypt installed in your application. Please add it to your Gemfile and run bundle install”
    raise
  end

  include InstanceMethodsOnActivation

  if options.fetch(:validations, true)
    include ActiveModel::Validations

    # This ensures the model has a password by checking whether the password_digest
    # is present, so that this works with both new and existing records. However,
    # when there is an error, the message is added to the password attribute instead
    # so that the error message will make sense to the end-user.
    validate do |record|
      record.errors.add(:password, :blank) unless record.password_digest.present?
    end

    validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
    validates_confirmation_of :password, allow_blank: true
  end
end

has_secure_password require了bcrypt,所以我们也需要在Gemfile中添加这个gem。然后使include 了 InstanceMethodsOnActivation 这个module,最后添加了三个validations。

###InstanceMethodsOnActivation 添加了三个实例方法和一个属性方法。

  1. password=
 def password=(unencrypted_password)
   if unencrypted_password.nil?
     self.password_digest = nil
   elsif !unencrypted_password.empty?
     @password = unencrypted_password
     cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
     self.password_digest = BCrypt::Password.create(unencrypted_password, cost: cost)
   end
 end

如果unencrypted_password时nil 那么password_digest 也是nil,如果unencrypted_password 不是空字符串则会调用bcrypt的BCrypt::Password.create(unencrypted_password, cost: cost)根据password的值生成一个加盐后的hash赋给password_digest, 也即对passsword赋值回生成或更新password_digset的值, 调用user.save后会把password_digest存储到数据库。

  1. attr_reader :password 获上个函数赋值的@password

  2. password_confirmation= 添加@password_confirmation实例变量

def password_confirmation=(unencrypted_password)
  @password_confirmation = unencrypted_password
end
  1. authenticate
 def authenticate(unencrypted_password)
   BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
 end

如果password正确返回self,否则返回false。

  1. 辅助函数BCrypt::Password.create 和 BCrypt::Password.new 以及 BCrypt::Password#is_password? 感兴趣可以看bcrypt的源码,这里只列出了重要部分并在注释中稍作说明。
module BCrypt
	class Password < String
		class << self
			#对一个密钥(也即password)进行哈希(hash),然后根据哈希的结果生成一个Password实例,Password继承了String。BCrypt::Engine.generate_salt(cost)生成一个salt值,cost越大越难破解,相应耗费的计算机的计算时间也越多。BCrypt::Engine.hash_secret(secret, BCrypt::Engine.generate_salt(cost))则会根据密钥和salt值生成一个哈希值。我对加密不熟悉,以上只是根据代码进行了推测。
			def create(secret, options = {})
	        cost = options[:cost] || BCrypt::Engine.cost
	        raise ArgumentError if cost > 31
	        Password.new(BCrypt::Engine.hash_secret(secret, BCrypt::Engine.generate_salt(cost)))
	      end
		end
		
	#检测哈希值,如果有效则调用String的replace函数替换自身为这个哈希值。
	def initialize(raw_hash)
      if valid_hash?(raw_hash)
        self.replace(raw_hash)
        @version, @cost, @salt, @checksum = split_hash(self)
      else
        raise Errors::InvalidHash.new(“invalid hash”)
      end
    end
	end
	
   # 比较密钥。BCrypt::Engine.hash_secret(secret, @salt)根据新密钥和salt值计算新的哈希值,如果新哈希值和原来的哈希值相同则返回true,否则返回false。
	def ==(secret)
	 super(BCrypt::Engine.hash_secret(secret, @salt))
	end
	alias_method :is_password?, :==
end

###validations validation 默认开启,可以通过has_secure_password(validations: false) 来关闭。

  1. password 是否存在
validate do |record|
  record.errors.add(:password, :blank) unless record.password_digest.present?
end

这个validatioin检查password_digest是否存在,对新纪录(User.new)和已存在的记录(从数据库加载)都会执行检查, 但是错误信息回家到password 这个属性上。

  1. 检查password长度和confirmation
	validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
	validates_confirmation_of :password, allow_blank: true # password_confirmation可以为空或nil, 上面的password_confirmation=是为了validation关闭的时候还有这个方法

##注册 $rails g controller users new 生成UsersController

class UsersController < ApplicationController

  def new
  end

  def create
    @user = User.new(params.require(:user).permit(:email, :password, :password_confirmation))
    if @user.save
      flash[:notice] = ‘User was successfully created.’
      redirect_to(@user)
    else
      render :new
    end
  end
end

app/views/users/new.html.slim

  = form_for @user do |f|
    = render “share/form_error_messages”, form: f
    .form-group
      = f.label :email, class: “control-label”
      = f.email_field :email, class: “form-control”, tabindex: 2, required: true
    .form-group
      = f.label :password, class: “control-label”
      = f.password_field :password, class: “form-control”, tabindex: 4, required: true
    .form-group
      = f.label :password_confirmation, class: “control-label”
      = f.password_field :password_confirmation, class: “form-control”, tabindex: 5, required: true
    .form-group
      = f.submit “注册用户”, class: “btn btn-success”, tabindex: 6

##登陆 $rails g controller sessions new

class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:email])
    if user && user.authenticate(params[:password])
      session[:user_id] = user.id
      redirect_to root_url
    else
      flash[:warning] = “wrong email/password”
      render :new
    end
  end

end

view ‘’’ = form_tag login_path, method: “post” do .form-group = label_tag :email, nil, class: “control-label” = email_field_tag :email, nil, class: “form-control” .form-group = label_tag :password, nil, class: “control-label” = password_field_tag :password, nil, class: “form-control” = submit_tag “登陆”, class: “btn btn-success” ‘’’

最后再添加一个helper method:

  helper_method :current_user

  def current_user
    @current_user = User.find(session[:user_id]) if session[:user_id].present?
  end

用户认证的基本功能就算完成了,可以在view里面根据是否已经登陆显示不同内容了。

##需要完善的功能

  • 发送确认邮件
  • 记住我的功能
  • 找回密码功能
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment