Display and revoke active sessions
This commit is contained in:
parent
d812ef0170
commit
9b33e3d36f
23 changed files with 642 additions and 47 deletions
3
Gemfile
3
Gemfile
|
@ -184,6 +184,9 @@ gem 're2', '~> 1.1.1'
|
|||
|
||||
gem 'version_sorter', '~> 2.1.0'
|
||||
|
||||
# User agent parsing
|
||||
gem 'device_detector'
|
||||
|
||||
# Cache
|
||||
gem 'redis-rails', '~> 5.0.2'
|
||||
|
||||
|
|
|
@ -161,6 +161,7 @@ GEM
|
|||
activerecord (>= 3.2.0, < 5.1)
|
||||
descendants_tracker (0.0.4)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
device_detector (1.0.0)
|
||||
devise (4.2.0)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
|
@ -1026,6 +1027,7 @@ DEPENDENCIES
|
|||
database_cleaner (~> 1.5.0)
|
||||
deckar01-task_list (= 2.0.0)
|
||||
default_value_for (~> 3.0.0)
|
||||
device_detector
|
||||
devise (~> 4.2)
|
||||
devise-two-factor (~> 3.0.0)
|
||||
diffy (~> 3.1.0)
|
||||
|
|
|
@ -452,6 +452,7 @@ img.emoji {
|
|||
|
||||
/** COMMON CLASSES **/
|
||||
.prepend-top-0 { margin-top: 0; }
|
||||
.prepend-top-2 { margin-top: 2px; }
|
||||
.prepend-top-5 { margin-top: 5px; }
|
||||
.prepend-top-8 { margin-top: $grid-size; }
|
||||
.prepend-top-10 { margin-top: 10px; }
|
||||
|
|
|
@ -39,35 +39,10 @@
|
|||
svg {
|
||||
fill: currentColor;
|
||||
|
||||
&.s8 {
|
||||
@include svg-size(8px);
|
||||
}
|
||||
|
||||
&.s12 {
|
||||
@include svg-size(12px);
|
||||
}
|
||||
|
||||
&.s16 {
|
||||
@include svg-size(16px);
|
||||
}
|
||||
|
||||
&.s18 {
|
||||
@include svg-size(18px);
|
||||
}
|
||||
|
||||
&.s24 {
|
||||
@include svg-size(24px);
|
||||
}
|
||||
|
||||
&.s32 {
|
||||
@include svg-size(32px);
|
||||
}
|
||||
|
||||
&.s48 {
|
||||
@include svg-size(48px);
|
||||
}
|
||||
|
||||
&.s72 {
|
||||
@include svg-size(72px);
|
||||
$svg-sizes: 8 12 16 18 24 32 48 72;
|
||||
@each $svg-size in $svg-sizes {
|
||||
&.s#{$svg-size} {
|
||||
@include svg-size(#{$svg-size}px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
14
app/controllers/profiles/active_sessions_controller.rb
Normal file
14
app/controllers/profiles/active_sessions_controller.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class Profiles::ActiveSessionsController < Profiles::ApplicationController
|
||||
def index
|
||||
@sessions = ActiveSession.list(current_user)
|
||||
end
|
||||
|
||||
def destroy
|
||||
ActiveSession.destroy(current_user, params[:id])
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to profile_active_sessions_url, status: 302 }
|
||||
format.js { head :ok }
|
||||
end
|
||||
end
|
||||
end
|
23
app/helpers/active_sessions_helper.rb
Normal file
23
app/helpers/active_sessions_helper.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
module ActiveSessionsHelper
|
||||
# Maps a device type as defined in `ActiveSession` to an svg icon name and
|
||||
# outputs the icon html.
|
||||
#
|
||||
# see `DeviceDetector::Device::DEVICE_NAMES` about the available device types
|
||||
def active_session_device_type_icon(active_session)
|
||||
icon_name =
|
||||
case active_session.device_type
|
||||
when 'smartphone', 'feature phone', 'phablet'
|
||||
'mobile'
|
||||
when 'tablet'
|
||||
'tablet'
|
||||
when 'tv', 'smart display', 'camera', 'portable media player', 'console'
|
||||
'media'
|
||||
when 'car browser'
|
||||
'car'
|
||||
else
|
||||
'monitor-o'
|
||||
end
|
||||
|
||||
sprite_icon(icon_name, size: 16, css_class: 'prepend-top-2')
|
||||
end
|
||||
end
|
110
app/models/active_session.rb
Normal file
110
app/models/active_session.rb
Normal file
|
@ -0,0 +1,110 @@
|
|||
class ActiveSession
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :created_at, :updated_at,
|
||||
:session_id, :ip_address,
|
||||
:browser, :os, :device_name, :device_type
|
||||
|
||||
def current?(session)
|
||||
return false if session_id.nil? || session.id.nil?
|
||||
|
||||
session_id == session.id
|
||||
end
|
||||
|
||||
def human_device_type
|
||||
device_type&.titleize
|
||||
end
|
||||
|
||||
def self.set(user, request)
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
session_id = request.session.id
|
||||
client = DeviceDetector.new(request.user_agent)
|
||||
timestamp = Time.current
|
||||
|
||||
active_user_session = new(
|
||||
ip_address: request.ip,
|
||||
browser: client.name,
|
||||
os: client.os_name,
|
||||
device_name: client.device_name,
|
||||
device_type: client.device_type,
|
||||
created_at: user.current_sign_in_at || timestamp,
|
||||
updated_at: timestamp,
|
||||
session_id: session_id
|
||||
)
|
||||
|
||||
redis.pipelined do
|
||||
redis.setex(
|
||||
key_name(user.id, session_id),
|
||||
Settings.gitlab['session_expire_delay'] * 60,
|
||||
Marshal.dump(active_user_session)
|
||||
)
|
||||
|
||||
redis.sadd(
|
||||
lookup_key_name(user.id),
|
||||
session_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.list(user)
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
cleaned_up_lookup_entries(redis, user.id).map do |entry|
|
||||
# rubocop:disable Security/MarshalLoad
|
||||
Marshal.load(entry)
|
||||
# rubocop:enable Security/MarshalLoad
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.destroy(user, session_id)
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.srem(lookup_key_name(user.id), session_id)
|
||||
|
||||
deleted_keys = redis.del(key_name(user.id, session_id))
|
||||
|
||||
# only allow deleting the devise session if we could actually find a
|
||||
# related active session. this prevents another user from deleting
|
||||
# someone else's session.
|
||||
if deleted_keys > 0
|
||||
redis.del("#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.cleanup(user)
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
cleaned_up_lookup_entries(redis, user.id)
|
||||
end
|
||||
end
|
||||
|
||||
def self.key_name(user_id, session_id = '*')
|
||||
"#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
|
||||
end
|
||||
|
||||
def self.lookup_key_name(user_id)
|
||||
"#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
|
||||
end
|
||||
|
||||
def self.cleaned_up_lookup_entries(redis, user_id)
|
||||
lookup_key = lookup_key_name(user_id)
|
||||
|
||||
session_ids = redis.smembers(lookup_key)
|
||||
|
||||
entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
|
||||
return [] if entry_keys.empty?
|
||||
|
||||
entries = redis.mget(entry_keys)
|
||||
|
||||
session_ids_and_entries = session_ids.zip(entries)
|
||||
|
||||
# remove expired keys.
|
||||
# only the single key entries are automatically expired by redis, the
|
||||
# lookup entries in the set need to be removed manually.
|
||||
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
|
||||
redis.srem(lookup_key, session_id)
|
||||
end
|
||||
|
||||
session_ids_and_entries.select { |_session_id, entry| entry }.map { |_session_id, entry| entry }
|
||||
end
|
||||
end
|
|
@ -129,6 +129,17 @@
|
|||
= link_to profile_preferences_path do
|
||||
%strong.fly-out-top-item-name
|
||||
#{ _('Preferences') }
|
||||
= nav_link(controller: :active_sessions) do
|
||||
= link_to profile_active_sessions_path do
|
||||
.nav-icon-container
|
||||
= sprite_icon('monitor-lines')
|
||||
%span.nav-item-name
|
||||
Active Sessions
|
||||
%ul.sidebar-sub-level-items.is-fly-out-only
|
||||
= nav_link(controller: :active_sessions, html_options: { class: "fly-out-top-item" } ) do
|
||||
= link_to profile_active_sessions_path do
|
||||
%strong.fly-out-top-item-name
|
||||
#{ _('Active Sessions') }
|
||||
= nav_link(path: 'profiles#audit_log') do
|
||||
= link_to audit_log_profile_path do
|
||||
.nav-icon-container
|
||||
|
|
31
app/views/profiles/active_sessions/_active_session.html.haml
Normal file
31
app/views/profiles/active_sessions/_active_session.html.haml
Normal file
|
@ -0,0 +1,31 @@
|
|||
- is_current_session = active_session.current?(session)
|
||||
|
||||
%li
|
||||
.pull-left.append-right-10{ data: { toggle: 'tooltip' }, title: active_session.human_device_type }
|
||||
= active_session_device_type_icon(active_session)
|
||||
|
||||
.description.pull-left
|
||||
%div
|
||||
%strong= active_session.ip_address
|
||||
- if is_current_session
|
||||
%div This is your current session
|
||||
- else
|
||||
%div
|
||||
Last accessed on
|
||||
= l(active_session.updated_at, format: :short)
|
||||
|
||||
%div
|
||||
%strong= active_session.browser
|
||||
on
|
||||
%strong= active_session.os
|
||||
|
||||
%div
|
||||
%strong Signed in
|
||||
on
|
||||
= l(active_session.created_at, format: :short)
|
||||
|
||||
- unless is_current_session
|
||||
.pull-right
|
||||
= link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
|
||||
%span.sr-only Revoke
|
||||
Revoke
|
14
app/views/profiles/active_sessions/index.html.haml
Normal file
14
app/views/profiles/active_sessions/index.html.haml
Normal file
|
@ -0,0 +1,14 @@
|
|||
- page_title 'Active Sessions'
|
||||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
|
||||
.row.prepend-top-default
|
||||
.col-lg-4.profile-settings-sidebar
|
||||
%h4.prepend-top-0
|
||||
= page_title
|
||||
%p
|
||||
This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.
|
||||
.col-lg-8
|
||||
.append-bottom-default
|
||||
|
||||
%ul.well-list
|
||||
= render partial: 'profiles/active_sessions/active_session', collection: @sessions
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Display active sessions and allow the user to revoke any of it
|
||||
merge_request: 17867
|
||||
author: Alexis Reigel
|
||||
type: added
|
|
@ -15,19 +15,15 @@ cookie_key = if Rails.env.development?
|
|||
"_gitlab_session"
|
||||
end
|
||||
|
||||
if Rails.env.test?
|
||||
Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session"
|
||||
else
|
||||
sessions_config = Gitlab::Redis::SharedState.params
|
||||
sessions_config[:namespace] = Gitlab::Redis::SharedState::SESSION_NAMESPACE
|
||||
sessions_config = Gitlab::Redis::SharedState.params
|
||||
sessions_config[:namespace] = Gitlab::Redis::SharedState::SESSION_NAMESPACE
|
||||
|
||||
Gitlab::Application.config.session_store(
|
||||
:redis_store, # Using the cookie_store would enable session replay attacks.
|
||||
servers: sessions_config,
|
||||
key: cookie_key,
|
||||
secure: Gitlab.config.gitlab.https,
|
||||
httponly: true,
|
||||
expires_in: Settings.gitlab['session_expire_delay'] * 60,
|
||||
path: Rails.application.config.relative_url_root.nil? ? '/' : Gitlab::Application.config.relative_url_root
|
||||
)
|
||||
end
|
||||
Gitlab::Application.config.session_store(
|
||||
:redis_store, # Using the cookie_store would enable session replay attacks.
|
||||
servers: sessions_config,
|
||||
key: cookie_key,
|
||||
secure: Gitlab.config.gitlab.https,
|
||||
httponly: true,
|
||||
expires_in: Settings.gitlab['session_expire_delay'] * 60,
|
||||
path: Rails.application.config.relative_url_root.nil? ? '/' : Gitlab::Application.config.relative_url_root
|
||||
)
|
||||
|
|
|
@ -6,4 +6,16 @@ Rails.application.configure do |config|
|
|||
Warden::Manager.before_failure do |env, opts|
|
||||
Gitlab::Auth::BlockedUserTracker.log_if_user_blocked(env)
|
||||
end
|
||||
|
||||
Warden::Manager.after_authentication do |user, auth, opts|
|
||||
ActiveSession.cleanup(user)
|
||||
end
|
||||
|
||||
Warden::Manager.after_set_user only: :fetch do |user, auth, opts|
|
||||
ActiveSession.set(user, auth.request)
|
||||
end
|
||||
|
||||
Warden::Manager.before_logout do |user, auth, opts|
|
||||
ActiveSession.destroy(user || auth.user, auth.request.session.id)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -30,6 +30,7 @@ resource :profile, only: [:show, :update] do
|
|||
put :revoke
|
||||
end
|
||||
end
|
||||
resources :active_sessions, only: [:index, :destroy]
|
||||
resources :emails, only: [:index, :create, :destroy] do
|
||||
member do
|
||||
put :resend_confirmation_instructions
|
||||
|
|
|
@ -49,7 +49,7 @@ Please use the following function inside JS to render an icon :
|
|||
|
||||
All Icons and Illustrations are managed in the [gitlab-svgs](https://gitlab.com/gitlab-org/gitlab-svgs) repository which is added as a dev-dependency.
|
||||
|
||||
To upgrade to a new SVG Sprite version run `yarn upgrade @gitlab-org/gitlab-svgs` and then run `yarn run svg`. This task will copy the svg sprite and all illustrations in the correct folders. The updated files should be tracked in Git as those are referenced.
|
||||
To upgrade to a new SVG Sprite version run `yarn upgrade @gitlab-org/gitlab-svgs`.
|
||||
|
||||
# SVG Illustrations
|
||||
|
||||
|
|
20
doc/user/profile/active_sessions.md
Normal file
20
doc/user/profile/active_sessions.md
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Active Sessions
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17867)
|
||||
> in GitLab 10.8.
|
||||
|
||||
GitLab lists all devices that have logged into your account. This allows you to
|
||||
review the sessions and revoke any of it that you don't recognize.
|
||||
|
||||
## Listing all active sessions
|
||||
|
||||
1. On the upper right corner, click on your avatar and go to your **Settings**.
|
||||
1. Navigate to the **Active Sessions** tab.
|
||||
|
||||
![Active sessions list](img/active_sessions_list.png)
|
||||
|
||||
## Revoking a session
|
||||
|
||||
1. Navigate to your [profile's](#profile-settings) **Settings > Active Sessions**.
|
||||
1. Click on **Revoke** besides a session. The current session cannot be
|
||||
revoked, as this would sign you out of GitLab.
|
BIN
doc/user/profile/img/active_sessions_list.png
Normal file
BIN
doc/user/profile/img/active_sessions_list.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
|
@ -39,6 +39,7 @@ From there, you can:
|
|||
- Manage [SSH keys](../../ssh/README.md#ssh) to access your account via SSH
|
||||
- Manage your [preferences](preferences.md#syntax-highlighting-theme)
|
||||
to customize your own GitLab experience
|
||||
- [View your active sessions](active_sessions.md) and revoke any of them if necessary
|
||||
- Access your audit log, a security log of important events involving your account
|
||||
|
||||
## Changing your username
|
||||
|
|
|
@ -5,6 +5,8 @@ module Gitlab
|
|||
module Redis
|
||||
class SharedState < ::Gitlab::Redis::Wrapper
|
||||
SESSION_NAMESPACE = 'session:gitlab'.freeze
|
||||
USER_SESSIONS_NAMESPACE = 'session:user:gitlab'.freeze
|
||||
USER_SESSIONS_LOOKUP_NAMESPACE = 'session:lookup:user:gitlab'.freeze
|
||||
DEFAULT_REDIS_SHARED_STATE_URL = 'redis://localhost:6382'.freeze
|
||||
REDIS_SHARED_STATE_CONFIG_ENV_VAR_NAME = 'GITLAB_REDIS_SHARED_STATE_CONFIG_FILE'.freeze
|
||||
|
||||
|
|
|
@ -142,7 +142,7 @@ describe Projects::Clusters::GcpController do
|
|||
|
||||
context 'when google project billing is enabled' do
|
||||
before do
|
||||
redis_double = double
|
||||
redis_double = double.as_null_object
|
||||
allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double)
|
||||
allow(redis_double).to receive(:get).with(CheckGcpProjectBillingWorker.redis_shared_state_key_for('token')).and_return('true')
|
||||
end
|
||||
|
|
89
spec/features/profiles/active_sessions_spec.rb
Normal file
89
spec/features/profiles/active_sessions_spec.rb
Normal file
|
@ -0,0 +1,89 @@
|
|||
require 'rails_helper'
|
||||
|
||||
feature 'Profile > Active Sessions', :clean_gitlab_redis_shared_state do
|
||||
let(:user) do
|
||||
create(:user).tap do |user|
|
||||
user.current_sign_in_at = Time.current
|
||||
end
|
||||
end
|
||||
|
||||
around do |example|
|
||||
Timecop.freeze(Time.zone.parse('2018-03-12 09:06')) do
|
||||
example.run
|
||||
end
|
||||
end
|
||||
|
||||
scenario 'User sees their active sessions' do
|
||||
Capybara::Session.new(:session1)
|
||||
Capybara::Session.new(:session2)
|
||||
|
||||
# note: headers can only be set on the non-js (aka. rack-test) driver
|
||||
using_session :session1 do
|
||||
Capybara.page.driver.header(
|
||||
'User-Agent',
|
||||
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:58.0) Gecko/20100101 Firefox/58.0'
|
||||
)
|
||||
|
||||
gitlab_sign_in(user)
|
||||
end
|
||||
|
||||
# set an additional session on another device
|
||||
using_session :session2 do
|
||||
Capybara.page.driver.header(
|
||||
'User-Agent',
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 8_1_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Mobile/12B466 [FBDV/iPhone7,2]'
|
||||
)
|
||||
|
||||
gitlab_sign_in(user)
|
||||
end
|
||||
|
||||
using_session :session1 do
|
||||
visit profile_active_sessions_path
|
||||
|
||||
expect(page).to have_content(
|
||||
'127.0.0.1 ' \
|
||||
'This is your current session ' \
|
||||
'Firefox on Ubuntu ' \
|
||||
'Signed in on 12 Mar 09:06'
|
||||
)
|
||||
|
||||
expect(page).to have_selector '[title="Desktop"]', count: 1
|
||||
|
||||
expect(page).to have_content(
|
||||
'127.0.0.1 ' \
|
||||
'Last accessed on 12 Mar 09:06 ' \
|
||||
'Mobile Safari on iOS ' \
|
||||
'Signed in on 12 Mar 09:06'
|
||||
)
|
||||
|
||||
expect(page).to have_selector '[title="Smartphone"]', count: 1
|
||||
end
|
||||
end
|
||||
|
||||
scenario 'User can revoke a session', :js, :redis_session_store do
|
||||
Capybara::Session.new(:session1)
|
||||
Capybara::Session.new(:session2)
|
||||
|
||||
# set an additional session in another browser
|
||||
using_session :session2 do
|
||||
gitlab_sign_in(user)
|
||||
end
|
||||
|
||||
using_session :session1 do
|
||||
gitlab_sign_in(user)
|
||||
visit profile_active_sessions_path
|
||||
|
||||
expect(page).to have_link('Revoke', count: 1)
|
||||
|
||||
accept_confirm { click_on 'Revoke' }
|
||||
|
||||
expect(page).not_to have_link('Revoke')
|
||||
end
|
||||
|
||||
using_session :session2 do
|
||||
visit profile_active_sessions_path
|
||||
|
||||
expect(page).to have_content('You need to sign in or sign up before continuing.')
|
||||
end
|
||||
end
|
||||
end
|
69
spec/features/users/active_sessions_spec.rb
Normal file
69
spec/features/users/active_sessions_spec.rb
Normal file
|
@ -0,0 +1,69 @@
|
|||
require 'spec_helper'
|
||||
|
||||
feature 'Active user sessions', :clean_gitlab_redis_shared_state do
|
||||
scenario 'Successful login adds a new active user login' do
|
||||
now = Time.zone.parse('2018-03-12 09:06')
|
||||
Timecop.freeze(now) do
|
||||
user = create(:user)
|
||||
gitlab_sign_in(user)
|
||||
expect(current_path).to eq root_path
|
||||
|
||||
sessions = ActiveSession.list(user)
|
||||
expect(sessions.count).to eq 1
|
||||
|
||||
# refresh the current page updates the updated_at
|
||||
Timecop.freeze(now + 1.minute) do
|
||||
visit current_path
|
||||
|
||||
sessions = ActiveSession.list(user)
|
||||
expect(sessions.first).to have_attributes(
|
||||
created_at: Time.zone.parse('2018-03-12 09:06'),
|
||||
updated_at: Time.zone.parse('2018-03-12 09:07')
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
scenario 'Successful login cleans up obsolete entries' do
|
||||
user = create(:user)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d')
|
||||
end
|
||||
|
||||
gitlab_sign_in(user)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).not_to include '59822c7d9fcdfa03725eff41782ad97d'
|
||||
end
|
||||
end
|
||||
|
||||
scenario 'Sessionless login does not clean up obsolete entries' do
|
||||
user = create(:user)
|
||||
personal_access_token = create(:personal_access_token, user: user)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d')
|
||||
end
|
||||
|
||||
visit user_path(user, :atom, private_token: personal_access_token.token)
|
||||
expect(page.status_code).to eq 200
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to include '59822c7d9fcdfa03725eff41782ad97d'
|
||||
end
|
||||
end
|
||||
|
||||
scenario 'Logout deletes the active user login' do
|
||||
user = create(:user)
|
||||
gitlab_sign_in(user)
|
||||
expect(current_path).to eq root_path
|
||||
|
||||
expect(ActiveSession.list(user).count).to eq 1
|
||||
|
||||
gitlab_sign_out
|
||||
expect(current_path).to eq new_user_session_path
|
||||
|
||||
expect(ActiveSession.list(user)).to be_empty
|
||||
end
|
||||
end
|
216
spec/models/active_session_spec.rb
Normal file
216
spec/models/active_session_spec.rb
Normal file
|
@ -0,0 +1,216 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
|
||||
let(:user) do
|
||||
create(:user).tap do |user|
|
||||
user.current_sign_in_at = Time.current
|
||||
end
|
||||
end
|
||||
|
||||
let(:session) { double(:session, id: '6919a6f1bb119dd7396fadc38fd18d0d') }
|
||||
|
||||
let(:request) do
|
||||
double(:request, {
|
||||
user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_1_3 like Mac OS X) AppleWebKit/600.1.4 ' \
|
||||
'(KHTML, like Gecko) Mobile/12B466 [FBDV/iPhone7,2]',
|
||||
ip: '127.0.0.1',
|
||||
session: session
|
||||
})
|
||||
end
|
||||
|
||||
describe '#current?' do
|
||||
it 'returns true if the active session matches the current session' do
|
||||
active_session = ActiveSession.new(session_id: '6919a6f1bb119dd7396fadc38fd18d0d')
|
||||
|
||||
expect(active_session.current?(session)).to be true
|
||||
end
|
||||
|
||||
it 'returns false if the active session does not match the current session' do
|
||||
active_session = ActiveSession.new(session_id: '59822c7d9fcdfa03725eff41782ad97d')
|
||||
|
||||
expect(active_session.current?(session)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if the session id is nil' do
|
||||
active_session = ActiveSession.new(session_id: nil)
|
||||
session = double(:session, id: nil)
|
||||
|
||||
expect(active_session.current?(session)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '.list' do
|
||||
it 'returns all sessions by user' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", Marshal.dump({ session_id: 'a' }))
|
||||
redis.set("session:user:gitlab:#{user.id}:59822c7d9fcdfa03725eff41782ad97d", Marshal.dump({ session_id: 'b' }))
|
||||
redis.set("session:user:gitlab:9999:5c8611e4f9c69645ad1a1492f4131358", '')
|
||||
|
||||
redis.sadd(
|
||||
"session:lookup:user:gitlab:#{user.id}",
|
||||
%w[
|
||||
6919a6f1bb119dd7396fadc38fd18d0d
|
||||
59822c7d9fcdfa03725eff41782ad97d
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
expect(ActiveSession.list(user)).to match_array [{ session_id: 'a' }, { session_id: 'b' }]
|
||||
end
|
||||
|
||||
it 'does not return obsolete entries and cleans them up' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", Marshal.dump({ session_id: 'a' }))
|
||||
|
||||
redis.sadd(
|
||||
"session:lookup:user:gitlab:#{user.id}",
|
||||
%w[
|
||||
6919a6f1bb119dd7396fadc38fd18d0d
|
||||
59822c7d9fcdfa03725eff41782ad97d
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
expect(ActiveSession.list(user)).to eq [{ session_id: 'a' }]
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.sscan_each("session:lookup:user:gitlab:#{user.id}").to_a).to eq ['6919a6f1bb119dd7396fadc38fd18d0d']
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns an empty array if the use does not have any active session' do
|
||||
expect(ActiveSession.list(user)).to eq []
|
||||
end
|
||||
end
|
||||
|
||||
describe '.set' do
|
||||
it 'sets a new redis entry for the user session and a lookup entry' do
|
||||
ActiveSession.set(user, request)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.scan_each.to_a).to match_array [
|
||||
"session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d",
|
||||
"session:lookup:user:gitlab:#{user.id}"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
it 'adds timestamps and information from the request' do
|
||||
Timecop.freeze(Time.zone.parse('2018-03-12 09:06')) do
|
||||
ActiveSession.set(user, request)
|
||||
|
||||
session = ActiveSession.list(user)
|
||||
|
||||
expect(session.count).to eq 1
|
||||
expect(session.first).to have_attributes(
|
||||
ip_address: '127.0.0.1',
|
||||
browser: 'Mobile Safari',
|
||||
os: 'iOS',
|
||||
device_name: 'iPhone 6',
|
||||
device_type: 'smartphone',
|
||||
created_at: Time.zone.parse('2018-03-12 09:06'),
|
||||
updated_at: Time.zone.parse('2018-03-12 09:06'),
|
||||
session_id: '6919a6f1bb119dd7396fadc38fd18d0d'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps the created_at from the login on consecutive requests' do
|
||||
now = Time.zone.parse('2018-03-12 09:06')
|
||||
|
||||
Timecop.freeze(now) do
|
||||
ActiveSession.set(user, request)
|
||||
|
||||
Timecop.freeze(now + 1.minute) do
|
||||
ActiveSession.set(user, request)
|
||||
|
||||
session = ActiveSession.list(user)
|
||||
|
||||
expect(session.first).to have_attributes(
|
||||
created_at: Time.zone.parse('2018-03-12 09:06'),
|
||||
updated_at: Time.zone.parse('2018-03-12 09:07')
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.destroy' do
|
||||
it 'removes the entry associated with the currently killed user session' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
|
||||
redis.set("session:user:gitlab:#{user.id}:59822c7d9fcdfa03725eff41782ad97d", '')
|
||||
redis.set("session:user:gitlab:9999:5c8611e4f9c69645ad1a1492f4131358", '')
|
||||
end
|
||||
|
||||
ActiveSession.destroy(user, request.session.id)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.scan_each(match: "session:user:gitlab:*")).to match_array [
|
||||
"session:user:gitlab:#{user.id}:59822c7d9fcdfa03725eff41782ad97d",
|
||||
"session:user:gitlab:9999:5c8611e4f9c69645ad1a1492f4131358"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
it 'removes the lookup entry' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
|
||||
redis.sadd("session:lookup:user:gitlab:#{user.id}", '6919a6f1bb119dd7396fadc38fd18d0d')
|
||||
end
|
||||
|
||||
ActiveSession.destroy(user, request.session.id)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.scan_each(match: "session:lookup:user:gitlab:#{user.id}").to_a).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'removes the devise session' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
|
||||
redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", '')
|
||||
end
|
||||
|
||||
ActiveSession.destroy(user, request.session.id)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.scan_each(match: "session:gitlab:*").to_a).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not remove the devise session if the active session could not be found' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", '')
|
||||
end
|
||||
|
||||
other_user = create(:user)
|
||||
|
||||
ActiveSession.destroy(other_user, request.session.id)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.scan_each(match: "session:gitlab:*").to_a).not_to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.cleanup' do
|
||||
it 'removes obsolete lookup entries' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
|
||||
redis.sadd("session:lookup:user:gitlab:#{user.id}", '6919a6f1bb119dd7396fadc38fd18d0d')
|
||||
redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d')
|
||||
end
|
||||
|
||||
ActiveSession.cleanup(user)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to eq ['6919a6f1bb119dd7396fadc38fd18d0d']
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not bail if there are no lookup entries' do
|
||||
ActiveSession.cleanup(user)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue