Rails Rails 做服务端,请问移动端的用户验证一般是用什么?

flowerwrong · 2014年07月27日 · 最后由 losingle 回复于 2014年07月28日 · 6960 次阅读

如题,我用 rails 做服务器端,提供 api,json 通信,但是不开放给第三方,所以没必要 oauth1/2。请问大家,1:移动端的用户登陆以及随后的操作一般是怎么做的?(我没做过自动端,感觉不会是每次发送用户名和密码) 举个例子,用户登陆之后可以写 blog。或者 2:有什么现成的 gem,还是自行处理 token 和 uid?3:只有 token 安全吗?或者怎样才算安全? 三个问题,谢谢大家。

我都是用 OAuth 2 Resource Owner Password Credentials Flow,登录的时候发用户名密码以及 client id、client secret 来直接获取 access token。另外很自然地,用 https 而不是 http。

User Model 是手写的。OAuth 2 Server 用了 doorkeeper gem。

好处是: 客户端的开发者可用用自己喜欢的 OAuth2 实现,不管是手写还是使用第三方库,都 OK。 服务端也不用去纠结 token 怎么生成怎么过期怎么认证。手写的 User Model 里面用 BCrypt 已经算是通行的 best practice 了。


API Server 这边大致是这样(Rails 4.1.4, Mongoid 4.0.0):

app/models/user.rb

class User
  include Mongoid::Document
  include Mongoid::Timestamps
  include ActiveModel::MassAssignmentSecurity

  attr_accessible :email, :password, :password_confirmation
  attr_accessible :display_name

  attr_accessor :password

  validates :password, confirmation: true, on: :create
  validates :password_confirmation, presence: true, on: :create
  validates :email, presence: true, uniqueness: true, email: true
  validates :display_name, presence: true, length: { minimum: 2, maximum: 42}

  before_save :encrypt_password

  field :email, type: String
  field :password_hash, type: String
  field :password_salt, type: String

  field :display_name, type: String

  def self.authenticate!(email, password)
    user = find_by(email: email) rescue nil
    if user && user.password_hash == BCrypt::Engine.hash_secret(password, user.password_salt)
      user
    else
      nil
    end
  end

  def encrypt_password
    if password.present?
      self.password_salt = BCrypt::Engine.generate_salt
      self.password_hash = BCrypt::Engine.hash_secret(password, password_salt)
    end
  end
end

config/initializers/doorkeeper.rb

module Doorkeeper
  class Application
    include ActiveModel::MassAssignmentSecurity
  end
  class AccessGrant
    include ActiveModel::MassAssignmentSecurity
  end
  class AccessToken
    include ActiveModel::MassAssignmentSecurity
  end
end

Doorkeeper.configure do
  # Change the ORM that doorkeeper will use.
  # Currently supported options are :active_record, :mongoid2, :mongoid3, :mongo_mapper
  orm :mongoid4

  resource_owner_from_credentials do
    User.authenticate! params[:username], params[:password]
  end

  native_redirect_uri 'urn:ietf:wg:oauth:2.0:oob'

  use_refresh_token
  client_credentials :from_basic
  access_token_methods :from_bearer_authorization
  grant_flows %w(password)
end

刚刚在一个项目中手写了认证,说说自己的感想。用过 Devise 来做认证,但是这个项目不需要邮箱地址,想想也不是特别难,就自己写了。可以去 RailsCasts 上面搜搜,有个 Authentication from Scratch 的视频。其实重点就是has_secure_password ,有了这个,认证什么的基本不是特别难。

由于只是后台 API,为了简单就不需要提供 Session 机制了,我选用的是 Username 和 Token,并且把两者放到 HTTP Header 中,每次需要授权的操作就带上Header['X-Api-Username']Header['X-Api-Token'],然后使用before_action来做认证。当然,如果你用 Devise 和 Grape 的话,也非常简单。不过基于 Token 的认证,好像已经从 Devise 去掉了,需要自己写。

我个人认为如果维持 Token 的复杂度,是可以在一定程度上保证安全的吧,当然,安全不是绝对的。至于如何生成 Token,我一直使用的是下面的代码:

loop do
  token = SecureRandom.hex
  break token unless self.class.exists?(auth_token: token)
end

至于 Token 的比较,我抄袭了 Devise 中的一个方法。

def self.secure_compare(a, b)
  return false if a.blank? || b.blank? || a.bytesize != b.bytesize
  l = a.unpack "C#{a.bytesize}"

  res = 0
  b.each_byte { |byte| res |= byte ^ l.shift }
  res == 0
end

当然,不知道这个方法是否合适,但是目前自己用起来还是 OK 的。

我只去验证 token

额 生成 token 的方法 略简单

def generate_token_and_expired_at
    expired_at = 1.month.from_now
    token = (Digest::MD5.hexdigest "#{SecureRandom.urlsafe_base64(nil, false)}-#{Time.now.to_i}")
  end

#2 楼 @chunlea Token 的比较 什么意思

#4 楼 @hammer 就是验证 Token,将用户发来的 Token 和数据库的做比较。至于生成 Token,我的建议是只要维持唯一性和一定长度就好。

你可以看一下 Ruby-China 的代码,有一套东西

Ruby-China?刚看了一个 python 的 app 做法,大致做法是客户端注册时,生成 access_token

access_token = generate_access_token()
def generate_access_token():
    # uuid.uuid1().hex => a3541742154011e49beb180373634345
    return uuid.uuid1().hex

随后设置过期时间,并保存到 redis,返回给客户端

expire = time.time() + 30 * 24 * 3600
set_access_token(access_token, {'uid': str(uid)})
return {
  'access_token': access_token,
  'expire': expire
}, 201

ACCESS_TOKEN_KEY_FMT = "auth:access_token:{access_token}"
def set_access_token(access_token, login_info, expire=30 * 24 * 3600):
    #' ACCESS_TOKEN_KEY_FMT.format(access_token=access_token) => auth:access_token:a3541742154011e49beb180373634345'
    g.memdb.setex(ACCESS_TOKEN_KEY_FMT.format(access_token=access_token), expire, json.dumps(login_info))

登陆是携带 access_token 放入 Authorization Authorization 形如 YWNjZXNzX3Rva2VuPTIwNzMwNDVhZTgyNzExZTM4MTMwYjhlODU2MmFhZGM4 是把 access_token=2073045ae82711e38130b8e8562aadc8 base64.urlsafe_b64encode(b'access_token=2073045ae82711e38130b8e8562aadc8') 后的结果, 服务端先解码,然后解析为数组,取出 access_token,然后从 redis 取出 uid,再从 mongodb 取得用户。定义的是一个 login_required 包裹

def login_required(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        '''
        Check and parse the ``Authorization`` Header to get ``access_token`` and valify it.
        If user has logined, then this function will call the handler function directory, or it will
        return ``401 Unauthorized``.

        '''
        reqparser = reqparse.RequestParser()
        reqparser.add_argument('Authorization', type=str, required=True, location='headers')
        reqargs = reqparser.parse_args()

        try:
            token_encoded = parse_qs(base64.urlsafe_b64decode(reqargs['Authorization']))

            if b'access_token' not in token_encoded:
                abort(401, message='Access token is required')

            access_token = token_encoded[b'access_token'][0].decode()
        except:
            abort(401, message='Malformed access token')

        login_info = get_access_token(access_token)
        if login_info is None:
            abort(401, message='Access token is expired')

        g.current_uid = ObjectId(login_info['uid'])
        return func(*args, **kwargs)
    return wrapper

登出代码

class LogoffHandler(restful.Resource):

    @login_required
    def get(self):
        argparser = reqparse.RequestParser()
        argparser.add_argument('Authorization', type=str, location='headers')
        args = argparser.parse_args()

        token_encoded = parse_qs(base64.urlsafe_b64decode(args['Authorization']))
        access_token = token_encoded[b'access_token'][0].decode()

        del_access_token(access_token)

        app.logger.info('User `%s` logout' % str(g.current_uid))

        return {}

请问有什么问题吗? 这篇帖子也不错 http://www.cnblogs.com/TankXiao/archive/2012/09/26/2695955.html

#6 楼 @assyer 感觉 ruby-china 的做法还不够,没有过期时间限制。

module RubyChina
  module APIHelpers
    def warden
      env['warden']
    end

    # topic helpers
    def max_page_size
      100
    end

    def default_page_size
      15
    end

    def page_size
      size = params[:size].to_i
      [size.zero? ? default_page_size : size, max_page_size].min
    end

    # user helpers
    def current_user
      token = params[:token] || oauth_token
      @current_user ||= User.where(private_token: token).first
    end

    def oauth_token
      # 此处的是为 ruby-china-for-ios 的 token Auth 特别设计的,不是所谓的 OAuth
      # 由于 NSRails 没有特别提供独立的 token 参数, 所以直接用 OAuth 那个参数来代替
      token = env['HTTP_AUTHORIZATION'] || ""
      token.split(" ").last
    end

    def authenticate!
      error!({ "error" => "401 Unauthorized" }, 401) unless current_user
    end
  end
end


class SessionsController < Devise::SessionsController
  def new
    super
    session['user_return_to'] = request.referrer
  end

  def create
    resource = warden.authenticate!(scope: resource_name, recall: "#{controller_path}#new")
    set_flash_message(:notice, :signed_in) if is_navigational_format?
    sign_in(resource_name, resource)
    resource.ensure_private_token!
    respond_to do |format|
      format.html { redirect_to after_sign_in_path_for(resource) }
      format.json { render status: '201', json: resource.as_json(only: [:login, :email, :private_token]) }
    end
  end
end

  # 重新生成 Private Token
  def update_private_token
    random_key = "#{SecureRandom.hex(10)}:#{self.id}"
    self.update_attribute(:private_token, random_key)
  end

楼上各位,其实还有 2 个安全防护点:

  1. 防止机器人探测密码。对同一用户名,同一 IP,认证失败的次数进行限制,避免机器暴力破解密码。
  2. 不知道你们是否熟悉 timing attach,预防方法是,在验证算法中加入一些随机延迟。

楼主如果不开放 API,而且项目是初期的话,不用太纠结安全问题,基本的 access_token 方式验证足矣。

#8 楼 @flowerwrong 😂 估计使用 API 的太少了,没有那么严格的限制,不过我觉得换成 https 基本就够了

我其实蛮担心 把生成的 token 原样返给客户端 服务端需要验证的时候 客户端原样返回 特别容易嗅探到 各位怎么做的

#7 楼 代码有点玄幻

#12 楼 @hammer 怎么说?python3.4 的,我只是 paste 了关键部分,大概知道意思就可以。

初期的产品简单的 token 就足以,然后再去慢慢迭代安全相关的特性

class User < ActiveRecord::Base
# ..code..
  devise :database_authenticatable,
    :invitable,
    :registerable,
    :recoverable,
    :rememberable,
    :trackable,
    :validatable

  attr_accessible :name, :email, :authentication_token

  before_save :ensure_authentication_token

  def ensure_authentication_token
    self.authentication_token ||= generate_authentication_token
  end

  private

  def generate_authentication_token
    loop do
      token = Devise.friendly_token
      break token unless User.where(authentication_token: token).first
    end
  end
module API
  class Root < Grape::API
    prefix    'api'
    format    :json

    rescue_from :all, :backtrace => true
    error_formatter :json, API::ErrorFormatter

    before do
      error!("401 Unauthorized", 401) unless authenticated
    end

    helpers do
      def warden
        env['warden']
      end

      def authenticated
        return true if warden.authenticated?
        params[:access_token] && @user = User.find_by_authentication_token(params[:access_token])
      end

      def current_user
        warden.user || @user
      end
    end

    mount API::V1::Root
    mount API::V2::Root
  end
end
15 楼 已删除
需要 登录 后方可回复, 如果你还没有账号请 注册新账号