mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-12-25 09:13:05 +08:00
Merge pull request #5192 from scinote-eln/features/token-revocation
Features/token revocation
This commit is contained in:
commit
00191e41de
18 changed files with 370 additions and 0 deletions
25
app/assets/javascripts/users/connected_devices.js
Normal file
25
app/assets/javascripts/users/connected_devices.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
/* global I18n */
|
||||
|
||||
(function() {
|
||||
let connectedDeviceDescription = $('.connected-devices-description');
|
||||
let revocationModal = $('.device-revocation-modal');
|
||||
let deleteButtonModal = $('#confirm-device-remove');
|
||||
|
||||
$('.x-button').on('click', function() {
|
||||
deleteButtonModal.attr('href', $(this).data('url'));
|
||||
deleteButtonModal.data('id', $(this).closest('.table-row').data('id'));
|
||||
});
|
||||
|
||||
deleteButtonModal
|
||||
.on('ajax:success', function() {
|
||||
$(`.table-row[data-id=${deleteButtonModal.data('id')}]`).remove();
|
||||
|
||||
// Show correct representation if user does not have any connected device anymore
|
||||
if (connectedDeviceDescription.find('.table-row').length === 0) {
|
||||
$('.connected-devices-container').remove();
|
||||
connectedDeviceDescription.append(`<p>${I18n.t('users.registrations.edit.connected_devices.empty_state')}</p>`);
|
||||
}
|
||||
|
||||
revocationModal.modal('hide');
|
||||
});
|
||||
}());
|
62
app/assets/stylesheets/settings/device_table.scss
Normal file
62
app/assets/stylesheets/settings/device_table.scss
Normal file
|
@ -0,0 +1,62 @@
|
|||
// scss-lint:disable SelectorDepth NestingDepth IdSelector
|
||||
#devicesTable {
|
||||
.devices-table {
|
||||
display: grid;
|
||||
grid-auto-rows: 3em 1px;
|
||||
grid-template-columns: 200px 150px 50px;
|
||||
min-width: 100%;
|
||||
|
||||
.table-header-cell {
|
||||
align-items: center;
|
||||
background-color: $color-concrete;
|
||||
border: 1px solid $color-white;
|
||||
display: flex;
|
||||
height: 3em;
|
||||
padding: 0 .5em;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: contents;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
}
|
||||
|
||||
.table-body {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.table-body-cell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 .5em;
|
||||
}
|
||||
|
||||
.x-button {
|
||||
background: 0;
|
||||
border: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: contents;
|
||||
|
||||
&:hover {
|
||||
.table-body-cell {
|
||||
background-color: $color-concrete;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: $color-concrete;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
grid-column: 1/-1;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -119,6 +119,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.device-revocation-modal {
|
||||
.modal-dialog {
|
||||
height: 216px;
|
||||
width: 370px;
|
||||
}
|
||||
}
|
||||
|
||||
.manage-devices {
|
||||
position: relative;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.user-settings {
|
||||
.two-factor-container {
|
||||
|
|
|
@ -17,6 +17,8 @@ module TokenAuthentication
|
|||
@token = request.headers['Authorization']&.sub('Bearer ', '')
|
||||
raise JWT::VerificationError, I18n.t('api.core.missing_token') unless @token
|
||||
|
||||
check_token_revocation!
|
||||
|
||||
@token_iss = Api::CoreJwt.read_iss(@token)
|
||||
raise JWT::InvalidPayload, I18n.t('api.core.no_iss') unless @token_iss
|
||||
|
||||
|
@ -34,4 +36,10 @@ module TokenAuthentication
|
|||
@current_user = User.find_by(id: payload['sub'])
|
||||
raise JWT::InvalidPayload, I18n.t('api.core.no_user_mapping') unless current_user
|
||||
end
|
||||
|
||||
def check_token_revocation!
|
||||
if Doorkeeper::AccessToken.where.not(revoked_at: nil).exists?(token: @token)
|
||||
raise JWT::VerificationError, I18n.t('api.core.expired_token')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
17
app/controllers/doorkeeper/access_tokens_controller.rb
Normal file
17
app/controllers/doorkeeper/access_tokens_controller.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Doorkeeper
|
||||
class AccessTokensController < ApplicationController
|
||||
before_action :find_token
|
||||
|
||||
def revoke
|
||||
@token.revoke
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_token
|
||||
@token = current_user.access_tokens.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
18
app/controllers/users/connected_devices_controller.rb
Normal file
18
app/controllers/users/connected_devices_controller.rb
Normal file
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Users
|
||||
class ConnectedDevicesController < ApplicationController
|
||||
before_action :check_delete_permissions, only: :destroy
|
||||
|
||||
def destroy
|
||||
@connected_device.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_delete_permissions
|
||||
@connected_device = ConnectedDevice.for_user(current_user).find_by(id: params[:id])
|
||||
render_403 if @connected_device.blank?
|
||||
end
|
||||
end
|
||||
end
|
|
@ -184,6 +184,11 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@connected_devices = ConnectedDevice.for_user(current_user)
|
||||
super
|
||||
end
|
||||
|
||||
def two_factor_enable
|
||||
user = current_user || User.find_by(id: session[:otp_user_id])
|
||||
if user.valid_otp?(params[:submit_code])
|
||||
|
|
40
app/models/connected_device.rb
Normal file
40
app/models/connected_device.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ConnectedDevice < ApplicationRecord
|
||||
belongs_to :oauth_access_token, class_name: 'Doorkeeper::AccessToken'
|
||||
validates :uid, presence: true
|
||||
|
||||
after_destroy :revoke_token
|
||||
|
||||
def self.for_user(user)
|
||||
where(oauth_access_token_id: Doorkeeper::AccessToken.select(:id).where(resource_owner_id: user.id))
|
||||
end
|
||||
|
||||
def self.from_request_headers(headers, token = nil)
|
||||
return unless headers['Device-Id']
|
||||
|
||||
current_token = Doorkeeper::AccessToken.find_by(
|
||||
token: headers['Authorization']&.gsub(/Bearer\s/, '')
|
||||
)
|
||||
|
||||
return unless token || current_token
|
||||
|
||||
connected_device = ConnectedDevice.find_or_initialize_by(uid: headers['Device-Id'])
|
||||
connected_device.update!(
|
||||
name: headers['Device-Name'],
|
||||
metadata: {
|
||||
os: headers['Device-Os'],
|
||||
app_version: headers['Device-App-Version']
|
||||
}.compact,
|
||||
last_seen_at: Time.current,
|
||||
oauth_access_token_id: token&.id || current_token&.id
|
||||
)
|
||||
connected_device
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def revoke_token
|
||||
oauth_access_token.revoke
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
<div class="modal device-revocation-modal" id="deviceRevocationModal" tabindex="-1" role="dialog" data-backdrop="static">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span></button>
|
||||
<h2 class="modal-title"><%= t("users.registrations.edit.device_revocation_modal.title") %></h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p><%= t("users.registrations.edit.device_revocation_modal.description") %></p>
|
||||
<h3><%= t("users.registrations.edit.device_revocation_modal.validation") %></h3>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal"><%= t('general.cancel') %></button>
|
||||
<%= link_to t("users.registrations.edit.device_revocation_modal.remove"), '#', id: "confirm-device-remove", remote:true ,method: :delete, type: 'button', class: 'btn btn-primary' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
44
app/views/users/registrations/_manage_devices.html.erb
Normal file
44
app/views/users/registrations/_manage_devices.html.erb
Normal file
|
@ -0,0 +1,44 @@
|
|||
<div class="connected-devices-description">
|
||||
<h3><%= t("users.registrations.edit.connected_devices.title") %></h3>
|
||||
<% if @connected_devices.present? %>
|
||||
<div class="connected-devices-container">
|
||||
<p><%= t("users.registrations.edit.connected_devices.description") %></p>
|
||||
<div id="devicesTable">
|
||||
<div class="devices-table-container">
|
||||
<div class="devices-table">
|
||||
<div class="table-header">
|
||||
<div class="table-header-cell">
|
||||
<%= t("users.registrations.edit.devices_table.device_name") %>
|
||||
</div>
|
||||
<div class="table-header-cell">
|
||||
<%= t("users.registrations.edit.devices_table.added_on") %>
|
||||
</div>
|
||||
<div class="table-header-cell">
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-body">
|
||||
<% @connected_devices.each do |device| %>
|
||||
<div class="table-row" data-id=<%= device.id%>>
|
||||
<div class="table-body-cell">
|
||||
<%= device.name %>
|
||||
</div>
|
||||
<div class="table-body-cell">
|
||||
<%= l(device.last_seen_at, format: :full_date) %>
|
||||
</div>
|
||||
<div class="table-body-cell">
|
||||
<button class="x-button" id="revokeDeviceBtn" data-toggle="modal" data-url=<%= connected_device_path(device.id) %> data-target="#deviceRevocationModal"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p><%= t("users.registrations.edit.connected_devices.empty_state") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render partial: 'device_revocation_modal' %>
|
||||
<%= javascript_include_tag 'users/connected_devices' %>
|
|
@ -38,6 +38,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<% if Rails.application.config.x.connected_devices_enabled %>
|
||||
<!-- Manage devices -->
|
||||
<div class="manage-devices">
|
||||
<%= render partial: 'users/registrations/manage_devices' %>
|
||||
<div>
|
||||
<% end %>
|
||||
|
||||
<!-- User statistics -->
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
@ -70,6 +77,7 @@
|
|||
</div>
|
||||
|
||||
<%= render partial: '2fa_modal' %>
|
||||
|
||||
<%= render partial: 'users/shared/user_avatars_modal' %>
|
||||
|
||||
<%= javascript_pack_tag 'custom/croppie' %>
|
||||
|
|
|
@ -57,6 +57,8 @@ module Scinote
|
|||
|
||||
config.x.webhooks_enabled = ENV['ENABLE_WEBHOOKS'] == 'true'
|
||||
|
||||
config.x.connected_devices_enabled = ENV['CONNECTED_DEVICES_ENABLED'] == 'true'
|
||||
|
||||
# Logging
|
||||
config.log_formatter = proc do |severity, datetime, progname, msg|
|
||||
"[#{datetime}] #{severity}: #{msg}\n"
|
||||
|
@ -69,6 +71,19 @@ module Scinote
|
|||
config.to_prepare do
|
||||
# Only Authorization endpoint
|
||||
Doorkeeper::AuthorizationsController.layout 'sign_in_halt'
|
||||
|
||||
# Add Connected Device logging
|
||||
Doorkeeper::TokensController.class_eval do
|
||||
after_action :log_connected_device, only: :create
|
||||
|
||||
private
|
||||
|
||||
def log_connected_device
|
||||
return if @authorize_response.is_a?(Doorkeeper::OAuth::ErrorResponse)
|
||||
|
||||
ConnectedDevice.from_request_headers(request.headers, @authorize_response&.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
config.action_view.field_error_proc = Proc.new { |html_tag, instance|
|
||||
|
|
|
@ -97,6 +97,7 @@ Rails.application.config.assets.precompile += %w(reports/save_pdf_to_inventory.j
|
|||
Rails.application.config.assets.precompile += %w(reports/content.js)
|
||||
Rails.application.config.assets.precompile += %w(session_end.js)
|
||||
Rails.application.config.assets.precompile += %w(label_templates/label_templates_datatable.js)
|
||||
Rails.application.config.assets.precompile += %w(users/connected_devices.js)
|
||||
Rails.application.config.assets.precompile += %w(BrowserPrint-3.0.216.min.js)
|
||||
Rails.application.config.assets.precompile += %w(BrowserPrint-Zebra-1.0.216.min.js)
|
||||
|
||||
|
|
|
@ -2338,6 +2338,19 @@ en:
|
|||
2fa_errors:
|
||||
wrong_submit_code: "Not correct code"
|
||||
wrong_password: "Not correct password"
|
||||
connected_devices:
|
||||
title: "Manage connected devices"
|
||||
description: "When you remove a device, you will be logged out from the selected device."
|
||||
empty_state: "No connected devices."
|
||||
devices_table:
|
||||
device_name: "Device Name"
|
||||
added_on: "Added on"
|
||||
device_revocation_modal:
|
||||
title: "Remove connected device"
|
||||
description: "This device will lose access to your SciNote account."
|
||||
validation: "Are you sure you want to remove it?"
|
||||
cancel: "Cancel"
|
||||
remove: "Remove"
|
||||
new:
|
||||
head_title: "Sign up"
|
||||
team_name_label: "Team name"
|
||||
|
|
|
@ -3,6 +3,8 @@ Rails.application.routes.draw do
|
|||
skip_controllers :applications, :authorized_applications, :token_info
|
||||
end
|
||||
|
||||
post 'access_tokens/revoke', to: 'doorkeeper/access_tokens#revoke'
|
||||
|
||||
# Addons
|
||||
|
||||
def draw(routes_name)
|
||||
|
@ -736,6 +738,8 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
resources :connected_devices, controller: 'users/connected_devices', only: %i(destroy)
|
||||
|
||||
get 'search' => 'search#index'
|
||||
get 'search/new' => 'search#new', as: :new_search
|
||||
|
||||
|
|
15
db/migrate/20230207140811_create_connected_devices.rb
Normal file
15
db/migrate/20230207140811_create_connected_devices.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateConnectedDevices < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :connected_devices do |t|
|
||||
t.string :uid
|
||||
t.string :name
|
||||
t.references :oauth_access_token, null: false, foreign_key: true
|
||||
t.json :metadata
|
||||
t.timestamp :last_seen_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
36
spec/controllers/concerns/token_authentication_spec.rb
Normal file
36
spec/controllers/concerns/token_authentication_spec.rb
Normal file
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
class TokenAuthenticatedController
|
||||
attr_accessor :token, :current_user
|
||||
|
||||
include TokenAuthentication
|
||||
|
||||
def request
|
||||
OpenStruct.new(
|
||||
headers: { 'Authorization' => "Bearer #{@token}" }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe TokenAuthentication do
|
||||
let(:test_controller_instance) { TokenAuthenticatedController.new }
|
||||
|
||||
let(:user) { create :user }
|
||||
let(:access_token) {
|
||||
user.access_tokens.create(
|
||||
expires_in: 7500
|
||||
)
|
||||
}
|
||||
|
||||
describe '#authenticate_request' do
|
||||
it "rejects revoked token" do
|
||||
test_controller_instance.token = access_token.token
|
||||
test_controller_instance.current_user = user
|
||||
|
||||
access_token.revoke
|
||||
expect { test_controller_instance.send(:authenticate_request!) }.to raise_error(JWT::VerificationError)
|
||||
end
|
||||
end
|
||||
end
|
28
spec/controllers/doorkeeper/access_tokens_controller_spec.rb
Normal file
28
spec/controllers/doorkeeper/access_tokens_controller_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Doorkeeper::AccessTokensController, type: :controller do
|
||||
login_user
|
||||
|
||||
let!(:access_token) do
|
||||
subject.current_user.access_tokens.create(expires_in: 7500)
|
||||
end
|
||||
|
||||
describe 'POST revoke' do
|
||||
let(:params) do
|
||||
{
|
||||
id: access_token.id
|
||||
}
|
||||
end
|
||||
|
||||
let(:action) do
|
||||
put :revoke, params: params
|
||||
end
|
||||
|
||||
it 'revokes the access token' do
|
||||
action
|
||||
expect(access_token.reload.revoked_at).to_not be_nil
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue