Skip to content
This repository has been archived by the owner on Apr 3, 2024. It is now read-only.

Commit

Permalink
Feature: Manage Active Sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
yshmarov committed Mar 24, 2024
1 parent a329139 commit 0280647
Show file tree
Hide file tree
Showing 13 changed files with 152 additions and 2 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ group :test do
end

gem "devise", "~> 4.9"

gem "device_detector", "~> 1.1"
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ GEM
debug (1.9.1)
irb (~> 1.10)
reline (>= 0.3.8)
device_detector (1.1.2)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
Expand Down Expand Up @@ -322,6 +323,7 @@ DEPENDENCIES
brakeman
capybara
debug
device_detector (~> 1.1)
devise (~> 4.9)
importmap-rails
jbuilder
Expand Down
39 changes: 39 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
class ApplicationController < ActionController::Base
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

before_action :require_login, if: :current_user

def after_sign_in_path_for(resource)
# create_login # you can move this to your sessions_controller#create
root_path
end

private

def create_login
device_id = Digest::SHA256.hexdigest("#{request.user_agent}#{request.remote_ip}")
current_login = current_user.logins.find_or_create_by(device_id: device_id, ip_address: request.remote_ip, user_agent: request.user_agent)
session[:device_id] = device_id
end

# trigger this in your sessions_controller#destroy
def destroy_login
current_user.logins.find_by(device_id: session[:device_id])&.destroy
session.delete(:device_id)
end

def require_login
# after_sign_in_path_for is triggered after require_login
# return if controller_path == 'devise/sessions' && action_name == 'create'
return if controller_path == 'users/sessions' && action_name == 'create' # if you are overriding devise sessions_controller

Check failure on line 29 in app/controllers/application_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

Check failure on line 29 in app/controllers/application_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

if Rails.env.test?
# mock
current_login = current_user.logins.create(device_id: "test_device_id")
else
current_login = current_user.logins.find_by(device_id: session[:device_id])
end

if current_login.nil?
sign_out current_user
redirect_to new_user_session_path, alert: "Device not recognized."
end
end
end
13 changes: 13 additions & 0 deletions app/controllers/logins_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class LoginsController < ApplicationController
before_action :authenticate_user!

def index
@logins = current_user.logins
end

def destroy
@login = current_user.logins.find(params[:id])
@login.destroy!
redirect_to logins_url, notice: "Device disconnected."
end
end
31 changes: 31 additions & 0 deletions app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

class Users::SessionsController < Devise::SessionsController
# before_action :configure_sign_in_params, only: [:create]

# GET /resource/sign_in
# def new
# super
# end

# POST /resource/sign_in
def create
# add require_login
super do |resource|
create_login if resource.persisted?
end
end

# DELETE /resource/sign_out
def destroy
destroy_login
super
end

# protected

# If you have extra params to permit, append them to the sanitizer.
# def configure_sign_in_params
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
# end
end
6 changes: 6 additions & 0 deletions app/helpers/logins_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module LoginsHelper
def device_description(user_agent)
device = DeviceDetector.new(user_agent)
[device.name, device.os_name, device.device_type].join(' / ')

Check failure on line 4 in app/helpers/logins_helper.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.

Check failure on line 4 in app/helpers/logins_helper.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.

Check failure on line 4 in app/helpers/logins_helper.rb

View workflow job for this annotation

GitHub Actions / lint

Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.
end
end
3 changes: 3 additions & 0 deletions app/models/login.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Login < ApplicationRecord
belongs_to :user
end
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ class User < ApplicationRecord
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable

has_many :logins
end
1 change: 1 addition & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<body>
<%= link_to 'Home', root_path %>
<%= link_to 'Admin', admin_path %>
<%= link_to 'Logins', logins_path %>
<% if signed_in? %>
<%= link_to current_user.email, edit_user_registration_path %>
<%= button_to "Log out", destroy_user_session_path, method: :delete, data: { turbo: "false" } %>
Expand Down
23 changes: 23 additions & 0 deletions app/views/logins/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<h1>
Logins:
<%= @logins.size %>
</h1>

<% @logins.order(updated_at: :desc).each do |login| %>
<div class="border">
<%= device_description(login.user_agent) %>

Last login at:
<%= login.updated_at %>

IP address:
<%= login.ip_address %>
<% if login.device_id == session[:device_id] %>
<span class="text-green-500">current session</span>
<% else %>
<%= button_to 'Disconnect', login_path(login), method: :delete, class: "text-red-500" %>
<% end %>

</div>
<% end %>
6 changes: 5 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
Rails.application.routes.draw do
devise_for :users
devise_for :users, controllers: {
sessions: 'users/sessions'
}
resources :logins
# devise_for :users
get "admin", to: "home#admin"
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20240324113711_create_logins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateLogins < ActiveRecord::Migration[7.2]
def change
create_table :logins do |t|
t.references :user, null: false, foreign_key: true
t.string :device_id
t.string :ip_address
t.string :user_agent

t.timestamps
end
end
end
14 changes: 13 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 0280647

Please sign in to comment.