From 9b33e3d36fcd46072b9fe83f1121fb0fd87c0fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20Reigel=20=28=20=F0=9F=8C=B4=20may=202nd=20-=20may?= =?UTF-8?q?=209th=20=F0=9F=8C=B4=20=29?= Date: Wed, 2 May 2018 08:08:16 +0000 Subject: [PATCH] Display and revoke active sessions --- Gemfile | 3 + Gemfile.lock | 2 + app/assets/stylesheets/framework/common.scss | 1 + app/assets/stylesheets/framework/images.scss | 35 +-- .../profiles/active_sessions_controller.rb | 14 ++ app/helpers/active_sessions_helper.rb | 23 ++ app/models/active_session.rb | 110 +++++++++ .../layouts/nav/sidebar/_profile.html.haml | 11 + .../active_sessions/_active_session.html.haml | 31 +++ .../profiles/active_sessions/index.html.haml | 14 ++ .../feature-display-active-sessions.yml | 5 + config/initializers/session_store.rb | 26 +-- config/initializers/warden.rb | 12 + config/routes/profile.rb | 1 + doc/development/fe_guide/icons.md | 2 +- doc/user/profile/active_sessions.md | 20 ++ doc/user/profile/img/active_sessions_list.png | Bin 0 -> 41649 bytes doc/user/profile/index.md | 1 + lib/gitlab/redis/shared_state.rb | 2 + .../projects/clusters/gcp_controller_spec.rb | 2 +- .../features/profiles/active_sessions_spec.rb | 89 ++++++++ spec/features/users/active_sessions_spec.rb | 69 ++++++ spec/models/active_session_spec.rb | 216 ++++++++++++++++++ 23 files changed, 642 insertions(+), 47 deletions(-) create mode 100644 app/controllers/profiles/active_sessions_controller.rb create mode 100644 app/helpers/active_sessions_helper.rb create mode 100644 app/models/active_session.rb create mode 100644 app/views/profiles/active_sessions/_active_session.html.haml create mode 100644 app/views/profiles/active_sessions/index.html.haml create mode 100644 changelogs/unreleased/feature-display-active-sessions.yml create mode 100644 doc/user/profile/active_sessions.md create mode 100644 doc/user/profile/img/active_sessions_list.png create mode 100644 spec/features/profiles/active_sessions_spec.rb create mode 100644 spec/features/users/active_sessions_spec.rb create mode 100644 spec/models/active_session_spec.rb diff --git a/Gemfile b/Gemfile index caeaae96164..a68b044b39e 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 9b2c47587ee..f11df6a283e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index e058a0b35b7..2faea55a5f5 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -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; } diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 62a0fba3da3..ab3cceceae9 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -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); + } } } diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb new file mode 100644 index 00000000000..f0cdc228366 --- /dev/null +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -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 diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb new file mode 100644 index 00000000000..97b6dac67c5 --- /dev/null +++ b/app/helpers/active_sessions_helper.rb @@ -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 diff --git a/app/models/active_session.rb b/app/models/active_session.rb new file mode 100644 index 00000000000..b4a86dbb331 --- /dev/null +++ b/app/models/active_session.rb @@ -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 diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index c878fcf2808..6cbd163dd41 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -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 diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml new file mode 100644 index 00000000000..d40b771f48b --- /dev/null +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -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 diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml new file mode 100644 index 00000000000..d0250bb4eab --- /dev/null +++ b/app/views/profiles/active_sessions/index.html.haml @@ -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 diff --git a/changelogs/unreleased/feature-display-active-sessions.yml b/changelogs/unreleased/feature-display-active-sessions.yml new file mode 100644 index 00000000000..14cfa66953e --- /dev/null +++ b/changelogs/unreleased/feature-display-active-sessions.yml @@ -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 diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index f2fde1e0048..da24881885e 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -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 +) diff --git a/config/initializers/warden.rb b/config/initializers/warden.rb index ee034d21eae..bf079f8e1a7 100644 --- a/config/initializers/warden.rb +++ b/config/initializers/warden.rb @@ -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 diff --git a/config/routes/profile.rb b/config/routes/profile.rb index bcfc17a5f66..a9ba5ac2c0b 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -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 diff --git a/doc/development/fe_guide/icons.md b/doc/development/fe_guide/icons.md index b288ee95722..b469a9c6aef 100644 --- a/doc/development/fe_guide/icons.md +++ b/doc/development/fe_guide/icons.md @@ -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 diff --git a/doc/user/profile/active_sessions.md b/doc/user/profile/active_sessions.md new file mode 100644 index 00000000000..5119c0e30d0 --- /dev/null +++ b/doc/user/profile/active_sessions.md @@ -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. diff --git a/doc/user/profile/img/active_sessions_list.png b/doc/user/profile/img/active_sessions_list.png new file mode 100644 index 0000000000000000000000000000000000000000..76a52220bcd12ddbee6339961497015b1647b633 GIT binary patch literal 41649 zcmb4rby$>N*DfllgoJdbNH+{64N?kjfLMj&>4 zZI81Kr^U}W!96Uwr3jImO_)ENM(1JR&9|{Kn~Aa)#Wou}WpVAN$WJyBZD&vPD+P|K z56sKo9_kz&>K$&{#@azvB0E*mAOZpcujUK2WMx}^TMo%bQi_}Kk&s$4doy?he?EK4Kz+L{$QQ{zF{q^6?cwZEwJ{{4BrZn<9Y zz%TQ67yBppH`KE)hclH3ql~ezu)>o?-3mGxSy+amZbGRF4eJdq;Z?3%UD+rnqp$5= zf78hQ`_}U%n5rmpX2!QCiN~Zpvetg??%Bo34wr6Ocu!Y6TPvb5>Cdm%Lr#G|&B7l; zAn%uZl2PT4o$rl&3nSq=*IQm$AvA?Thx%L38aSJS@p7Qn&(P)*xeO)VwqV-kzbOq3 z*N1atV_;ywg^SA<4bf1<-yd3BQxg)J?Bh|3t?D|zix|nHp zDcdv>UPXM_1hy~aGbFsh*cCR!`T0Nhd{>BqxNY|^d^o}(=l>KQ?reB0dOMr9=8K4O za@rzbB3uSF4T#1WDeYN2l}wTQ-L~VuLt^}zoD0cN9;QA@7xzR@-JYzBt1;bN3xd!1 z5{s=kSqkg=O_bZ+8<{Ax(S@7iIFa#r9$S~WpC4Mv%cK8R9n3N3Er7VJ58p^_)zpan z_*}=!;bGx2n5h&Sd_f^(s}iIe&40-Oo66JS!(Q_7@d>gmx1D-c^lp^XoTtKK;2Tj} z-Od(9(*N@(2zoQZQnOFa~(aSER#e7Rhe?DWOL2Xx-v(U~| zHO4MJDN$LXSx;L8#hMcaD(X|af}vV(26KlM&S5^=N#We!r#0O~T(B)ozI}O{9iCu3 znjqnFyBRfXw**#AW+9>1c`J)KD#;qrb}juJcC@I&@_Qewqo~CzlqZrHNqgBa!cv*b z2c~O1*O#RD^HX@=@+hCN<24e8OFBBwu$0a>XV*5pLXg zcCbPyEHaT}Zim3Vf1iH$A>!euI1S5a6>B_Cwr>`#=My+|2U*O&Nx7b; zkFkzVA9v$vuJSvPLV^=hQ-IDb&xBfoi;qP?oUomE(>c&h09r5T7H+0D9K5!MG+lU zwnLapO=Em{$^yL@-o!yp*Wn93(aO^{4(q-X=x%e? ziDva`|6X{owS%ir-g@D_10klyy%@$DQ1E42HdS99-26RFot~hWFNl_yA``1^Ml)V;> zCkpqJKR<5lMiC@M;5wEoCvw8rGnFGUP+wy)sve}aJMXg{VHP@lic@ZxUH|iI0jsfC z+^FFLh`Z-rcrp5Kep}pGE}8e(ncjP+GVR~RR`#>8wc11Z%Z0D&eXd#xH-8UB zi`dVi>@9UCN*R1e;tQngW%Td87tJIy-C6j_7f){=?Gc>l-mE@CU|$u_D9sJsDeT(e zWP3_vtwUdB4)&GZ;=avMiom}zt2uX#W;#T2s319tXIB+OMMb%}xt*MxaB*_} z{2BV+Y4YEB^S=`6-@N%ZpF& zdFS!k+S>5&%k&i&{q~#Z7#p_(&dA7!W5l9UQB%`;^+>_h!=tjiyu7sZQE-+e`5Se0 z_2PHH#EFTCgM$M?LP9tku9KjzukY>c-MZ~ugbRjnvAD>HO(ch}P*zq(EUj%|5ZNp! zD0to{DJf}^(^Y~k$=^HW4UK~Ty?0^3#MYJ*o1KG0pci5e6(Y2?vzwWoew$NQUr$0p zBE!~q@a-`!9^UBaC>}2E+mkkJ3^3Q=YgBJ^5&}nhdsQiqudjVvUCXMfQnJ^YnwplE z%?n2MA}j9guLTToF^wT^UmD{J2API0tg;x_s#+b5p+>`d8N^`=z8 zcVHq9g@aehwe!-~*GsmCj)u4OUxCb_BnuODLvS+A5K&P^US)DC;!#09htAUZKdOfv4E7OYLB5m|!qn6%Y;o?v2|#=gu^OVdDbY8N#~#_LhBkfd~A zPt*C_r|~|- ^T`(4?(0VD<A*d3-KUu?U&5DTTR_ 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 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 diff --git a/spec/features/users/active_sessions_spec.rb b/spec/features/users/active_sessions_spec.rb new file mode 100644 index 00000000000..631d7e3bced --- /dev/null +++ b/spec/features/users/active_sessions_spec.rb @@ -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 diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb new file mode 100644 index 00000000000..129b2f92683 --- /dev/null +++ b/spec/models/active_session_spec.rb @@ -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