公司系统用 devise 做的登录,有个需求【同一帐号被另一台机器登录,本机器被强制退出登录】,也就是一个账号只能登录同时登录一台电脑,本来觉着很简单,但自己鼓捣了半天也没什么好的方法,希望有经验的打大神指点一下
我没有做过类似的功能,但我觉得可以这样实现:
以上仅仅提供一个思路,不一定是最好的解决方案,仅供参考
感谢感谢! 我目前的思路是:在 users 表中增加 last_session_id 字段,用于存储上次登录生成的 session_id,登出的时候清空;然后每次登录之前检查 last_session_id 是否存在,如果存在,说明该用户已经登录,然后据此找到对应的 session 清空,但是我不知道怎么根据 session_id 找到对应的 session; 感觉原理和您给的思路差不多,但我对【自动登出】不会实操,尴尬了,看了半天 devise 源码,没看明白
不一定要从源码入手,尝试这样是否可行:
before_action authenticate_user!
def authenticate_user!
super
# your code
end
我的意思是,你可以在验证用户后面写你自己的逻辑,比如判断当前用户的 session_id 是否与你 DB 中保存的一样,不一样就 sign_out(user),至于登出的 redirect,你可以覆盖after_sign_out_path_for
这个方法来指定登出跳转,但如果你要做到实时性,就是一但新设备登录,老设备就自动登出,那你可能要借助长轮询(Long Polling)或 WebSocket 来实现。
嗯,我的理解是:用户验证是发生在登录成功之后的,如果按照您的方法,退出的应该是当前机器上的这个用户,而不是之前机器登录的用户;还有就是,每次登录的 session_id 都不会一样的,判断是否一样貌似没有意义了。其实我现在的困惑可以转换为怎么根据 session_id 登出相应的用户。
我这个需求按照产品的意思貌似不需要实时,只用后台登出就好了。感谢感谢!
这个问题是个好例子说明为什么 devise 很难用。
如果是自己实现的用户登录机制,那么很容易会知道应该改哪里。通常来说,user 应该带有一个 token 字段存在 session 中,访问的时候通过 token 查用户。如果要注销旧的登录态,只要把 token 重置就行了。
那么 devise 怎么做呢,应该 reset 哪个 token?我也不知道,因为内部实现太复杂了,查看数据模式根本没有这个字段,我尝试读一下源码。
首先根据我以前读源码的经验,我先找 devise 的 session controller:
def create
self.resource = warden.authenticate!(auth_options)
set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)
yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
end
关键在于 sign_in(resource_name, resource)
这一行,继续找 sign_in 如何实现:
def sign_in(resource_or_scope, *args)
options = args.extract_options!
scope = Devise::Mapping.find_scope!(resource_or_scope)
resource = args.last || resource_or_scope
expire_data_after_sign_in!
if options[:bypass]
ActiveSupport::Deprecation.warn(<<-DEPRECATION.strip_heredoc, caller)
[Devise] bypass option is deprecated and it will be removed in future version of Devise.
Please use bypass_sign_in method instead.
Example:
bypass_sign_in(user)
DEPRECATION
warden.session_serializer.store(resource, scope)
elsif warden.user(scope) == resource && !options.delete(:force)
# Do nothing. User already signed in and we are not forcing it.
true
else
warden.set_user(resource, options.merge!(scope: scope))
end
end
关键在最后一行 warden.set_user(resource, options.merge!(scope: scope))
,warden 是另一个 gem,在到另一个库里搜 set_user
做了什么:
def set_user(user, opts = {})
scope = (opts[:scope] ||= @config.default_scope)
# Get the default options from the master configuration for the given scope
opts = (@config[:scope_defaults][scope] || {}).merge(opts)
opts[:event] ||= :set_user
@users[scope] = user
if opts[:store] != false && opts[:event] != :fetch
options = env[ENV_SESSION_OPTIONS]
if options
if options.frozen?
env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
else
options[:renew] = true
end
end
session_serializer.store(user, scope)
end
run_callbacks = opts.fetch(:run_callbacks, true)
manager._run_callbacks(:after_set_user, user, self, opts) if run_callbacks
@users[scope]
end
关键是这个 session_serializer.store(user, scope)
,在找找 session_serializer 是什么鬼,然后找到这里:
def store(user, scope)
return unless user
method_name = "#{scope}_serialize"
specialized = respond_to?(method_name)
session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user)
end
我这里跳跃一下,直觉告诉我 warden 里面都是些 proxy 方法,也许 devise 用到的 serialize 是在 deivse 里面定义的,于是我回去搜 devise 里面有没有 serialize 相关的方法,于是搜到这个:
def serialize_into_session(record)
[record.to_key, record.authenticatable_salt]
end
def serialize_from_session(key, salt)
record = to_adapter.get(key)
record if record && record.authenticatable_salt == salt
end
我也不知道是不是,看起来 devise 是通过 to_key 和 authenticatable_salt 定位用户的。
在 ActiveReocrd 里 to_key 约等于 id,不可变,那么我们最好从 authenticatable_salt 入手,搜一下 authenticatable_salt
:
def authenticatable_salt
end
# A reliable way to expose the salt regardless of the implementation.
def authenticatable_salt
encrypted_password[0,29] if encrypted_password
end
根据加载的模块不同,authenticatable_salt 的实现不一样。留意 authenticatable 里面的实现,authenticatable_salt 跟 encrypted_password 绑定,这样可以实现修改密码之后 authenticatable_salt 一起变更,但这怎么解决顶楼的问题呢?
我建议是新增一个字段,然后把 authenticatable_salt alias 过去。
class User < ActiveRecord::Base
has_secure_token :auth_token
def authenticatable_salt
auth_token
end
end
然后要改写 session_controller,在每次登录之后 reset remember_token:
def create
self.resource = warden.authenticate!(auth_options)
set_flash_message!(:notice, :signed_in)
resource.regenerate_auth_token # <- 重置 auth_token
sign_in(resource_name, resource)
yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
end
以上方法我没有实践过,不保证能用。
读源码是最费时的一种方法,有的人可能会提醒我,用 Google 搜一下,也许会有现成答案。没错,我搜了一下发现这个特性有人写成了 Gem,https://github.com/phatworx/devise_security_extension (session_limitable)。大多数的 devise 问题都能搜一搜,粘贴段代码,或者装个 gem 解决,但是用户登录是系统的核心模块,这样做真的对吗,如果用户登录的原理都搞不懂,怎么能确保系统是安全的呢?
所以我的建议是别用 devise,读 devise 源码的耗时远远大于自己写,当然楼主维护现有系统是没得选了。这回复得比较啰嗦,我留作以后讨论 devise 的例子。
嗯,所以一开始我就是建议你做 uuid 来判断唯一设备,不过我想了想 uuid 放 session 不太妥。你可以这样操作:
觉着楼主记录最新的 session_id,行的通的。
通过旧的 session_id 找用户,这种后端主动登出,需要后端向前端交互,实时性高的话需要 websocket。
不过如果实时性要求不高的话,你可以通过在过滤器中判断只要与新的 session 不一致,就 sign_out,做这种被动处理。
嗯,我一开始老想着清除对应的 session,苦于没有办法找到对应的 session,而没有想到加过滤器来判断是否登陆,然后登出,思路不够开阔
除了添加额外字段对比 session 之外,你也可以用一样的思路依赖 devise 内置的 model Trackable(通常我都会建议在使用 devise 的项目中设置这个模块)的字段 sign_in_count,然后写一个 hook,几行代码就可以满足你的需求:
Warden::Manager. after_authentication do |record, warden, options|
warden.session(options[:scope])[:sign_in_count] = record.sign_in_count
end
Warden::Manager.after_fetch do |record, warden, options|
if record.sign_in_count != warden.session(options[:scope])[:sign_in_count]
warden.logout(options[:scope])
throw :warden, :scope => options[:scope], :message => "Signin from another IP address #{record.last_sign_in_ip}"
end
end
基本上做 devise 的扩展都是通过 hook 来进行,非常简洁方便。
我用 jwt 可以把 token 的 iat 放在 token 里面,服务端保存最新的 iat,客户端请求的 iat 若小于服务端的 iat 就返回 401