diff --git a/app/assets/stylesheets/settings/users.scss b/app/assets/stylesheets/settings/users.scss index 383a4d4ff..d2822724b 100644 --- a/app/assets/stylesheets/settings/users.scss +++ b/app/assets/stylesheets/settings/users.scss @@ -146,3 +146,36 @@ } } } + +.api-key-container { + border: $border-default; + padding: 1em; + + .title { + margin-top: 0; + } + + .description { + margin-bottom: .5em; + } + + .api-key-display { + margin: 1em 0; + } + + .api-key-field { + width: 100%; + } + + .api-key-controls { + display: flex; + + .btn { + margin-right: .5em; + } + } + + .api-key-error { + color: $brand-danger; + } +} diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index f1fdc5d96..72eb1780e 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -106,6 +106,14 @@ module Api ) end + rescue_from Api::V1::ApiKeyError do |e| + render_error( + e.message, + I18n.t('api.core.invalid_api_key_detail'), + :unauthorized + ) + end + before_action :check_include_param, only: %i(index show) def index diff --git a/app/controllers/concerns/token_authentication.rb b/app/controllers/concerns/token_authentication.rb index 46f967bfa..1bcb4aa24 100644 --- a/app/controllers/concerns/token_authentication.rb +++ b/app/controllers/concerns/token_authentication.rb @@ -1,5 +1,12 @@ # frozen_string_literal: true +module Api + module V1 + class ApiKeyError < StandardError + end + end +end + module TokenAuthentication extend ActiveSupport::Concern @@ -13,7 +20,21 @@ module TokenAuthentication raise JWT::InvalidPayload, I18n.t('api.core.no_azure_user_mapping') unless current_user end + def authenticate_with_api_key + @api_key = request.headers['Api-Key'] + return unless @api_key + + @current_user = User.from_api_key(@api_key) + + raise Api::V1::ApiKeyError, I18n.t('api.core.invalid_api_key') unless @current_user + + @current_user + end + def authenticate_request! + # API key authentication successful + return if authenticate_with_api_key + @token = request.headers['Authorization']&.sub('Bearer ', '') raise JWT::VerificationError, I18n.t('api.core.missing_token') unless @token diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 33b948b9b..5758f98dc 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -207,6 +207,18 @@ class Users::RegistrationsController < Devise::RegistrationsController render json: { qr_code: create_2fa_qr_code(current_user) } end + def regenerate_api_key + current_user.regenerate_api_key! + + redirect_to edit_user_registration_path + end + + def revoke_api_key + current_user.revoke_api_key! + + redirect_to edit_user_registration_path + end + protected # Called upon creating User (before .save). Permits parameters and extracts diff --git a/app/models/user.rb b/app/models/user.rb index 535cbbf76..b1ea01d8c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -509,6 +509,21 @@ class User < ApplicationRecord .find_by(user_identities: { provider: provider_conf['provider_name'], uid: token_payload[:sub] }) end + def self.from_api_key(api_key) + where('api_key_expires_at > ?', Time.current).find_by(api_key: api_key) + end + + def regenerate_api_key! + update!( + api_key: SecureRandom.urlsafe_base64(33), + api_key_expires_at: Constants::API_KEY_EXPIRES_IN.from_now + ) + end + + def revoke_api_key! + update!(api_key: nil, api_key_expires_at: nil) + end + def has_linked_account?(provider) user_identities.exists?(provider: provider) end diff --git a/app/views/users/registrations/edit.html.erb b/app/views/users/registrations/edit.html.erb index 2a7ba17f7..958595cea 100644 --- a/app/views/users/registrations/edit.html.erb +++ b/app/views/users/registrations/edit.html.erb @@ -35,6 +35,10 @@ <%= render partial: 'users/registrations/edit_partials/2fa' %> + + <% if Rails.application.config.x.core_api_v1_enabled %> + <%= render partial: 'users/registrations/edit_partials/api_key' %> + <% end %> diff --git a/app/views/users/registrations/edit_partials/_api_key.html.erb b/app/views/users/registrations/edit_partials/_api_key.html.erb new file mode 100644 index 000000000..bdfd1c978 --- /dev/null +++ b/app/views/users/registrations/edit_partials/_api_key.html.erb @@ -0,0 +1,24 @@ +
+

<%= t("users.registrations.edit.api_key.title") %>

+
+ <%= t("users.registrations.edit.api_key.description") %> +
+ <% if current_user.api_key %> +
+ <%= text_field_tag :api_key, current_user.api_key, class: "api-key-field", disabled: "disabled" %> + <% if current_user.api_key_expires_at < Time.current %> +

+ <%= t("users.registrations.edit.api_key.expired") %> +

+ <% end %> +
+ <% end %> +
+ <% if current_user.api_key %> + <%= button_to t("users.registrations.edit.api_key.regenerate"), users_api_key_regenerate_path, class: "btn btn-primary" %> + <%= button_to t("users.registrations.edit.api_key.revoke"), users_api_key_revoke_path, class: "btn btn-danger" %> + <% else %> + <%= button_to t("users.registrations.edit.api_key.generate"), users_api_key_regenerate_path, class: "btn btn-primary" %> + <% end %> +
+
diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index 45e36763f..c5a9810cb 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -247,6 +247,8 @@ class Constants TWO_FACTOR_RECOVERY_CODE_COUNT = 6 TWO_FACTOR_RECOVERY_CODE_LENGTH = 12 + API_KEY_EXPIRES_IN = 1.year + #============================================================================= # Protocol importers #============================================================================= diff --git a/config/locales/en.yml b/config/locales/en.yml index edf5414e3..2aa9413e9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2825,6 +2825,13 @@ en: validation: "Are you sure you want to remove it?" cancel: "Cancel" remove: "Remove" + api_key: + title: "API Key" + description: "Generate or revoke an API key for use with the SciNote API. Regenerating the API key will invalidate the old one." + generate: "Generate" + regenerate: "Regenerate" + revoke: "Revoke" + expired: "This key has expired!" new: head_title: "Sign up" team_name_label: "Team name" @@ -4038,6 +4045,8 @@ en: api: core: status_ok: "Ok" + invalid_api_key: "API key is invalid or expired" + invalid_api_key_detail: "The API key you are using does not exist or has expired." expired_token: "Token is expired" invalid_token: "Token is invalid" missing_token: "Core: No token in the header" diff --git a/config/routes.rb b/config/routes.rb index 76e839b6d..8964c7d88 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -852,6 +852,9 @@ Rails.application.routes.draw do post 'users/2fa_enable' => 'users/registrations#two_factor_enable' post 'users/2fa_disable' => 'users/registrations#two_factor_disable' get 'users/2fa_qr_code' => 'users/registrations#two_factor_qr_code' + + post 'users/api_key_regenerate' => 'users/registrations#regenerate_api_key' + post 'users/api_key_revoke' => 'users/registrations#revoke_api_key' end namespace :api, defaults: { format: 'json' } do diff --git a/db/migrate/20220712110253_add_api_key_to_users.rb b/db/migrate/20220712110253_add_api_key_to_users.rb new file mode 100644 index 000000000..b110c4a66 --- /dev/null +++ b/db/migrate/20220712110253_add_api_key_to_users.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddApiKeyToUsers < ActiveRecord::Migration[6.1] + def change + change_table :users, bulk: true do |t| + t.string :api_key + t.datetime :api_key_expires_at + end + end +end