diff --git a/app/assets/javascripts/users/connected_devices.js b/app/assets/javascripts/users/connected_devices.js new file mode 100644 index 000000000..c99c18e23 --- /dev/null +++ b/app/assets/javascripts/users/connected_devices.js @@ -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(`
${I18n.t('users.registrations.edit.connected_devices.empty_state')}
`); + } + + revocationModal.modal('hide'); + }); +}()); diff --git a/app/assets/stylesheets/settings/device_table.scss b/app/assets/stylesheets/settings/device_table.scss new file mode 100644 index 000000000..210bf4b55 --- /dev/null +++ b/app/assets/stylesheets/settings/device_table.scss @@ -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; + } + } + } +} diff --git a/app/assets/stylesheets/settings/users.scss b/app/assets/stylesheets/settings/users.scss index f1984281b..ef844a210 100644 --- a/app/assets/stylesheets/settings/users.scss +++ b/app/assets/stylesheets/settings/users.scss @@ -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 { diff --git a/app/controllers/concerns/token_authentication.rb b/app/controllers/concerns/token_authentication.rb index a6ed6d2cd..46f967bfa 100644 --- a/app/controllers/concerns/token_authentication.rb +++ b/app/controllers/concerns/token_authentication.rb @@ -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 diff --git a/app/controllers/doorkeeper/access_tokens_controller.rb b/app/controllers/doorkeeper/access_tokens_controller.rb new file mode 100644 index 000000000..809f16907 --- /dev/null +++ b/app/controllers/doorkeeper/access_tokens_controller.rb @@ -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 diff --git a/app/controllers/users/connected_devices_controller.rb b/app/controllers/users/connected_devices_controller.rb new file mode 100644 index 000000000..59d1daf05 --- /dev/null +++ b/app/controllers/users/connected_devices_controller.rb @@ -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 diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index c1f7e9d11..056d0ee4b 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -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]) diff --git a/app/models/connected_device.rb b/app/models/connected_device.rb new file mode 100644 index 000000000..40771bc4a --- /dev/null +++ b/app/models/connected_device.rb @@ -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 diff --git a/app/views/users/registrations/_device_revocation_modal.html.erb b/app/views/users/registrations/_device_revocation_modal.html.erb new file mode 100644 index 000000000..6a4da209d --- /dev/null +++ b/app/views/users/registrations/_device_revocation_modal.html.erb @@ -0,0 +1,19 @@ +<%= t("users.registrations.edit.connected_devices.description") %>
+<%= t("users.registrations.edit.connected_devices.empty_state") %>
+ <% end %> +